mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
chore: merged main
This commit is contained in:
@ -27,6 +27,8 @@ NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
|
|||||||
# [[URLS]]
|
# [[URLS]]
|
||||||
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||||
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
|
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
|
||||||
|
# URL used by the web app to request itself (e.g. local background jobs)
|
||||||
|
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
|
||||||
|
|
||||||
# [[DATABASE]]
|
# [[DATABASE]]
|
||||||
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||||
|
|||||||
4
.github/actions/cache-build/action.yml
vendored
4
.github/actions/cache-build/action.yml
vendored
@ -3,7 +3,7 @@ description: 'Cache or restore if necessary'
|
|||||||
inputs:
|
inputs:
|
||||||
node_version:
|
node_version:
|
||||||
required: false
|
required: false
|
||||||
default: v18.x
|
default: v20.x
|
||||||
runs:
|
runs:
|
||||||
using: 'composite'
|
using: 'composite'
|
||||||
steps:
|
steps:
|
||||||
@ -17,7 +17,7 @@ runs:
|
|||||||
**/.turbo/**
|
**/.turbo/**
|
||||||
**/dist/**
|
**/dist/**
|
||||||
|
|
||||||
key: prod-build-${{ github.run_id }}
|
key: prod-build-${{ github.run_id }}-${{ hashFiles('package-lock.json') }}
|
||||||
restore-keys: prod-build-
|
restore-keys: prod-build-
|
||||||
|
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|||||||
2
.github/actions/node-install/action.yml
vendored
2
.github/actions/node-install/action.yml
vendored
@ -2,7 +2,7 @@ name: 'Setup node and cache node_modules'
|
|||||||
inputs:
|
inputs:
|
||||||
node_version:
|
node_version:
|
||||||
required: false
|
required: false
|
||||||
default: v18.x
|
default: v20.x
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: 'composite'
|
using: 'composite'
|
||||||
|
|||||||
3
.github/workflows/e2e-tests.yml
vendored
3
.github/workflows/e2e-tests.yml
vendored
@ -32,6 +32,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: npm run ci
|
run: npm run ci
|
||||||
|
env:
|
||||||
|
# Needed since we use next start which will set the NODE_ENV to production
|
||||||
|
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH: './example/cert.p12'
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,5 +1,7 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
packages/prisma/generated/types.ts
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules
|
node_modules
|
||||||
.pnp
|
.pnp
|
||||||
|
|||||||
@ -303,6 +303,10 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
[](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
|
[](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
|
||||||
|
|
||||||
|
## Elestio
|
||||||
|
|
||||||
|
[](https://elest.io/open-source/documenso)
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### I'm not receiving any emails when using the developer quickstart.
|
### I'm not receiving any emails when using the developer quickstart.
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"next": "14.0.3",
|
"next": "14.2.6",
|
||||||
"next-plausible": "^3.12.0",
|
"next-plausible": "^3.12.0",
|
||||||
"nextra": "^2.13.4",
|
"nextra": "^2.13.4",
|
||||||
"nextra-theme-docs": "^2.13.4",
|
"nextra-theme-docs": "^2.13.4",
|
||||||
|
|||||||
@ -12,5 +12,6 @@
|
|||||||
"title": "API & Integration Guides"
|
"title": "API & Integration Guides"
|
||||||
},
|
},
|
||||||
"public-api": "Public API",
|
"public-api": "Public API",
|
||||||
|
"embedding": "Embedding",
|
||||||
"webhooks": "Webhooks"
|
"webhooks": "Webhooks"
|
||||||
}
|
}
|
||||||
131
apps/documentation/pages/developers/embedding/index.mdx
Normal file
131
apps/documentation/pages/developers/embedding/index.mdx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
title: Get Started
|
||||||
|
description: Learn how to use embedding to bring signing to your own website or application
|
||||||
|
---
|
||||||
|
|
||||||
|
# Embedding
|
||||||
|
|
||||||
|
Our embedding feature lets you integrate our document signing experience into your own application or website. Whether you're building with React, Preact, Vue, Svelte, Solid, or using generalized web components, this guide will help you get started with embedding Documenso.
|
||||||
|
|
||||||
|
## Availability
|
||||||
|
|
||||||
|
Embedding is currently available for all users on a **Teams Plan** and above, as well as **Early Adopter's** within a team (Early Adopters can create a team for free).
|
||||||
|
|
||||||
|
In the future, we will roll out a **Platform Plan** that will offer additional enhancements for embedding, including the option to remove Documenso branding for a more customized experience.
|
||||||
|
|
||||||
|
## How Embedding Works
|
||||||
|
|
||||||
|
Embedding with Documenso allows you to handle document signing in two main ways:
|
||||||
|
|
||||||
|
1. **Using Direct Templates**: Using direct templates you can have an evergreen template that upon completion will create a new document within Documenso.
|
||||||
|
2. **Using a Signing Token**: A more advanced option for those running rich integrations with Documenso already. Given a recipients signing token you can embed the signing experience in your application rather than direct the recipient to Documenso.
|
||||||
|
|
||||||
|
_For most use-cases we recommend using direct templates, however if you have a need for a more advanced integration, we are happy to help you get started._
|
||||||
|
|
||||||
|
## Supported Frameworks
|
||||||
|
|
||||||
|
We support embedding across a range of popular JavaScript frameworks, including:
|
||||||
|
|
||||||
|
| Framework | Package |
|
||||||
|
| --------- | -------------------------------------------------------------------------------- |
|
||||||
|
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
|
||||||
|
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
|
||||||
|
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
|
||||||
|
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
|
||||||
|
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
|
||||||
|
|
||||||
|
Additionally, we provide **web components** for more generalized use. However, please note that web components are still in their early stages and haven't been extensively tested.
|
||||||
|
|
||||||
|
## Embedding with Direct Templates
|
||||||
|
|
||||||
|
#### Instructions
|
||||||
|
|
||||||
|
To get started with embedding using a Direct Template we will need the URL segment which is also referred to as the token for the template.
|
||||||
|
|
||||||
|
You can find your URL/Token by performing the following steps:
|
||||||
|
|
||||||
|
1. **Navigate to your team's templates within Documenso**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
2. **Click on the direct link template you want to embed**
|
||||||
|
|
||||||
|
This will copy the URL to your clipboard, e.g. `https://stg-app.documenso.com/d/-WoSwWVT-fYOERS2MI37k`
|
||||||
|
|
||||||
|
**For the above url the token is `-WoSwWVT-fYOERS2MI37k`**
|
||||||
|
|
||||||
|
3. Provide the token to the `EmbedDirectTemplate` component in your frameworks SDK
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmbedDirectTemplate } from '@documenso/embed-react';
|
||||||
|
|
||||||
|
const MyEmbeddingComponent = () => {
|
||||||
|
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
|
||||||
|
|
||||||
|
return <EmbedDirectTemplate token={token} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Converting a regular template to a direct link template**
|
||||||
|
|
||||||
|
If you don't currently have any direct link templates you can easily create one by selecting the "Direct Link" option within the actions dropdown on the templates table.
|
||||||
|
|
||||||
|
This will show a dialog which will ask you to configure which recipient should be used as the direct link signer.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Embedding with Signing Tokens
|
||||||
|
|
||||||
|
To embed the signing process for an ordinary document, you’ll need a **document signing token** for the recipient. This token provides the necessary access to load the document and facilitate the signing process securely.
|
||||||
|
|
||||||
|
#### Instructions
|
||||||
|
|
||||||
|
1. Retrieve the signing token for the recipient document you want to embed
|
||||||
|
|
||||||
|
This will typically be done using an API integration where signing tokens are provided as part of the response when creating a document. Alternatively you can manually get a signing link by clicking hovering over a recipients avatar and clicking their email on a document that you own.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
With the signing url on our clipboard we can extract the token the same way we did for the direct link template.
|
||||||
|
|
||||||
|
So `https://stg-app.documenso.com/sign/lm7Tp2_yhvFfzdeJQzYQF` will become `lm7Tp2_yhvFfzdeJQzYQF`
|
||||||
|
|
||||||
|
2. Provide the token to the `EmbedSignDocument` component in your frameworks SDK
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmbedSignDocument } from '@documenso/embed-react';
|
||||||
|
|
||||||
|
const MyEmbeddingComponent = () => {
|
||||||
|
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
|
||||||
|
|
||||||
|
return <EmbedSignDocument token={token} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using Embedding in Your Application
|
||||||
|
|
||||||
|
Once you've obtained the appropriate tokens, you can integrate the signing experience into your application. For framework-specific instructions, please refer to the guides provided in our documentation for:
|
||||||
|
|
||||||
|
- [React](/developers/embedding/react)
|
||||||
|
- [Preact](/developers/embedding/preact)
|
||||||
|
- [Vue](/developers/embedding/vue)
|
||||||
|
- [Svelte](/developers/embedding/svelte)
|
||||||
|
- [Solid](/developers/embedding/solid)
|
||||||
|
|
||||||
|
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
|
||||||
|
|
||||||
|
## Stay Tuned for the Platform Plan
|
||||||
|
|
||||||
|
While embedding is already a powerful tool, we're working on a **Platform Plan** that will introduce even more functionality. This plan will offer:
|
||||||
|
|
||||||
|
- Additional customization options
|
||||||
|
- The ability to remove Documenso branding
|
||||||
|
- Additional controls for the signing experience
|
||||||
|
|
||||||
|
More details will be shared as we approach the release.
|
||||||
77
apps/documentation/pages/developers/embedding/preact.mdx
Normal file
77
apps/documentation/pages/developers/embedding/preact.mdx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
title: Preact Integration
|
||||||
|
description: Learn how to use our embedding SDK within your Preact application.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Preact Integration
|
||||||
|
|
||||||
|
Our Preact SDK provides a simple way to embed a signing experience within your Preact application. It supports both direct link templates and signing tokens.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To install the SDK, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @documenso/embed-preact
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
|
||||||
|
|
||||||
|
### Direct Link Template
|
||||||
|
|
||||||
|
If you have a direct link template, you can simply provide the token for the template to the `EmbedDirectTemplate` component.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmbedDirectTemplate } from '@documenso/embed-preact';
|
||||||
|
|
||||||
|
const MyEmbeddingComponent = () => {
|
||||||
|
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
|
||||||
|
|
||||||
|
return <EmbedDirectTemplate token={token} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| token | string | The token for the document you want to embed |
|
||||||
|
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
||||||
|
| name | string (optional) | The name the signer that will be used by default for signing |
|
||||||
|
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
||||||
|
| email | string (optional) | The email the signer that will be used by default for signing |
|
||||||
|
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
|
||||||
|
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
|
||||||
|
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
||||||
|
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
||||||
|
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
||||||
|
| onFieldSigned | function (optional) | A callback function that will be called when a field has been signed |
|
||||||
|
| onFieldUnsigned | function (optional) | A callback function that will be called when a field has been unsigned |
|
||||||
|
|
||||||
|
### Signing Token
|
||||||
|
|
||||||
|
If you have a signing token, you can provide it to the `EmbedSignDocument` component.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmbedSignDocument } from '@documenso/embed-preact';
|
||||||
|
|
||||||
|
const MyEmbeddingComponent = () => {
|
||||||
|
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
|
||||||
|
|
||||||
|
return <EmbedSignDocument token={token} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| token | string | The token for the document you want to embed |
|
||||||
|
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
||||||
|
| name | string (optional) | The name the signer that will be used by default for signing |
|
||||||
|
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
||||||
|
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
||||||
|
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
||||||
|
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
||||||
77
apps/documentation/pages/developers/embedding/react.mdx
Normal file
77
apps/documentation/pages/developers/embedding/react.mdx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
title: React Integration
|
||||||
|
description: Learn how to use our embedding SDK within your React application.
|
||||||
|
---
|
||||||
|
|
||||||
|
# React Integration
|
||||||
|
|
||||||
|
Our React SDK provides a simple way to embed a signing experience within your React application. It supports both direct link templates and signing tokens.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To install the SDK, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @documenso/embed-react
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
|
||||||
|
|
||||||
|
### Direct Link Template
|
||||||
|
|
||||||
|
If you have a direct link template, you can simply provide the token for the template to the `EmbedDirectTemplate` component.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmbedDirectTemplate } from '@documenso/embed-react';
|
||||||
|
|
||||||
|
const MyEmbeddingComponent = () => {
|
||||||
|
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
|
||||||
|
|
||||||
|
return <EmbedDirectTemplate token={token} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| token | string | The token for the document you want to embed |
|
||||||
|
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
||||||
|
| name | string (optional) | The name the signer that will be used by default for signing |
|
||||||
|
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
||||||
|
| email | string (optional) | The email the signer that will be used by default for signing |
|
||||||
|
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
|
||||||
|
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
|
||||||
|
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
||||||
|
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
||||||
|
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
||||||
|
| onFieldSigned | function (optional) | A callback function that will be called when a field has been signed |
|
||||||
|
| onFieldUnsigned | function (optional) | A callback function that will be called when a field has been unsigned |
|
||||||
|
|
||||||
|
### Signing Token
|
||||||
|
|
||||||
|
If you have a signing token, you can provide it to the `EmbedSignDocument` component.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmbedSignDocument } from '@documenso/embed-react';
|
||||||
|
|
||||||
|
const MyEmbeddingComponent = () => {
|
||||||
|
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
|
||||||
|
|
||||||
|
return <EmbedSignDocument token={token} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| token | string | The token for the document you want to embed |
|
||||||
|
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
||||||
|
| name | string (optional) | The name the signer that will be used by default for signing |
|
||||||
|
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
||||||
|
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
||||||
|
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
||||||
|
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
||||||
77
apps/documentation/pages/developers/embedding/solid.mdx
Normal file
77
apps/documentation/pages/developers/embedding/solid.mdx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
title: Solid.js Integration
|
||||||
|
description: Learn how to use our embedding SDK within your Solid.js application.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Solid.js Integration
|
||||||
|
|
||||||
|
Our Solid.js SDK provides a simple way to embed a signing experience within your Solid.js application. It supports both direct link templates and signing tokens.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To install the SDK, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @documenso/embed-solid
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
|
||||||
|
|
||||||
|
### Direct Link Template
|
||||||
|
|
||||||
|
If you have a direct link template, you can simply provide the token for the template to the `EmbedDirectTemplate` component.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmbedDirectTemplate } from '@documenso/embed-solid';
|
||||||
|
|
||||||
|
const MyEmbeddingComponent = () => {
|
||||||
|
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
|
||||||
|
|
||||||
|
return <EmbedDirectTemplate token={token} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| token | string | The token for the document you want to embed |
|
||||||
|
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
||||||
|
| name | string (optional) | The name the signer that will be used by default for signing |
|
||||||
|
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
||||||
|
| email | string (optional) | The email the signer that will be used by default for signing |
|
||||||
|
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
|
||||||
|
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
|
||||||
|
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
||||||
|
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
||||||
|
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
||||||
|
| onFieldSigned | function (optional) | A callback function that will be called when a field has been signed |
|
||||||
|
| onFieldUnsigned | function (optional) | A callback function that will be called when a field has been unsigned |
|
||||||
|
|
||||||
|
### Signing Token
|
||||||
|
|
||||||
|
If you have a signing token, you can provide it to the `EmbedSignDocument` component.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmbedSignDocument } from '@documenso/embed-solid';
|
||||||
|
|
||||||
|
const MyEmbeddingComponent = () => {
|
||||||
|
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
|
||||||
|
|
||||||
|
return <EmbedSignDocument token={token} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| token | string | The token for the document you want to embed |
|
||||||
|
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
||||||
|
| name | string (optional) | The name the signer that will be used by default for signing |
|
||||||
|
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
||||||
|
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
||||||
|
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
||||||
|
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
||||||
79
apps/documentation/pages/developers/embedding/svelte.mdx
Normal file
79
apps/documentation/pages/developers/embedding/svelte.mdx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
title: Svelte Integration
|
||||||
|
description: Learn how to use our embedding SDK within your Svelte application.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Svelte Integration
|
||||||
|
|
||||||
|
Our Svelte SDK provides a simple way to embed a signing experience within your Svelte application. It supports both direct link templates and signing tokens.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To install the SDK, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @documenso/embed-svelte
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
|
||||||
|
|
||||||
|
### Direct Link Template
|
||||||
|
|
||||||
|
If you have a direct link template, you can simply provide the token for the template to the `EmbedDirectTemplate` component.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script lang="ts">
|
||||||
|
import { EmbedDirectTemplate } from '@documenso/embed-svelte';
|
||||||
|
|
||||||
|
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<EmbedDirectTemplate {token} />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| token | string | The token for the document you want to embed |
|
||||||
|
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
||||||
|
| name | string (optional) | The name the signer that will be used by default for signing |
|
||||||
|
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
||||||
|
| email | string (optional) | The email the signer that will be used by default for signing |
|
||||||
|
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
|
||||||
|
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
|
||||||
|
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
||||||
|
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
||||||
|
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
||||||
|
| onFieldSigned | function (optional) | A callback function that will be called when a field has been signed |
|
||||||
|
| onFieldUnsigned | function (optional) | A callback function that will be called when a field has been unsigned |
|
||||||
|
|
||||||
|
### Signing Token
|
||||||
|
|
||||||
|
If you have a signing token, you can provide it to the `EmbedSignDocument` component.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmbedSignDocument } from '@documenso/embed-svelte';
|
||||||
|
|
||||||
|
const MyEmbeddingComponent = () => {
|
||||||
|
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
|
||||||
|
|
||||||
|
return <EmbedSignDocument token={token} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| token | string | The token for the document you want to embed |
|
||||||
|
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
||||||
|
| name | string (optional) | The name the signer that will be used by default for signing |
|
||||||
|
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
||||||
|
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
||||||
|
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
||||||
|
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
||||||
79
apps/documentation/pages/developers/embedding/vue.mdx
Normal file
79
apps/documentation/pages/developers/embedding/vue.mdx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
title: Vue Integration
|
||||||
|
description: Learn how to use our embedding SDK within your Vue application.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vue Integration
|
||||||
|
|
||||||
|
Our Vue SDK provides a simple way to embed a signing experience within your Vue application. It supports both direct link templates and signing tokens.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To install the SDK, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @documenso/embed-vue
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
|
||||||
|
|
||||||
|
### Direct Link Template
|
||||||
|
|
||||||
|
If you have a direct link template, you can simply provide the token for the template to the `EmbedDirectTemplate` component.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { EmbedDirectTemplate } from '@documenso/embed-vue';
|
||||||
|
|
||||||
|
const token = ref('YOUR_TOKEN_HERE'); // Replace with the actual token
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<EmbedDirectTemplate :token="token" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| token | string | The token for the document you want to embed |
|
||||||
|
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
||||||
|
| name | string (optional) | The name the signer that will be used by default for signing |
|
||||||
|
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
||||||
|
| email | string (optional) | The email the signer that will be used by default for signing |
|
||||||
|
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
|
||||||
|
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
|
||||||
|
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
||||||
|
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
||||||
|
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
||||||
|
| onFieldSigned | function (optional) | A callback function that will be called when a field has been signed |
|
||||||
|
| onFieldUnsigned | function (optional) | A callback function that will be called when a field has been unsigned |
|
||||||
|
|
||||||
|
### Signing Token
|
||||||
|
|
||||||
|
If you have a signing token, you can provide it to the `EmbedSignDocument` component.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { EmbedSignDocument } from '@documenso/embed-vue';
|
||||||
|
|
||||||
|
const MyEmbeddingComponent = () => {
|
||||||
|
const token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
|
||||||
|
|
||||||
|
return <EmbedSignDocument token={token} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| token | string | The token for the document you want to embed |
|
||||||
|
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
||||||
|
| name | string (optional) | The name the signer that will be used by default for signing |
|
||||||
|
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
||||||
|
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
||||||
|
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
||||||
|
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
||||||
@ -3,5 +3,6 @@
|
|||||||
"quickstart": "Developer Quickstart",
|
"quickstart": "Developer Quickstart",
|
||||||
"manual": "Manual Setup",
|
"manual": "Manual Setup",
|
||||||
"gitpod": "Gitpod",
|
"gitpod": "Gitpod",
|
||||||
"signing-certificate": "Signing Certificate"
|
"signing-certificate": "Signing Certificate",
|
||||||
|
"translations": "Translations"
|
||||||
}
|
}
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
---
|
||||||
|
title: Translations
|
||||||
|
description: Handling translations in code.
|
||||||
|
---
|
||||||
|
|
||||||
|
# About
|
||||||
|
|
||||||
|
Documenso uses the following stack to handle translations:
|
||||||
|
|
||||||
|
- [Lingui](https://lingui.dev/) - React i10n library
|
||||||
|
- [Crowdin](https://crowdin.com/) - Handles syncing translations
|
||||||
|
- [OpenAI](https://openai.com/) - Provides AI translations
|
||||||
|
|
||||||
|
Additional reading can be found in the [Lingui documentation](https://lingui.dev/introduction).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
You **must** insert **`setupI18nSSR()`** when creating any of the following files:
|
||||||
|
|
||||||
|
- Server layout.tsx
|
||||||
|
- Server page.tsx
|
||||||
|
- Server loading.tsx
|
||||||
|
|
||||||
|
Server meaning it does not have `'use client'` in it.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
|
export default function SomePage() {
|
||||||
|
setupI18nSSR(); // Required if there are translations within the page, or nested in components.
|
||||||
|
|
||||||
|
// Rest of code...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional information can be found [here.](https://lingui.dev/tutorials/react-rsc#pages-layouts-and-lingui)
|
||||||
|
|
||||||
|
## Quick guide
|
||||||
|
|
||||||
|
If you require more in-depth information, please see the [Lingui documentation](https://lingui.dev/introduction).
|
||||||
|
|
||||||
|
### HTML
|
||||||
|
|
||||||
|
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/macro** (not @lingui/react).
|
||||||
|
|
||||||
|
```html
|
||||||
|
<h1>
|
||||||
|
<Trans>Title</Trans>
|
||||||
|
</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
For text that is broken into elements, but represent a whole sentence, you must wrap it in a Trans tag so ensure the full message is extracted correctly.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<h1>
|
||||||
|
<Trans>
|
||||||
|
This is one
|
||||||
|
<span className="text-foreground/60">full</span>
|
||||||
|
<a href="https://documenso.com">sentence</a>
|
||||||
|
</Trans>
|
||||||
|
</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Constants outside of react components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
// Wrap text in msg`text to translate` when it's in a constant here, or another file/package.
|
||||||
|
export const CONSTANT_WITH_MSG = {
|
||||||
|
foo: msg`Hello`,
|
||||||
|
bar: msg`World`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SomeComponent = () => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* This will render the correct translated text. */}
|
||||||
|
<p>{_(CONSTANT_WITH_MSG.foo)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plurals
|
||||||
|
|
||||||
|
Lingui provides a Plural component to make it easy. See full documentation [here.](https://lingui.dev/ref/macro#plural-1)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Basic usage.
|
||||||
|
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dates
|
||||||
|
|
||||||
|
Lingui provides a [DateTime instance](https://lingui.dev/ref/core#i18n.date) with the configured locale.
|
||||||
|
|
||||||
|
#### Server components
|
||||||
|
|
||||||
|
Note that the i18n instance is coming from **setupI18nSSR**.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
export const SomeComponent = () => {
|
||||||
|
const { i18n } = setupI18nSSR();
|
||||||
|
|
||||||
|
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Client components
|
||||||
|
|
||||||
|
Note that the i18n instance is coming from the **import**.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { i18n } from '@lingui/core';
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
export const SomeComponent = () => {
|
||||||
|
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
|
||||||
|
};
|
||||||
|
```
|
||||||
@ -5,6 +5,8 @@ description: Learn how to self-host Documenso on your server or cloud infrastruc
|
|||||||
|
|
||||||
import { Callout, Steps } from 'nextra/components';
|
import { Callout, Steps } from 'nextra/components';
|
||||||
|
|
||||||
|
import { CallToAction } from '@documenso/ui/components/call-to-action';
|
||||||
|
|
||||||
# Self Hosting
|
# Self Hosting
|
||||||
|
|
||||||
We support various deployment methods and are actively working on adding more. Please let us know if you have a specific deployment method in mind!
|
We support various deployment methods and are actively working on adding more. Please let us know if you have a specific deployment method in mind!
|
||||||
@ -273,3 +275,5 @@ We offer several alternative deployment methods for Documenso if you need more o
|
|||||||
## Koyeb
|
## Koyeb
|
||||||
|
|
||||||
[](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
|
[](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
|
||||||
|
|
||||||
|
<CallToAction className="mt-12" utmSource="self-hosting" />
|
||||||
|
|||||||
@ -3,6 +3,10 @@ title: Getting Started with Self-Hosting
|
|||||||
description: A step-by-step guide to setting up and hosting your own Documenso instance.
|
description: A step-by-step guide to setting up and hosting your own Documenso instance.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
import { CallToAction } from '@documenso/ui/components/call-to-action';
|
||||||
|
|
||||||
# Getting Started with Self-Hosting
|
# Getting Started with Self-Hosting
|
||||||
|
|
||||||
This is a step-by-step guide to setting up and hosting your own Documenso instance. Before getting started, [select the right license for you](/users/licenses).
|
This is a step-by-step guide to setting up and hosting your own Documenso instance. Before getting started, [select the right license for you](/users/licenses).
|
||||||
|
|
||||||
|
<CallToAction className="mt-12" utmSource="self-hosting" />
|
||||||
|
|||||||
@ -15,7 +15,7 @@ The signature field collects the signer's signature. It's required for each reci
|
|||||||
|
|
||||||
The field doesn't have any additional settings. You just need to place it on the document where you want the signer to sign.
|
The field doesn't have any additional settings. You just need to place it on the document where you want the signer to sign.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Document Signing View
|
### Document Signing View
|
||||||
|
|
||||||
@ -23,11 +23,11 @@ The recipient will see the signature field when they open the document to sign.
|
|||||||
|
|
||||||
The recipient must click on the signature field to open the signing view, where they can sign using their mouse, touchpad, or touchscreen.
|
The recipient must click on the signature field to open the signing view, where they can sign using their mouse, touchpad, or touchscreen.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The image below shows the signature field signed by the recipient.
|
The image below shows the signature field signed by the recipient.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
After signing, the recipient can click the "Complete" button to complete the signing process.
|
After signing, the recipient can click the "Complete" button to complete the signing process.
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ The email field is used to collect the signer's email address.
|
|||||||
|
|
||||||
The field doesn't have any additional settings. You just need to place it on the document where you want the signer to sign.
|
The field doesn't have any additional settings. You just need to place it on the document where you want the signer to sign.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Document Signing View
|
### Document Signing View
|
||||||
|
|
||||||
@ -47,11 +47,11 @@ When the recipient opens the document to sign, they will see the email field.
|
|||||||
|
|
||||||
The recipient must click on the email field to automatically sign the field with the email associated with their account.
|
The recipient must click on the email field to automatically sign the field with the email associated with their account.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The image below shows the email field signed by the recipient.
|
The image below shows the email field signed by the recipient.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
After entering their email address, the recipient can click the "Complete" button to complete the signing process.
|
After entering their email address, the recipient can click the "Complete" button to complete the signing process.
|
||||||
|
|
||||||
@ -63,7 +63,7 @@ The name field is used to collect the signer's name.
|
|||||||
|
|
||||||
The field doesn't have any additional settings. You just need to place it on the document where you want the signer to sign.
|
The field doesn't have any additional settings. You just need to place it on the document where you want the signer to sign.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Document Signing View
|
### Document Signing View
|
||||||
|
|
||||||
@ -71,11 +71,11 @@ When the recipient opens the document to sign, they will see the name field.
|
|||||||
|
|
||||||
The recipient must click on the name field, which will automatically sign the field with the name associated with their account.
|
The recipient must click on the name field, which will automatically sign the field with the name associated with their account.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The image below shows the name field signed by the recipient.
|
The image below shows the name field signed by the recipient.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
After entering their name, the recipient can click the "Complete" button to complete the signing process.
|
After entering their name, the recipient can click the "Complete" button to complete the signing process.
|
||||||
|
|
||||||
@ -87,7 +87,7 @@ The date field is used to collect the date of the signature.
|
|||||||
|
|
||||||
The field doesn't have any additional settings. You just need to place it on the document where you want the signer to sign.
|
The field doesn't have any additional settings. You just need to place it on the document where you want the signer to sign.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Document Signing View
|
### Document Signing View
|
||||||
|
|
||||||
@ -95,11 +95,11 @@ When the recipient opens the document to sign, they will see the date field.
|
|||||||
|
|
||||||
The recipient must click on the date field to automatically sign the field with the current date and time.
|
The recipient must click on the date field to automatically sign the field with the current date and time.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The image below shows the date field signed by the recipient.
|
The image below shows the date field signed by the recipient.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
After entering the date, the recipient can click the "Complete" button to complete the signing process.
|
After entering the date, the recipient can click the "Complete" button to complete the signing process.
|
||||||
|
|
||||||
@ -111,11 +111,11 @@ The text field is used to collect text input from the signer.
|
|||||||
|
|
||||||
Place the text field on the document where you want the signer to enter text. The text field comes with additional settings that can be configured.
|
Place the text field on the document where you want the signer to enter text. The text field comes with additional settings that can be configured.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
To open the settings, click on the text field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
|
To open the settings, click on the text field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The text field settings include:
|
The text field settings include:
|
||||||
|
|
||||||
@ -137,7 +137,7 @@ It also comes with a couple of rules:
|
|||||||
|
|
||||||
Let's look at the following example.
|
Let's look at the following example.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The field is configured as follows:
|
The field is configured as follows:
|
||||||
|
|
||||||
@ -156,23 +156,23 @@ What the recipient sees when they open the document to sign depends on the setti
|
|||||||
|
|
||||||
In this case, the recipient sees the text field signed with the default value.
|
In this case, the recipient sees the text field signed with the default value.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The recipient can modify the text field value since the field is not read-only. To change the value, the recipient must click the field to un-sign it.
|
The recipient can modify the text field value since the field is not read-only. To change the value, the recipient must click the field to un-sign it.
|
||||||
|
|
||||||
Once it's unsigned, the field uses the label set by the sender.
|
Once it's unsigned, the field uses the label set by the sender.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
To sign the field with a different value, the recipient needs to click on the field and enter the new value.
|
To sign the field with a different value, the recipient needs to click on the field and enter the new value.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Since the text field has a character limit, the recipient must enter a value that doesn't exceed the limit. Otherwise, an error message will appear, and the field will not be signed.
|
Since the text field has a character limit, the recipient must enter a value that doesn't exceed the limit. Otherwise, an error message will appear, and the field will not be signed.
|
||||||
|
|
||||||
The image below illustrates the text field signed with a new value.
|
The image below illustrates the text field signed with a new value.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
After signing the field, the recipient can click the "Complete" button to complete the signing process.
|
After signing the field, the recipient can click the "Complete" button to complete the signing process.
|
||||||
|
|
||||||
@ -184,11 +184,11 @@ The number field is used for collecting a number input from the signer.
|
|||||||
|
|
||||||
Place the number field on the document where you want the signer to enter a number. The number field comes with additional settings that can be configured.
|
Place the number field on the document where you want the signer to enter a number. The number field comes with additional settings that can be configured.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
To open the settings, click on the number field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
|
To open the settings, click on the number field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The number field settings include:
|
The number field settings include:
|
||||||
|
|
||||||
@ -221,7 +221,7 @@ In this example, the number field is configured as follows:
|
|||||||
- Validation:
|
- Validation:
|
||||||
- Min value: 5, Max value: 50
|
- Min value: 5, Max value: 50
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Since the field has a label set, the label is displayed instead of the default number field value - "Add number".
|
Since the field has a label set, the label is displayed instead of the default number field value - "Add number".
|
||||||
|
|
||||||
@ -231,23 +231,23 @@ What the recipient sees when they open the document to sign depends on the setti
|
|||||||
|
|
||||||
The recipient sees the number field signed with the default value in this case.
|
The recipient sees the number field signed with the default value in this case.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Since the number field is not read-only, the recipient can modify its value. To change the value, the recipient must click the field to un-sign it.
|
Since the number field is not read-only, the recipient can modify its value. To change the value, the recipient must click the field to un-sign it.
|
||||||
|
|
||||||
Once it's unsigned, the field uses the label set by the sender.
|
Once it's unsigned, the field uses the label set by the sender.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
To sign the field with a different value, the recipient needs to click on the field and enter the new value.
|
To sign the field with a different value, the recipient needs to click on the field and enter the new value.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Since the number field has a validation rule set, the recipient must enter a value that meets the rules. In this example, the value needs to be between 5 and 50. Otherwise, an error message will appear, and the field will not be signed.
|
Since the number field has a validation rule set, the recipient must enter a value that meets the rules. In this example, the value needs to be between 5 and 50. Otherwise, an error message will appear, and the field will not be signed.
|
||||||
|
|
||||||
The image below illustrates the text field signed with a new value.
|
The image below illustrates the text field signed with a new value.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
After signing the field, the recipient can click the "Complete" button to complete the signing process.
|
After signing the field, the recipient can click the "Complete" button to complete the signing process.
|
||||||
|
|
||||||
@ -259,11 +259,11 @@ The radio field is used to collect a single choice from the signer.
|
|||||||
|
|
||||||
Place the radio field on the document where you want the signer to select a choice. The radio field comes with additional settings that can be configured.
|
Place the radio field on the document where you want the signer to select a choice. The radio field comes with additional settings that can be configured.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
To open the settings, click on the radio field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
|
To open the settings, click on the radio field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The radio field settings include:
|
The radio field settings include:
|
||||||
|
|
||||||
@ -293,7 +293,7 @@ In this example, the radio field is configured as follows:
|
|||||||
- Empty value
|
- Empty value
|
||||||
- Option 3
|
- Option 3
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Since the field contains radio options, it displays them instead of the default radio field value, "Radio".
|
Since the field contains radio options, it displays them instead of the default radio field value, "Radio".
|
||||||
|
|
||||||
@ -303,11 +303,11 @@ What the recipient sees when they open the document to sign depends on the setti
|
|||||||
|
|
||||||
In this case, the recipient sees the radio field unsigned because the sender didn't select a value.
|
In this case, the recipient sees the radio field unsigned because the sender didn't select a value.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The recipient can select one of the options by clicking on the radio button next to the option.
|
The recipient can select one of the options by clicking on the radio button next to the option.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
After signing the field, the recipient can click the "Complete" button to complete the signing process.
|
After signing the field, the recipient can click the "Complete" button to complete the signing process.
|
||||||
|
|
||||||
@ -319,11 +319,11 @@ The checkbox field is used to collect multiple choices from the signer.
|
|||||||
|
|
||||||
Place the checkbox field on the document where you want the signer to select choices. The checkbox field comes with additional settings that can be configured.
|
Place the checkbox field on the document where you want the signer to select choices. The checkbox field comes with additional settings that can be configured.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
To open the settings, click on the checkbox field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
|
To open the settings, click on the checkbox field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The checkbox field settings include the following:
|
The checkbox field settings include the following:
|
||||||
|
|
||||||
@ -356,7 +356,7 @@ In this example, the checkbox field is configured as follows:
|
|||||||
- Option 3 (checked)
|
- Option 3 (checked)
|
||||||
- Empty value
|
- Empty value
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Since the field contains checkbox options, it displays them instead of the default checkbox field value, "Checkbox".
|
Since the field contains checkbox options, it displays them instead of the default checkbox field value, "Checkbox".
|
||||||
|
|
||||||
@ -366,7 +366,7 @@ What the recipient sees when they open the document to sign depends on the setti
|
|||||||
|
|
||||||
In this case, the recipient sees the checkbox field signed with the values selected by the sender.
|
In this case, the recipient sees the checkbox field signed with the values selected by the sender.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Since the field is required, the recipient can either sign with the values selected by the sender or modify the values.
|
Since the field is required, the recipient can either sign with the values selected by the sender or modify the values.
|
||||||
|
|
||||||
@ -377,11 +377,11 @@ The values can be modified in 2 ways:
|
|||||||
|
|
||||||
The image below illustrates the checkbox field with the values cleared by the recipient. Since the field is required, it has a red border instead of the yellow one (non-required fields).
|
The image below illustrates the checkbox field with the values cleared by the recipient. Since the field is required, it has a red border instead of the yellow one (non-required fields).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Then, the recipient can select values other than the ones chosen by the sender.
|
Then, the recipient can select values other than the ones chosen by the sender.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
After signing the field, the recipient can click the "Complete" button to complete the signing process.
|
After signing the field, the recipient can click the "Complete" button to complete the signing process.
|
||||||
|
|
||||||
@ -393,11 +393,11 @@ The dropdown/select field collects a single choice from a list of options.
|
|||||||
|
|
||||||
Place the dropdown/select field on the document where you want the signer to select a choice. The dropdown/select field comes with additional settings that can be configured.
|
Place the dropdown/select field on the document where you want the signer to select a choice. The dropdown/select field comes with additional settings that can be configured.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
To open the settings, click on the dropdown/select field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
|
To open the settings, click on the dropdown/select field and then on the "Sliders" icon. That opens the settings panel on the right side of the screen.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The dropdown/select field settings include:
|
The dropdown/select field settings include:
|
||||||
|
|
||||||
@ -433,14 +433,14 @@ What the recipient sees when they open the document to sign depends on the setti
|
|||||||
|
|
||||||
In this case, the recipient sees the dropdown/select field with the default label, "-- Select ---" since the sender has not set a default value.
|
In this case, the recipient sees the dropdown/select field with the default label, "-- Select ---" since the sender has not set a default value.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The recipient can modify the dropdown/select field value since the field is not read-only. To change the value, the recipient must click on the field for the dropdown list to appear.
|
The recipient can modify the dropdown/select field value since the field is not read-only. To change the value, the recipient must click on the field for the dropdown list to appear.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The recipient can select one of the options from the list. The image below illustrates the dropdown/select field signed with a new value.
|
The recipient can select one of the options from the list. The image below illustrates the dropdown/select field signed with a new value.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
After signing the field, the recipient can click the "Complete" button to complete the signing process.
|
After signing the field, the recipient can click the "Complete" button to complete the signing process.
|
||||||
|
|||||||
@ -18,17 +18,17 @@ The guide assumes you have a Documenso account. If you don't, you can create a f
|
|||||||
|
|
||||||
Navigate to the [Documenso dashboard](https://app.documenso.com/documents) and click on the "Add a document" button. Select the document you want to upload and wait for the upload to complete.
|
Navigate to the [Documenso dashboard](https://app.documenso.com/documents) and click on the "Add a document" button. Select the document you want to upload and wait for the upload to complete.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
After the upload is complete, you will be redirected to the document's page. You can configure the document's settings and add recipients and fields here.
|
After the upload is complete, you will be redirected to the document's page. You can configure the document's settings and add recipients and fields here.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### (Optional) Advanced Options
|
### (Optional) Advanced Options
|
||||||
|
|
||||||
Click on the "Advanced options" button to access additional settings for the document. You can set an external ID, date format, time zone, and the redirect URL.
|
Click on the "Advanced options" button to access additional settings for the document. You can set an external ID, date format, time zone, and the redirect URL.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The external ID allows you to set a custom ID for the document that can be used to identify the document in your external system(s).
|
The external ID allows you to set a custom ID for the document that can be used to identify the document in your external system(s).
|
||||||
|
|
||||||
@ -45,7 +45,7 @@ The available options are:
|
|||||||
- **Require account** - The recipient must be signed in to view the document.
|
- **Require account** - The recipient must be signed in to view the document.
|
||||||
- **None** - The document can be accessed directly by the URL sent to the recipient.
|
- **None** - The document can be accessed directly by the URL sent to the recipient.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
The "Document Access" feature is only available for Enterprise accounts.
|
The "Document Access" feature is only available for Enterprise accounts.
|
||||||
@ -61,7 +61,7 @@ The available options are:
|
|||||||
- **Require 2FA** - The recipient must have an account and 2FA enabled via their settings.
|
- **Require 2FA** - The recipient must have an account and 2FA enabled via their settings.
|
||||||
- **None** - No authentication required.
|
- **None** - No authentication required.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
This can be overridden by setting the authentication requirements directly for each recipient in the next step.
|
This can be overridden by setting the authentication requirements directly for each recipient in the next step.
|
||||||
|
|
||||||
@ -75,11 +75,11 @@ Click the "+ Add Signer" button to add a new recipient. You can configure the re
|
|||||||
|
|
||||||
You can choose any option from the ["Recipient Authentication"](#optional-recipient-authentication) section, or you can set it to "Inherit authentication method" to use the global action signing authentication method configured in the "General Settings" step.
|
You can choose any option from the ["Recipient Authentication"](#optional-recipient-authentication) section, or you can set it to "Inherit authentication method" to use the global action signing authentication method configured in the "General Settings" step.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
You can also set the recipient's role, which determines their actions and permissions in the document.
|
You can also set the recipient's role, which determines their actions and permissions in the document.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### Roles
|
#### Roles
|
||||||
|
|
||||||
@ -96,7 +96,7 @@ Documenso has 4 roles for recipients with different permissions and actions.
|
|||||||
|
|
||||||
Documenso supports 9 different field types that can be added to the document. Each field type collects various information from the recipients when they sign the document.
|
Documenso supports 9 different field types that can be added to the document. Each field type collects various information from the recipients when they sign the document.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The available field types are:
|
The available field types are:
|
||||||
|
|
||||||
@ -121,13 +121,13 @@ All fields can be placed anywhere on the document and resized as needed.
|
|||||||
|
|
||||||
Signer Roles require at least 1 signature field. You will get an error message if you try to send a document without a signature field.
|
Signer Roles require at least 1 signature field. You will get an error message if you try to send a document without a signature field.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Email Settings
|
### Email Settings
|
||||||
|
|
||||||
Before sending the document, you can configure the email settings and customize the subject line, message, and sender name.
|
Before sending the document, you can configure the email settings and customize the subject line, message, and sender name.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
If you leave the email subject and message empty, Documenso will use the default email template.
|
If you leave the email subject and message empty, Documenso will use the default email template.
|
||||||
|
|
||||||
@ -135,13 +135,13 @@ If you leave the email subject and message empty, Documenso will use the default
|
|||||||
|
|
||||||
After configuring the document, click the "Send" button to send the document to the recipients. The recipients will receive an email with a link to sign the document.
|
After configuring the document, click the "Send" button to send the document to the recipients. The recipients will receive an email with a link to sign the document.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### Signing Link
|
#### Signing Link
|
||||||
|
|
||||||
If you need to copy the signing link for each recipient, you can do so by clicking on the recipient whose link you want to copy. The signing link is copied automatically to your clipboard.
|
If you need to copy the signing link for each recipient, you can do so by clicking on the recipient whose link you want to copy. The signing link is copied automatically to your clipboard.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The signing link has the following format:
|
The signing link has the following format:
|
||||||
|
|
||||||
|
|||||||
@ -10,15 +10,15 @@ Documenso allows you to create templates, which are reusable documents. Template
|
|||||||
|
|
||||||
To create a new template, navigate to the ["Templates" page](https://app.documenso.com/templates) and click on the "New Template" button.
|
To create a new template, navigate to the ["Templates" page](https://app.documenso.com/templates) and click on the "New Template" button.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Clicking on the "New Template" button opens a new modal to upload the document you want to use as a template. Select the document and wait for Documenso to upload it to your account.
|
Clicking on the "New Template" button opens a new modal to upload the document you want to use as a template. Select the document and wait for Documenso to upload it to your account.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Once the upload is complete, Documenso opens the template configuration page.
|
Once the upload is complete, Documenso opens the template configuration page.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
You can then configure the template by adding recipients, fields, and other options.
|
You can then configure the template by adding recipients, fields, and other options.
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ When you send a document for signing, Documenso emails the recipients with a lin
|
|||||||
|
|
||||||
Documenso uses a generic subject and message but also allows you to customize them for each document and template.
|
Documenso uses a generic subject and message but also allows you to customize them for each document and template.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
To configure the email options, click the "Email Options" tab and fill in the subject and message fields. Every time you use this template for signing, Documenso will use the custom subject and message you provided. They can also be overridden before sending the document.
|
To configure the email options, click the "Email Options" tab and fill in the subject and message fields. Every time you use this template for signing, Documenso will use the custom subject and message you provided. They can also be overridden before sending the document.
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ To configure the email options, click the "Email Options" tab and fill in the su
|
|||||||
|
|
||||||
The template also has advanced options that you can configure. These options include settings such as the external ID, date format, time zone and the redirect URL.
|
The template also has advanced options that you can configure. These options include settings such as the external ID, date format, time zone and the redirect URL.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The external ID allows you to set a custom ID for the document that can be used to identify the document in your external system(s).
|
The external ID allows you to set a custom ID for the document that can be used to identify the document in your external system(s).
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ You can add placeholders for the template recipients. Placeholders specify where
|
|||||||
|
|
||||||
You can also add recipients directly to the template. Recipients are the people who will receive the document for signing.
|
You can also add recipients directly to the template. Recipients are the people who will receive the document for signing.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
If you add placeholders to the template, you must replace them with actual recipients when creating a document from it. See the modal from the ["Use a Template"](#use-a-template) section.
|
If you add placeholders to the template, you must replace them with actual recipients when creating a document from it. See the modal from the ["Use a Template"](#use-a-template) section.
|
||||||
|
|
||||||
@ -70,7 +70,7 @@ Documenso provides the following field types:
|
|||||||
- **Checkbox** - Collects multiple choices from the signer
|
- **Checkbox** - Collects multiple choices from the signer
|
||||||
- **Dropdown/Select** - Collects a single choice from a list of choices
|
- **Dropdown/Select** - Collects a single choice from a list of choices
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
After adding the fields, press the "Save Template" button to save the template.
|
After adding the fields, press the "Save Template" button to save the template.
|
||||||
|
|
||||||
@ -85,7 +85,7 @@ Click on the "Use Template" button to create a new document from the template. B
|
|||||||
|
|
||||||
After filling in the recipients, click the "Create Document" button to create the document in your account.
|
After filling in the recipients, click the "Create Document" button to create the document in your account.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
You can also send the document straight to the recipients for signing by checking the "Send document" checkbox.
|
You can also send the document straight to the recipients for signing by checking the "Send document" checkbox.
|
||||||
|
|
||||||
|
|||||||
BIN
apps/documentation/public/embedding/copy-recipient-token.png
Normal file
BIN
apps/documentation/public/embedding/copy-recipient-token.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
BIN
apps/documentation/public/embedding/enable-direct-link.png
Normal file
BIN
apps/documentation/public/embedding/enable-direct-link.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/documentation/public/embedding/team-templates.png
Normal file
BIN
apps/documentation/public/embedding/team-templates.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
72
apps/marketing/content/blog/introducing-embedding.mdx
Normal file
72
apps/marketing/content/blog/introducing-embedding.mdx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
title: 'Introducing Embedding Support for Documenso'
|
||||||
|
description: 'Embedding is now here! Learn how we built it and how it can be used to bring e-signing to your own applications.'
|
||||||
|
authorName: 'Lucas Smith'
|
||||||
|
authorImage: '/blog/blog-author-lucas.png'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2024-09-06
|
||||||
|
tags:
|
||||||
|
- Development
|
||||||
|
---
|
||||||
|
|
||||||
|
When we first launched Documenso, one of the most requested features was embedding. We knew it was important and aligned with our desire to not just be a e-signing application but to instead provide the e-signature infrastructure for the web and beyond.
|
||||||
|
|
||||||
|
With that said, we decided to hold off initially so we could focus on building a solid, well-featured core application. Looking back, this was definitely the right call. Embedding is only as good as the features behind it, and we didn't want to release something that wasn't ready to meet user and developer expectations.
|
||||||
|
|
||||||
|
Over the past year, we've been busy adding tons of new features and reaching new levels of compliance, like 21 CFR Part 11. We've also introduced [new fields](/blog/introducing-advanced-signing-fields), [built out an API](/blog/public-api), [added webhooks, integrations with Zapier](/blog/launch-week-2-day-4), and a lot more.
|
||||||
|
|
||||||
|
Now that we've laid a solid foundation, it's finally time to focus on embedding, the top-requested feature from both our users and those self-hosting our platform.
|
||||||
|
|
||||||
|
## Why Embedding Took Time
|
||||||
|
|
||||||
|
In previous projects, I’ve often seen embedding built by bundling components for use in a client’s website or app. This method gives users maximum flexibility for styling and behavior, while avoiding certain cross-origin issues. However, it can also introduce problems like code conflicts or performance bottlenecks. For example, third-party tools such as Google Tag Manager (GTM) or other marketing scripts can interfere with your SDK. Additionally, the SDK must remain lightweight to avoid slowing down the client’s page.
|
||||||
|
|
||||||
|
For Documenso, we decided to explore a different approach. After carefully researching our options, we opted for an iframe-based solution. While iframes are typically less flexible—especially when it comes to theming or passing pre-filled data containing personally identifiable information (PII)—we identified ways to mitigate these concerns.
|
||||||
|
|
||||||
|
One of the biggest challenges was ensuring that we could pass sensitive data, like emails for pre-filling forms, without exposing PII to our server. To solve this, we used [fragment identifiers](https://developer.mozilla.org/en-US/docs/Web/API/URL/hash) in the URL, which are processed client-side and never sent in network requests. This method ensures that PII is protected and not logged by our server or any intermediate web services.
|
||||||
|
|
||||||
|
### Using the PostMessage API for Communication
|
||||||
|
|
||||||
|
To maintain a high level of interactivity, our iframes communicate with the parent window using the [postMessage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). This allows us to notify the parent app when specific events occur inside the iframe, creating a more dynamic user experience and bridging the gap between our iframe-based solution and typical fat SDKs.
|
||||||
|
|
||||||
|
Additionally, props are passed into the iframe via the [fragment identifier](https://developer.mozilla.org/en-US/docs/Web/API/URL/hash) of the URL. This avoids the need for complex two-way data synchronization between the parent and child frames, making the system stable and more reliable.
|
||||||
|
|
||||||
|
### Building the Embeds with Mitosis
|
||||||
|
|
||||||
|
Given that our iframe solution is quite lightweight, we saw this as a great opportunity to experiment with [Mitosis](https://mitosis.builder.io/) which would let us do something truly special. For those unfamiliar, Mitosis is a project by Builder.io that lets you write components once and then transpile them into a variety of frameworks like React, Vue, and Svelte.
|
||||||
|
|
||||||
|
We used Mitosis to build two key components: a direct template embed and a document signing embed. The direct template allows users to use a template as if it were an evergreen document—meaning that, when someone completes the template, a new document is automatically generated. This is the use case we expect most users to adopt for embedding. For more advanced workflows, we also offer a document signing embed, which can handle multi-recipient workflows and other complex scenarios intended for use in deeper, rich integrations.
|
||||||
|
|
||||||
|
Mitosis allowed us to quickly target several popular frameworks, including [React](https://www.npmjs.com/package/@documenso/embed-react), [Preact](https://www.npmjs.com/package/@documenso/embed-preact), [Vue](https://www.npmjs.com/package/@documenso/embed-vue), [Svelte](https://www.npmjs.com/package/@documenso/embed-svelte), and [SolidJS](https://www.npmjs.com/package/@documenso/embed-solid).
|
||||||
|
|
||||||
|
I had also hoped to include Angular, but while Mitosis makes it really easy to transpile component, we still have to take care of bundling and packaging the resulting component ourselves. While the above frameworks can all be bundled using Vite.js, Angular still has it's own set of tooling that we would need to learn and use. Given this constraint we opted to put Angular on hold for now while we wait for the newer Vite.js support to mature.
|
||||||
|
|
||||||
|
### Challenges and Lessons with Mitosis and more
|
||||||
|
|
||||||
|
While our experience with Mitosis was largely positive, there were some challenges along the way. For instance, certain state properties with the same names as props caused issues during the transpilation process, leading to type errors and unexpected transpilation results with some targets.
|
||||||
|
|
||||||
|
This was also a challenge since our initial implementation of the two components had some minor separation of concerns which also resulted in some transpilation issues with some targets. We addressed this by removing the separation of concerns for now since it was mostly for show rather than out of necessity.
|
||||||
|
|
||||||
|
On top of that, packaging and publishing the embeds posed its own set of challenges, particularly given the growing complexity of JavaScript package management. Tools like [Publint](https://www.npmjs.com/package/publint) helped streamline the process by ensuring we followed best practices for both CommonJS and ESM formats.
|
||||||
|
|
||||||
|
### To the Future, The Documenso Platform
|
||||||
|
|
||||||
|
With the embedding feature now in place, we're excited to continue expanding Documenso's capabilities. Embeds are just the beginning of what we're calling the Documenso platform. Through our user research, we've learned that while many businesses appreciate having a flexible e-signature solution, they're even more interested in using our tools to build signing functionality directly into their own apps—without worrying about the technical complexities of compliance and security that come with e-signing.
|
||||||
|
|
||||||
|
Over the coming months, we'll be working on enhancing our API, strengthening integrations with tools like Zapier, and improving our webhook system. Our goal is to give users the ability to embed e-signatures and document management wherever they need it, whether that's through self-hosting or by using Documenso as a platform. We can't wait to see how our users and self-hosters leverage these new capabilities!
|
||||||
|
|
||||||
|
### Ready to Get Started?
|
||||||
|
|
||||||
|
If you're ready to embed document signing into your own app or website, check out our [Embedding Documentation](https://docs.documenso.com/developers/embedding?utm_source=blog&utm_campaign=introducing-embedding) to see how easy it is to get started. You'll find everything you need to get started today!
|
||||||
|
|
||||||
|
<video
|
||||||
|
src="/blog/introducing-embedding/embedding-demo.mp4"
|
||||||
|
className="aspect-video w-full"
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
|
||||||
|
We're always here to help! If you have questions or need support, join our [Discord](https://documen.so/discord) or [book a demo](https://documen.so/book-a-demo). We'd love to hear how you're using Documenso or wanting to use Documenso to enhance your workflow.
|
||||||
|
|
||||||
|
Stay tuned for more updates as we continue to evolve the Documenso platform and make it even easier to bring document signing into your workflows.
|
||||||
@ -8,6 +8,80 @@ Check out what's new in the latest version and read our thoughts on it. For more
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# Documenso v1.7.0: Embedded Signing, Copy and Paste, and More
|
||||||
|
|
||||||
|
We're thrilled to announce the release of Documenso v1.7.0, packed with exciting new features and improvements that enhance document signing flexibility, user experience, and global accessibility.
|
||||||
|
|
||||||
|
We're excited to see what you'll create with this release and we'd love to hear your feedback. Let's dive into the highlights:
|
||||||
|
|
||||||
|
## 🌟 Key Features
|
||||||
|
|
||||||
|
### Embedded Signing Experience
|
||||||
|
|
||||||
|
Take your document signing to the next level with our new embedded signing feature. Now you can seamlessly integrate Documenso's signing process directly into your own website or application, providing a smooth, branded experience for your users.
|
||||||
|
|
||||||
|
<video
|
||||||
|
src="/blog/introducing-embedding/embedding-demo.mp4"
|
||||||
|
className="aspect-video w-full"
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
|
||||||
|
Check out our [Embedding documentation](https://docs.documenso.com/developers/embedding) to learn more about how to get started.
|
||||||
|
|
||||||
|
### Copy and Paste Fields
|
||||||
|
|
||||||
|
Streamline your document preparation with our new copy and paste functionality for fields. This feature allows you to quickly duplicate fields across your document, saving time and ensuring consistency in your templates.
|
||||||
|
|
||||||
|
### Customizable Signature Colors
|
||||||
|
|
||||||
|
Recipients can now select a signature color from our list of available colors, supporting workflows where specific colors are required for each recipient, location, or document.
|
||||||
|
|
||||||
|
### Enhanced Internationalization (i18n)
|
||||||
|
|
||||||
|
Following on from our last release we've now expanded our i18n support to the main web application. We haven't yet added support for any additional languages but that will be coming quickly now that we have completed the hard work of wrapping all of our content in our new i18n system.
|
||||||
|
|
||||||
|
These enhancements make Documenso more accessible to users worldwide.
|
||||||
|
|
||||||
|
## 🔧 Other Improvements
|
||||||
|
|
||||||
|
- **API Enhancements**:
|
||||||
|
|
||||||
|
- New endpoint to prefill fields via API
|
||||||
|
- Updated createFields API endpoint for more flexibility
|
||||||
|
- Automatically set public profile URL for OIDC users
|
||||||
|
|
||||||
|
- **Security and Performance**:
|
||||||
|
|
||||||
|
- Document sealing moved to a background job for improved performance
|
||||||
|
- Disable 2FA with backup codes for enhanced account recovery options
|
||||||
|
- Extended lifespan for invites and confirmations
|
||||||
|
|
||||||
|
- **User Experience**:
|
||||||
|
|
||||||
|
- Updated email templates to reflect team-specific information
|
||||||
|
- Fixed issues with dialog closing on page refresh
|
||||||
|
- Improved field editing in document templates
|
||||||
|
|
||||||
|
- **Other Items**:
|
||||||
|
- Added Elestio as a one-click deploy option
|
||||||
|
- Updated README for manual self-hosting
|
||||||
|
- New environment variable for internal webapp URL configuration
|
||||||
|
|
||||||
|
## 📚 New Content
|
||||||
|
|
||||||
|
- [Advanced fields article to help you make the most of Documenso's capabilities](/blog/introducing-advanced-signing-fields)
|
||||||
|
- [Embedding blog post to guide you through how we implemented embedding](/blog/introducing-embedding)
|
||||||
|
|
||||||
|
## 👏 Community Contributions
|
||||||
|
|
||||||
|
A big thank you to our vibrant community! This release includes contributions from several new contributors, further enriching Documenso's capabilities.
|
||||||
|
|
||||||
|
We're excited to see how you'll use these new features to streamline your document workflows. As always, we appreciate your feedback and support in making Documenso the best open-source document signing solution available.
|
||||||
|
|
||||||
|
Enjoy exploring v1.7.0!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Documenso v1.6.1: Internationalization, Enhanced OIDC, and More
|
# Documenso v1.6.1: Internationalization, Enhanced OIDC, and More
|
||||||
|
|
||||||
We're excited to announce the release of Documenso v1.6.1, which brings several improvements to enhance your document signing experience. Here are the key updates:
|
We're excited to announce the release of Documenso v1.6.1, which brings several improvements to enhance your document signing experience. Here are the key updates:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/marketing",
|
"name": "@documenso/marketing",
|
||||||
"version": "1.6.1-rc.1",
|
"version": "1.7.1-rc.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -20,8 +20,8 @@
|
|||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
"@lingui/macro": "^4.11.1",
|
"@lingui/macro": "^4.11.3",
|
||||||
"@lingui/react": "^4.11.1",
|
"@lingui/react": "^4.11.3",
|
||||||
"@openstatus/react": "^0.0.3",
|
"@openstatus/react": "^0.0.3",
|
||||||
"cmdk": "^0.2.1",
|
"cmdk": "^0.2.1",
|
||||||
"contentlayer": "^0.3.4",
|
"contentlayer": "^0.3.4",
|
||||||
@ -32,16 +32,16 @@
|
|||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "14.0.3",
|
"next": "14.2.6",
|
||||||
"next-auth": "4.24.5",
|
"next-auth": "4.24.5",
|
||||||
"next-axiom": "^1.1.1",
|
"next-axiom": "^1.1.1",
|
||||||
"next-contentlayer": "^0.3.4",
|
"next-contentlayer": "^0.3.4",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
"posthog-js": "^1.77.3",
|
"posthog-js": "^1.77.3",
|
||||||
"react": "18.2.0",
|
"react": "^18",
|
||||||
"react-confetti": "^6.1.0",
|
"react-confetti": "^6.1.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"recharts": "^2.7.2",
|
"recharts": "^2.7.2",
|
||||||
@ -50,18 +50,10 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lingui/loader": "^4.11.1",
|
"@lingui/loader": "^4.11.3",
|
||||||
"@lingui/swc-plugin": "4.0.6",
|
"@lingui/swc-plugin": "4.0.8",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "^18"
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"next-auth": {
|
|
||||||
"next": "$next"
|
|
||||||
},
|
|
||||||
"next-contentlayer": {
|
|
||||||
"next": "$next"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
apps/marketing/process-env.d.ts
vendored
1
apps/marketing/process-env.d.ts
vendored
@ -2,6 +2,7 @@ declare namespace NodeJS {
|
|||||||
export interface ProcessEnv {
|
export interface ProcessEnv {
|
||||||
NEXT_PUBLIC_WEBAPP_URL?: string;
|
NEXT_PUBLIC_WEBAPP_URL?: string;
|
||||||
NEXT_PUBLIC_MARKETING_URL?: string;
|
NEXT_PUBLIC_MARKETING_URL?: string;
|
||||||
|
NEXT_PRIVATE_INTERNAL_WEBAPP_URL?: string;
|
||||||
|
|
||||||
NEXT_PRIVATE_DATABASE_URL: string;
|
NEXT_PRIVATE_DATABASE_URL: string;
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@ -5,6 +5,8 @@ import { allDocuments } from 'contentlayer/generated';
|
|||||||
import type { MDXComponents } from 'mdx/types';
|
import type { MDXComponents } from 'mdx/types';
|
||||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export const generateMetadata = ({ params }: { params: { content: string } }) => {
|
export const generateMetadata = ({ params }: { params: { content: string } }) => {
|
||||||
@ -29,6 +31,8 @@ const mdxComponents: MDXComponents = {
|
|||||||
* Will render the document if it exists, otherwise will return a 404.
|
* Will render the document if it exists, otherwise will return a 404.
|
||||||
*/
|
*/
|
||||||
export default function ContentPage({ params }: { params: { content: string } }) {
|
export default function ContentPage({ params }: { params: { content: string } }) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const post = allDocuments.find((post) => post._raw.flattenedPath === params.content);
|
const post = allDocuments.find((post) => post._raw.flattenedPath === params.content);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { ChevronLeft } from 'lucide-react';
|
|||||||
import type { MDXComponents } from 'mdx/types';
|
import type { MDXComponents } from 'mdx/types';
|
||||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
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 { CallToAction } from '~/components/(marketing)/call-to-action';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@ -47,6 +49,8 @@ const mdxComponents: MDXComponents = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function BlogPostPage({ params }: { params: { post: string } }) {
|
export default function BlogPostPage({ params }: { params: { post: string } }) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const post = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
|
const post = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
@ -46,8 +48,8 @@ export const SinglePlayerClient = () => {
|
|||||||
|
|
||||||
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Add document',
|
title: msg`Add document`,
|
||||||
description: 'Upload a document and add fields.',
|
description: msg`Upload a document and add fields.`,
|
||||||
stepIndex: 1,
|
stepIndex: 1,
|
||||||
onBackStep: uploadedFile
|
onBackStep: uploadedFile
|
||||||
? () => {
|
? () => {
|
||||||
@ -58,8 +60,8 @@ export const SinglePlayerClient = () => {
|
|||||||
onNextStep: () => setStep('sign'),
|
onNextStep: () => setStep('sign'),
|
||||||
},
|
},
|
||||||
sign: {
|
sign: {
|
||||||
title: 'Sign',
|
title: msg`Sign`,
|
||||||
description: 'Enter your details.',
|
description: msg`Enter your details.`,
|
||||||
stepIndex: 2,
|
stepIndex: 2,
|
||||||
onBackStep: () => setStep('fields'),
|
onBackStep: () => setStep('fields'),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { SinglePlayerClient } from './client';
|
import { SinglePlayerClient } from './client';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -13,5 +15,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
// !: the Single Player Mode page. This regression was introduced during
|
// !: the Single Player Mode page. This regression was introduced during
|
||||||
// !: the upgrade of Next.js to v13.5.x.
|
// !: the upgrade of Next.js to v13.5.x.
|
||||||
export default function SingleplayerPage() {
|
export default function SingleplayerPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return <SinglePlayerClient />;
|
return <SinglePlayerClient />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
import { Caveat, Inter } from 'next/font/google';
|
import { Caveat, Inter } from 'next/font/google';
|
||||||
import { cookies, headers } from 'next/headers';
|
|
||||||
|
|
||||||
import { AxiomWebVitals } from 'next-axiom';
|
import { AxiomWebVitals } from 'next-axiom';
|
||||||
import { PublicEnvScript } from 'next-runtime-env';
|
import { PublicEnvScript } from 'next-runtime-env';
|
||||||
@ -10,8 +9,6 @@ import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/featur
|
|||||||
import { I18nClientProvider } from '@documenso/lib/client-only/providers/i18n.client';
|
import { I18nClientProvider } from '@documenso/lib/client-only/providers/i18n.client';
|
||||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
|
||||||
import type { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
|
||||||
import { ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
|
||||||
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -59,25 +56,7 @@ export function generateMetadata() {
|
|||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const flags = await getAllAnonymousFlags();
|
const flags = await getAllAnonymousFlags();
|
||||||
|
|
||||||
let overrideLang: (typeof SUPPORTED_LANGUAGE_CODES)[number] | undefined;
|
const { lang, locales, i18n } = setupI18nSSR();
|
||||||
|
|
||||||
// Should be safe to remove when we upgrade NextJS.
|
|
||||||
// https://github.com/vercel/next.js/pull/65008
|
|
||||||
// Currently if the middleware sets the cookie, it's not accessible in the cookies
|
|
||||||
// during the same render.
|
|
||||||
// So we go the roundabout way of checking the header for the set-cookie value.
|
|
||||||
if (!cookies().get('i18n')) {
|
|
||||||
const setCookieValue = headers().get('set-cookie');
|
|
||||||
const i18nCookie = setCookieValue?.split(';').find((cookie) => cookie.startsWith('i18n='));
|
|
||||||
|
|
||||||
if (i18nCookie) {
|
|
||||||
const i18n = i18nCookie.split('=')[1];
|
|
||||||
|
|
||||||
overrideLang = ZSupportedLanguageCodeSchema.parse(i18n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { lang, i18n } = setupI18nSSR(overrideLang);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
@ -105,7 +84,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<PlausibleProvider>
|
<PlausibleProvider>
|
||||||
<TrpcProvider>
|
<TrpcProvider>
|
||||||
<I18nClientProvider initialLocale={lang} initialMessages={i18n.messages}>
|
<I18nClientProvider
|
||||||
|
initialLocaleData={{ lang, locales }}
|
||||||
|
initialMessages={i18n.messages}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</I18nClientProvider>
|
</I18nClientProvider>
|
||||||
</TrpcProvider>
|
</TrpcProvider>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import { type HTMLAttributes, useState } from 'react';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@ -9,15 +9,15 @@ import { msg } from '@lingui/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { FaXTwitter } from 'react-icons/fa6';
|
import { FaXTwitter } from 'react-icons/fa6';
|
||||||
import { LiaDiscord } from 'react-icons/lia';
|
import { LiaDiscord } from 'react-icons/lia';
|
||||||
import { LuGithub } from 'react-icons/lu';
|
import { LuGithub, LuLanguages } from 'react-icons/lu';
|
||||||
|
|
||||||
import LogoImage from '@documenso/assets/logo.png';
|
import LogoImage from '@documenso/assets/logo.png';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
|
||||||
|
|
||||||
import { I18nSwitcher } from '~/components/(marketing)/i18n-switcher';
|
|
||||||
|
|
||||||
// import { StatusWidgetContainer } from './status-widget-container';
|
// import { StatusWidgetContainer } from './status-widget-container';
|
||||||
|
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||||
|
|
||||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
@ -44,7 +44,9 @@ const FOOTER_LINKS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
export const Footer = ({ className, ...props }: FooterProps) => {
|
||||||
const { _ } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
|
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('border-t py-12', className)} {...props}>
|
<div className={cn('border-t py-12', className)} {...props}>
|
||||||
@ -97,13 +99,22 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-row-reverse items-center sm:flex-row">
|
<div className="flex flex-row-reverse items-center sm:flex-row">
|
||||||
<I18nSwitcher className="text-muted-foreground ml-2 rounded-full font-normal sm:mr-2" />
|
<Button
|
||||||
|
className="text-muted-foreground ml-2 rounded-full font-normal sm:mr-2"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setLanguageSwitcherOpen(true)}
|
||||||
|
>
|
||||||
|
<LuLanguages className="mr-1.5 h-4 w-4" />
|
||||||
|
{SUPPORTED_LANGUAGES[i18n.locale]?.full || i18n.locale}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="flex flex-wrap">
|
<div className="flex flex-wrap">
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LanguageSwitcherDialog open={languageSwitcherOpen} setOpen={setLanguageSwitcherOpen} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { CheckIcon } from 'lucide-react';
|
|
||||||
import { LuLanguages } from 'react-icons/lu';
|
|
||||||
|
|
||||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
|
||||||
import { switchI18NLanguage } from '@documenso/lib/server-only/i18n/switch-i18n-language';
|
|
||||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
CommandDialog,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from '@documenso/ui/primitives/command';
|
|
||||||
|
|
||||||
type I18nSwitcherProps = {
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const I18nSwitcher = ({ className }: I18nSwitcherProps) => {
|
|
||||||
const { i18n, _ } = useLingui();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [value, setValue] = useState(i18n.locale);
|
|
||||||
|
|
||||||
const setLanguage = async (lang: string) => {
|
|
||||||
setValue(lang);
|
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
await dynamicActivate(i18n, lang);
|
|
||||||
await switchI18NLanguage(lang);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button className={className} variant="ghost" onClick={() => setOpen(true)}>
|
|
||||||
<LuLanguages className="mr-1.5 h-4 w-4" />
|
|
||||||
{SUPPORTED_LANGUAGES[value]?.full || i18n.locale}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
|
||||||
<CommandInput placeholder={_(msg`Search languages...`)} />
|
|
||||||
|
|
||||||
<CommandList>
|
|
||||||
<CommandGroup>
|
|
||||||
{Object.values(SUPPORTED_LANGUAGES).map((language) => (
|
|
||||||
<CommandItem
|
|
||||||
key={language.short}
|
|
||||||
value={language.full}
|
|
||||||
onSelect={async () => setLanguage(language.short)}
|
|
||||||
>
|
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
'mr-2 h-4 w-4',
|
|
||||||
value === language.short ? 'opacity-100' : 'opacity-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{SUPPORTED_LANGUAGES[language.short].full}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</CommandDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
import { cookies } from 'next/headers';
|
|
||||||
import type { NextRequest } from 'next/server';
|
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { extractSupportedLanguage } from '@documenso/lib/utils/i18n';
|
|
||||||
|
|
||||||
export default function middleware(req: NextRequest) {
|
|
||||||
const lang = extractSupportedLanguage({
|
|
||||||
headers: req.headers,
|
|
||||||
cookies: cookies(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = NextResponse.next();
|
|
||||||
|
|
||||||
response.cookies.set('i18n', lang);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
/*
|
|
||||||
* Match all request paths except for the ones starting with:
|
|
||||||
* - api (API routes)
|
|
||||||
* - _next/static (static files)
|
|
||||||
* - _next/image (image optimization files)
|
|
||||||
* - favicon.ico (favicon file)
|
|
||||||
* - ingest (analytics)
|
|
||||||
* - site.webmanifest
|
|
||||||
*/
|
|
||||||
{
|
|
||||||
source: '/((?!api|_next/static|_next/image|ingest|favicon|site.webmanifest).*)',
|
|
||||||
missing: [
|
|
||||||
{ type: 'header', key: 'next-router-prefetch' },
|
|
||||||
{ type: 'header', key: 'purpose', value: 'prefetch' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@ -8,7 +8,7 @@ const config: LinguiConfig = {
|
|||||||
locales: APP_I18N_OPTIONS.supportedLangs as unknown as string[],
|
locales: APP_I18N_OPTIONS.supportedLangs as unknown as string[],
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
path: '<rootDir>/../../packages/lib/translations/web/{locale}',
|
path: '<rootDir>/../../packages/lib/translations/{locale}/web',
|
||||||
include: ['<rootDir>/apps/web/src'],
|
include: ['<rootDir>/apps/web/src'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/web",
|
"name": "@documenso/web",
|
||||||
"version": "1.6.1-rc.1",
|
"version": "1.7.1-rc.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -23,8 +23,8 @@
|
|||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
"@lingui/macro": "^4.11.1",
|
"@lingui/macro": "^4.11.3",
|
||||||
"@lingui/react": "^4.11.1",
|
"@lingui/react": "^4.11.3",
|
||||||
"@simplewebauthn/browser": "^9.0.1",
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
"@simplewebauthn/server": "^9.0.3",
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
"@tanstack/react-query": "^4.29.5",
|
"@tanstack/react-query": "^4.29.5",
|
||||||
@ -35,7 +35,7 @@
|
|||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "14.0.3",
|
"next": "14.2.6",
|
||||||
"next-auth": "4.24.5",
|
"next-auth": "4.24.5",
|
||||||
"next-axiom": "^1.1.1",
|
"next-axiom": "^1.1.1",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
@ -44,8 +44,9 @@
|
|||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
"posthog-js": "^1.75.3",
|
"posthog-js": "^1.75.3",
|
||||||
"posthog-node": "^3.1.1",
|
"posthog-node": "^3.1.1",
|
||||||
"react": "18.2.0",
|
"react": "^18",
|
||||||
"react-dom": "18.2.0",
|
"react-call": "^1.3.0",
|
||||||
|
"react-dom": "^18",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
@ -60,25 +61,17 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lingui/loader": "^4.11.1",
|
|
||||||
"@lingui/swc-plugin": "4.0.6",
|
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
|
"@lingui/loader": "^4.11.3",
|
||||||
|
"@lingui/swc-plugin": "4.0.8",
|
||||||
"@simplewebauthn/types": "^9.0.1",
|
"@simplewebauthn/types": "^9.0.1",
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/papaparse": "^5.3.14",
|
"@types/papaparse": "^5.3.14",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "18.2.7",
|
"@types/react-dom": "^18",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"typescript": "5.2.2"
|
"typescript": "5.2.2"
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"next-auth": {
|
|
||||||
"next": "$next"
|
|
||||||
},
|
|
||||||
"next-contentlayer": {
|
|
||||||
"next": "$next"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
apps/web/process-env.d.ts
vendored
1
apps/web/process-env.d.ts
vendored
@ -2,6 +2,7 @@ declare namespace NodeJS {
|
|||||||
export interface ProcessEnv {
|
export interface ProcessEnv {
|
||||||
NEXT_PUBLIC_WEBAPP_URL?: string;
|
NEXT_PUBLIC_WEBAPP_URL?: string;
|
||||||
NEXT_PUBLIC_MARKETING_URL?: string;
|
NEXT_PUBLIC_MARKETING_URL?: string;
|
||||||
|
NEXT_PRIVATE_INTERNAL_WEBAPP_URL?: string;
|
||||||
|
|
||||||
NEXT_PRIVATE_DATABASE_URL: string;
|
NEXT_PRIVATE_DATABASE_URL: string;
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import { type Document, SigningStatus } from '@documenso/prisma/client';
|
import { type Document, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -22,20 +25,21 @@ export type AdminActionsProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
|
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
|
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
|
||||||
trpc.admin.resealDocument.useMutation({
|
trpc.admin.resealDocument.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Success',
|
title: _(msg`Success`),
|
||||||
description: 'Document resealed',
|
description: _(msg`Document resealed`),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'Failed to reseal document',
|
description: _(msg`Failed to reseal document`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -54,19 +58,23 @@ export const AdminActions = ({ className, document, recipients }: AdminActionsPr
|
|||||||
)}
|
)}
|
||||||
onClick={() => resealDocument({ id: document.id })}
|
onClick={() => resealDocument({ id: document.id })}
|
||||||
>
|
>
|
||||||
Reseal document
|
<Trans>Reseal document</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
<TooltipContent className="max-w-[40ch]">
|
<TooltipContent className="max-w-[40ch]">
|
||||||
|
<Trans>
|
||||||
Attempts sealing the document again, useful for after a code change has occurred to
|
Attempts sealing the document again, useful for after a code change has occurred to
|
||||||
resolve an erroneous document.
|
resolve an erroneous document.
|
||||||
|
</Trans>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link href={`/admin/users/${document.userId}`}>Go to owner</Link>
|
<Link href={`/admin/users/${document.userId}`}>
|
||||||
|
<Trans>Go to owner</Trans>
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@ -10,7 +12,6 @@ import {
|
|||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
import { AdminActions } from './admin-actions';
|
import { AdminActions } from './admin-actions';
|
||||||
import { RecipientItem } from './recipient-item';
|
import { RecipientItem } from './recipient-item';
|
||||||
@ -23,6 +24,8 @@ type AdminDocumentDetailsPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
|
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
|
||||||
|
const { i18n } = setupI18nSSR();
|
||||||
|
|
||||||
const document = await getEntireDocument({ id: Number(params.id) });
|
const document = await getEntireDocument({ id: Number(params.id) });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -35,28 +38,33 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
|
|||||||
|
|
||||||
{document.deletedAt && (
|
{document.deletedAt && (
|
||||||
<Badge size="large" variant="destructive">
|
<Badge size="large" variant="destructive">
|
||||||
Deleted
|
<Trans>Deleted</Trans>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-4 text-sm">
|
<div className="text-muted-foreground mt-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
Created on: <LocaleDate date={document.createdAt} format={DateTime.DATETIME_MED} />
|
<Trans>Created on</Trans>: {i18n.date(document.createdAt, DateTime.DATETIME_MED)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
Last updated at: <LocaleDate date={document.updatedAt} format={DateTime.DATETIME_MED} />
|
<Trans>Last updated at</Trans>: {i18n.date(document.updatedAt, DateTime.DATETIME_MED)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold">Admin Actions</h2>
|
<h2 className="text-lg font-semibold">
|
||||||
|
<Trans>Admin Actions</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<AdminActions className="mt-2" document={document} recipients={document.Recipient} />
|
<AdminActions className="mt-2" document={document} recipients={document.Recipient} />
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
<h2 className="text-lg font-semibold">Recipients</h2>
|
<h2 className="text-lg font-semibold">
|
||||||
|
<Trans>Recipients</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Accordion type="multiple" className="space-y-4">
|
<Accordion type="multiple" className="space-y-4">
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@ -13,6 +17,7 @@ import {
|
|||||||
} from '@documenso/prisma/client';
|
} from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@ -43,7 +48,9 @@ export type RecipientItemProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const form = useForm<TAdminUpdateRecipientFormSchema>({
|
const form = useForm<TAdminUpdateRecipientFormSchema>({
|
||||||
@ -55,6 +62,50 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
|||||||
|
|
||||||
const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
|
const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation();
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: 'ID',
|
||||||
|
accessorKey: 'id',
|
||||||
|
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Type`),
|
||||||
|
accessorKey: 'type',
|
||||||
|
cell: ({ row }) => <div>{row.original.type}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Inserted`),
|
||||||
|
accessorKey: 'inserted',
|
||||||
|
cell: ({ row }) => <div>{row.original.inserted ? 'True' : 'False'}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Value`),
|
||||||
|
accessorKey: 'customText',
|
||||||
|
cell: ({ row }) => <div>{row.original.customText}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Signature`),
|
||||||
|
accessorKey: 'signature',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
{row.original.Signature?.typedSignature && (
|
||||||
|
<span>{row.original.Signature.typedSignature}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{row.original.Signature?.signatureImageAsBase64 && (
|
||||||
|
<img
|
||||||
|
src={row.original.Signature.signatureImageAsBase64}
|
||||||
|
alt="Signature"
|
||||||
|
className="h-12 w-full dark:invert"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof recipient)['Field'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => {
|
const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await updateRecipient({
|
await updateRecipient({
|
||||||
@ -64,14 +115,14 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Recipient updated',
|
title: _(msg`Recipient updated`),
|
||||||
description: 'The recipient has been updated successfully',
|
description: _(msg`The recipient has been updated successfully`),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to update recipient',
|
title: _(msg`Failed to update recipient`),
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@ -93,7 +144,9 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormLabel required>Name</FormLabel>
|
<FormLabel required>
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
@ -109,7 +162,9 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
|||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormLabel required>Email</FormLabel>
|
<FormLabel required>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="email" {...field} />
|
<Input type="email" {...field} />
|
||||||
@ -122,7 +177,7 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
Update Recipient
|
<Trans>Update Recipient</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -131,52 +186,11 @@ export const RecipientItem = ({ recipient }: RecipientItemProps) => {
|
|||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
<h2 className="mb-4 text-lg font-semibold">Fields</h2>
|
<h2 className="mb-4 text-lg font-semibold">
|
||||||
|
<Trans>Fields</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<DataTable
|
<DataTable columns={columns} data={recipient.Field} />
|
||||||
data={recipient.Field}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
header: 'ID',
|
|
||||||
accessorKey: 'id',
|
|
||||||
cell: ({ row }) => <div>{row.original.id}</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Type',
|
|
||||||
accessorKey: 'type',
|
|
||||||
cell: ({ row }) => <div>{row.original.type}</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Inserted',
|
|
||||||
accessorKey: 'inserted',
|
|
||||||
cell: ({ row }) => <div>{row.original.inserted ? 'True' : 'False'}</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Value',
|
|
||||||
accessorKey: 'customText',
|
|
||||||
cell: ({ row }) => <div>{row.original.customText}</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Signature',
|
|
||||||
accessorKey: 'signature',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div>
|
|
||||||
{row.original.Signature?.typedSignature && (
|
|
||||||
<span>{row.original.Signature.typedSignature}</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{row.original.Signature?.signatureImageAsBase64 && (
|
|
||||||
<img
|
|
||||||
src={row.original.Signature.signatureImageAsBase64}
|
|
||||||
alt="Signature"
|
|
||||||
className="h-12 w-full dark:invert"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import type { Document } from '@documenso/prisma/client';
|
import type { Document } from '@documenso/prisma/client';
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -26,7 +29,9 @@ export type SuperDeleteDocumentDialogProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
|
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
@ -43,7 +48,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
|
|||||||
await deleteDocument({ id: document.id, reason });
|
await deleteDocument({ id: document.id, reason });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document deleted',
|
title: _(msg`Document deleted`),
|
||||||
description: 'The Document has been deleted successfully.',
|
description: 'The Document has been deleted successfully.',
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
@ -52,13 +57,13 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||||
toast({
|
toast({
|
||||||
title: 'An error occurred',
|
title: _(msg`An error occurred`),
|
||||||
description: err.message,
|
description: err.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'An unknown error occurred',
|
title: _(msg`An unknown error occurred`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description:
|
description:
|
||||||
err.message ??
|
err.message ??
|
||||||
@ -76,31 +81,41 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<AlertTitle>Delete Document</AlertTitle>
|
<AlertTitle>
|
||||||
|
<Trans>Delete Document</Trans>
|
||||||
|
</AlertTitle>
|
||||||
<AlertDescription className="mr-2">
|
<AlertDescription className="mr-2">
|
||||||
|
<Trans>
|
||||||
Delete the document. This action is irreversible so proceed with caution.
|
Delete the document. This action is irreversible so proceed with caution.
|
||||||
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="destructive">Delete Document</Button>
|
<Button variant="destructive">
|
||||||
|
<Trans>Delete Document</Trans>
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="space-y-4">
|
||||||
<DialogTitle>Delete Document</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Delete Document</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription className="selection:bg-red-100">
|
<AlertDescription className="selection:bg-red-100">
|
||||||
This action is not reversible. Please be certain.
|
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<DialogDescription>To confirm, please enter the reason</DialogDescription>
|
<DialogDescription>
|
||||||
|
<Trans>To confirm, please enter the reason</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
@ -117,7 +132,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={!reason}
|
disabled={!reason}
|
||||||
>
|
>
|
||||||
{isDeletingDocument ? 'Deleting document...' : 'Delete Document'}
|
<Trans>Delete document</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
@ -12,17 +14,19 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
|
|||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
// export type AdminDocumentResultsProps = {};
|
// export type AdminDocumentResultsProps = {};
|
||||||
|
|
||||||
export const AdminDocumentResults = () => {
|
export const AdminDocumentResults = () => {
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
@ -45,32 +49,22 @@ export const AdminDocumentResults = () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPaginationChange = (newPage: number, newPerPage: number) => {
|
const results = findDocumentsData ?? {
|
||||||
updateSearchParams({
|
data: [],
|
||||||
page: newPage,
|
perPage: 20,
|
||||||
perPage: newPerPage,
|
currentPage: 1,
|
||||||
});
|
totalPages: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const columns = useMemo(() => {
|
||||||
<div>
|
return [
|
||||||
<Input
|
|
||||||
type="search"
|
|
||||||
placeholder="Search by document title"
|
|
||||||
value={term}
|
|
||||||
onChange={(e) => setTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative mt-4">
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{
|
{
|
||||||
header: 'Created',
|
header: _(msg`Created`),
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Title',
|
header: _(msg`Title`),
|
||||||
accessorKey: 'title',
|
accessorKey: 'title',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
@ -84,12 +78,12 @@ export const AdminDocumentResults = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Status',
|
header: _(msg`Status`),
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Owner',
|
header: _(msg`Owner`),
|
||||||
accessorKey: 'owner',
|
accessorKey: 'owner',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const avatarFallbackText = row.original.User.name
|
const avatarFallbackText = row.original.User.name
|
||||||
@ -127,13 +121,34 @@ export const AdminDocumentResults = () => {
|
|||||||
{
|
{
|
||||||
header: 'Last updated',
|
header: 'Last updated',
|
||||||
accessorKey: 'updatedAt',
|
accessorKey: 'updatedAt',
|
||||||
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
|
cell: ({ row }) => i18n.date(row.original.updatedAt),
|
||||||
},
|
},
|
||||||
]}
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
data={findDocumentsData?.data ?? []}
|
}, []);
|
||||||
perPage={findDocumentsData?.perPage ?? 20}
|
|
||||||
currentPage={findDocumentsData?.currentPage ?? 1}
|
const onPaginationChange = (newPage: number, newPerPage: number) => {
|
||||||
totalPages={findDocumentsData?.totalPages ?? 1}
|
updateSearchParams({
|
||||||
|
page: newPage,
|
||||||
|
perPage: newPerPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={_(msg`Search by document title`)}
|
||||||
|
value={term}
|
||||||
|
onChange={(e) => setTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative mt-4">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage ?? 20}
|
||||||
|
currentPage={results.currentPage ?? 1}
|
||||||
|
totalPages={results.totalPages ?? 1}
|
||||||
onPaginationChange={onPaginationChange}
|
onPaginationChange={onPaginationChange}
|
||||||
>
|
>
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
|||||||
@ -1,9 +1,17 @@
|
|||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { AdminDocumentResults } from './document-results';
|
import { AdminDocumentResults } from './document-results';
|
||||||
|
|
||||||
export default function AdminDocumentsPage() {
|
export default function AdminDocumentsPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-semibold">Manage documents</h2>
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<Trans>Manage documents</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<AdminDocumentResults />
|
<AdminDocumentResults />
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
|
|
||||||
@ -12,6 +13,8 @@ export type AdminSectionLayoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
|
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
if (!isAdmin(user)) {
|
if (!isAdmin(user)) {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { BarChart3, FileStack, Settings, Users, Wallet2 } from 'lucide-react';
|
import { BarChart3, FileStack, Settings, Users, Wallet2 } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -33,7 +34,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
|||||||
>
|
>
|
||||||
<Link href="/admin/stats">
|
<Link href="/admin/stats">
|
||||||
<BarChart3 className="mr-2 h-5 w-5" />
|
<BarChart3 className="mr-2 h-5 w-5" />
|
||||||
Stats
|
<Trans>Stats</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -47,7 +48,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
|||||||
>
|
>
|
||||||
<Link href="/admin/users">
|
<Link href="/admin/users">
|
||||||
<Users className="mr-2 h-5 w-5" />
|
<Users className="mr-2 h-5 w-5" />
|
||||||
Users
|
<Trans>Users</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -61,7 +62,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
|||||||
>
|
>
|
||||||
<Link href="/admin/documents">
|
<Link href="/admin/documents">
|
||||||
<FileStack className="mr-2 h-5 w-5" />
|
<FileStack className="mr-2 h-5 w-5" />
|
||||||
Documents
|
<Trans>Documents</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -75,7 +76,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
|||||||
>
|
>
|
||||||
<Link href="/admin/subscriptions">
|
<Link href="/admin/subscriptions">
|
||||||
<Wallet2 className="mr-2 h-5 w-5" />
|
<Wallet2 className="mr-2 h-5 w-5" />
|
||||||
Subscriptions
|
<Trans>Subscriptions</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -89,7 +90,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
|||||||
>
|
>
|
||||||
<Link href="/admin/site-settings">
|
<Link href="/admin/site-settings">
|
||||||
<Settings className="mr-2 h-5 w-5" />
|
<Settings className="mr-2 h-5 w-5" />
|
||||||
Site Settings
|
<Trans>Site Settings</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
@ -37,8 +39,10 @@ export type BannerFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function BannerForm({ banner }: BannerFormProps) {
|
export function BannerForm({ banner }: BannerFormProps) {
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const form = useForm<TBannerFormSchema>({
|
const form = useForm<TBannerFormSchema>({
|
||||||
resolver: zodResolver(ZBannerFormSchema),
|
resolver: zodResolver(ZBannerFormSchema),
|
||||||
@ -67,8 +71,8 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Banner Updated',
|
title: _(msg`Banner Updated`),
|
||||||
description: 'Your banner has been updated successfully.',
|
description: _(msg`Your banner has been updated successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -76,16 +80,17 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||||
toast({
|
toast({
|
||||||
title: 'An error occurred',
|
title: _(msg`An error occurred`),
|
||||||
description: err.message,
|
description: err.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'An unknown error occurred',
|
title: _(msg`An unknown error occurred`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description:
|
description: _(
|
||||||
'We encountered an unknown error while attempting to update the banner. Please try again later.',
|
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,10 +98,14 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold">Site Banner</h2>
|
<h2 className="font-semibold">
|
||||||
|
<Trans>Site Banner</Trans>
|
||||||
|
</h2>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
The site banner is a message that is shown at the top of the site. It can be used to display
|
<Trans>
|
||||||
important information to your users.
|
The site banner is a message that is shown at the top of the site. It can be used to
|
||||||
|
display important information to your users.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@ -110,7 +119,9 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
name="enabled"
|
name="enabled"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormLabel>Enabled</FormLabel>
|
<FormLabel>
|
||||||
|
<Trans>Enabled</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div>
|
<div>
|
||||||
@ -131,7 +142,9 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
name="data.bgColor"
|
name="data.bgColor"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Background Color</FormLabel>
|
<FormLabel>
|
||||||
|
<Trans>Background Color</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div>
|
<div>
|
||||||
@ -149,7 +162,9 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
name="data.textColor"
|
name="data.textColor"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Text Color</FormLabel>
|
<FormLabel>
|
||||||
|
<Trans>Text Color</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div>
|
<div>
|
||||||
@ -170,14 +185,16 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
name="data.content"
|
name="data.content"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Content</FormLabel>
|
<FormLabel>
|
||||||
|
<Trans>Content</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea className="h-32 resize-none" {...field} />
|
<Textarea className="h-32 resize-none" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
The content to show in the banner, HTML is allowed
|
<Trans>The content to show in the banner, HTML is allowed</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -191,7 +208,7 @@ export function BannerForm({ banner }: BannerFormProps) {
|
|||||||
loading={isUpdateSiteSettingLoading}
|
loading={isUpdateSiteSettingLoading}
|
||||||
className="mt-4 justify-end self-end"
|
className="mt-4 justify-end self-end"
|
||||||
>
|
>
|
||||||
Update Banner
|
<Trans>Update Banner</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
||||||
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||||
|
|
||||||
@ -8,13 +12,20 @@ import { BannerForm } from './banner-form';
|
|||||||
// import { BannerForm } from './banner-form';
|
// import { BannerForm } from './banner-form';
|
||||||
|
|
||||||
export default async function AdminBannerPage() {
|
export default async function AdminBannerPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const banner = await getSiteSettings().then((settings) =>
|
const banner = await getSiteSettings().then((settings) =>
|
||||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader title="Site Settings" subtitle="Manage your site settings here" />
|
<SettingsHeader
|
||||||
|
title={_(msg`Site Settings`)}
|
||||||
|
subtitle={_(msg`Manage your site settings here`)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<BannerForm banner={banner} />
|
<BannerForm banner={banner} />
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import {
|
import {
|
||||||
File,
|
File,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
@ -12,6 +14,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||||
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||||
import {
|
import {
|
||||||
@ -27,6 +30,10 @@ import { SignerConversionChart } from './signer-conversion-chart';
|
|||||||
import { UserWithDocumentChart } from './user-with-document';
|
import { UserWithDocumentChart } from './user-with-document';
|
||||||
|
|
||||||
export default async function AdminStatsPage() {
|
export default async function AdminStatsPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [
|
const [
|
||||||
usersCount,
|
usersCount,
|
||||||
usersWithSubscriptionsCount,
|
usersWithSubscriptionsCount,
|
||||||
@ -49,64 +56,98 @@ export default async function AdminStatsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-semibold">Instance Stats</h2>
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<Trans>Instance Stats</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<CardMetric icon={Users} title="Total Users" value={usersCount} />
|
<CardMetric icon={Users} title={_(msg`Total Users`)} value={usersCount} />
|
||||||
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
|
<CardMetric icon={File} title={_(msg`Total Documents`)} value={docStats.ALL} />
|
||||||
<CardMetric
|
<CardMetric
|
||||||
icon={UserPlus}
|
icon={UserPlus}
|
||||||
title="Active Subscriptions"
|
title={_(msg`Active Subscriptions`)}
|
||||||
value={usersWithSubscriptionsCount}
|
value={usersWithSubscriptionsCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardMetric icon={FileCog} title="App Version" value={`v${process.env.APP_VERSION}`} />
|
<CardMetric
|
||||||
|
icon={FileCog}
|
||||||
|
title={_(msg`App Version`)}
|
||||||
|
value={`v${process.env.APP_VERSION}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 gap-8">
|
<div className="mt-16 gap-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-3xl font-semibold">Document metrics</h3>
|
<h3 className="text-3xl font-semibold">
|
||||||
|
<Trans>Document metrics</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<CardMetric icon={FileEdit} title="Drafted Documents" value={docStats.DRAFT} />
|
<CardMetric icon={FileEdit} title={_(msg`Drafted Documents`)} value={docStats.DRAFT} />
|
||||||
<CardMetric icon={FileClock} title="Pending Documents" value={docStats.PENDING} />
|
<CardMetric
|
||||||
<CardMetric icon={FileCheck} title="Completed Documents" value={docStats.COMPLETED} />
|
icon={FileClock}
|
||||||
|
title={_(msg`Pending Documents`)}
|
||||||
|
value={docStats.PENDING}
|
||||||
|
/>
|
||||||
|
<CardMetric
|
||||||
|
icon={FileCheck}
|
||||||
|
title={_(msg`Completed Documents`)}
|
||||||
|
value={docStats.COMPLETED}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
|
<h3 className="text-3xl font-semibold">
|
||||||
|
<Trans>Recipients metrics</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<CardMetric
|
<CardMetric
|
||||||
icon={UserSquare2}
|
icon={UserSquare2}
|
||||||
title="Total Recipients"
|
title={_(msg`Total Recipients`)}
|
||||||
value={recipientStats.TOTAL_RECIPIENTS}
|
value={recipientStats.TOTAL_RECIPIENTS}
|
||||||
/>
|
/>
|
||||||
<CardMetric icon={Mail} title="Documents Received" value={recipientStats.SENT} />
|
<CardMetric
|
||||||
<CardMetric icon={MailOpen} title="Documents Viewed" value={recipientStats.OPENED} />
|
icon={Mail}
|
||||||
<CardMetric icon={PenTool} title="Signatures Collected" value={recipientStats.SIGNED} />
|
title={_(msg`Documents Received`)}
|
||||||
|
value={recipientStats.SENT}
|
||||||
|
/>
|
||||||
|
<CardMetric
|
||||||
|
icon={MailOpen}
|
||||||
|
title={_(msg`Documents Viewed`)}
|
||||||
|
value={recipientStats.OPENED}
|
||||||
|
/>
|
||||||
|
<CardMetric
|
||||||
|
icon={PenTool}
|
||||||
|
title={_(msg`Signatures Collected`)}
|
||||||
|
value={recipientStats.SIGNED}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16">
|
<div className="mt-16">
|
||||||
<h3 className="text-3xl font-semibold">Charts</h3>
|
<h3 className="text-3xl font-semibold">
|
||||||
|
<Trans>Charts</Trans>
|
||||||
|
</h3>
|
||||||
<div className="mt-5 grid grid-cols-2 gap-8">
|
<div className="mt-5 grid grid-cols-2 gap-8">
|
||||||
<UserWithDocumentChart
|
<UserWithDocumentChart
|
||||||
data={MONTHLY_USERS_SIGNED}
|
data={MONTHLY_USERS_SIGNED}
|
||||||
title="MAU (created document)"
|
title={_(msg`MAU (created document)`)}
|
||||||
tooltip="Monthly Active Users: Users that created at least one Document"
|
tooltip={_(msg`Monthly Active Users: Users that created at least one Document`)}
|
||||||
/>
|
/>
|
||||||
<UserWithDocumentChart
|
<UserWithDocumentChart
|
||||||
data={MONTHLY_USERS_SIGNED}
|
data={MONTHLY_USERS_SIGNED}
|
||||||
completed
|
completed
|
||||||
title="MAU (had document completed)"
|
title={_(msg`MAU (had document completed)`)}
|
||||||
tooltip="Monthly Active Users: Users that had at least one of their documents completed"
|
tooltip={_(
|
||||||
|
msg`Monthly Active Users: Users that had at least one of their documents completed`,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<SignerConversionChart title="Signers that Signed Up" data={signerConversionMonthly} />
|
<SignerConversionChart title="Signers that Signed Up" data={signerConversionMonthly} />
|
||||||
<SignerConversionChart
|
<SignerConversionChart
|
||||||
title="Total Signers that Signed Up"
|
title={_(msg`Total Signers that Signed Up`)}
|
||||||
data={signerConversionMonthly}
|
data={signerConversionMonthly}
|
||||||
cummulative
|
cummulative
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions';
|
import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@ -11,20 +14,32 @@ import {
|
|||||||
} from '@documenso/ui/primitives/table';
|
} from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
export default async function Subscriptions() {
|
export default async function Subscriptions() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const subscriptions = await findSubscriptions();
|
const subscriptions = await findSubscriptions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-semibold">Manage subscriptions</h2>
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<Trans>Manage subscriptions</Trans>
|
||||||
|
</h2>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ID</TableHead>
|
<TableHead>ID</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>
|
||||||
<TableHead>Created At</TableHead>
|
<Trans>Status</Trans>
|
||||||
<TableHead>Ends On</TableHead>
|
</TableHead>
|
||||||
<TableHead>User ID</TableHead>
|
<TableHead>
|
||||||
|
<Trans>Created At</Trans>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Trans>Ends On</Trans>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Trans>User ID</Trans>
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -27,8 +30,10 @@ export type DeleteUserDialogProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => {
|
export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => {
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
@ -43,8 +48,8 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Account deleted',
|
title: _(msg`Account deleted`),
|
||||||
description: 'The account has been deleted successfully.',
|
description: _(msg`The account has been deleted successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -52,17 +57,19 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||||
toast({
|
toast({
|
||||||
title: 'An error occurred',
|
title: _(msg`An error occurred`),
|
||||||
description: err.message,
|
description: err.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'An unknown error occurred',
|
title: _(msg`An unknown error occurred`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description:
|
description:
|
||||||
err.message ??
|
err.message ??
|
||||||
'We encountered an unknown error while attempting to delete your account. Please try again later.',
|
_(
|
||||||
|
msg`We encountered an unknown error while attempting to delete your account. Please try again later.`,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -77,31 +84,39 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
|
|||||||
<div>
|
<div>
|
||||||
<AlertTitle>Delete Account</AlertTitle>
|
<AlertTitle>Delete Account</AlertTitle>
|
||||||
<AlertDescription className="mr-2">
|
<AlertDescription className="mr-2">
|
||||||
|
<Trans>
|
||||||
Delete the users account and all its contents. This action is irreversible and will
|
Delete the users account and all its contents. This action is irreversible and will
|
||||||
cancel their subscription, so proceed with caution.
|
cancel their subscription, so proceed with caution.
|
||||||
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="destructive">Delete Account</Button>
|
<Button variant="destructive">
|
||||||
|
<Trans>Delete Account</Trans>
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="space-y-4">
|
||||||
<DialogTitle>Delete Account</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Delete Account</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription className="selection:bg-red-100">
|
<AlertDescription className="selection:bg-red-100">
|
||||||
This action is not reversible. Please be certain.
|
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
To confirm, please enter the accounts email address <br />({user.email}).
|
To confirm, please enter the accounts email address <br />({user.email}).
|
||||||
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
@ -119,7 +134,7 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={email !== user.email}
|
disabled={email !== user.email}
|
||||||
>
|
>
|
||||||
{isDeletingUser ? 'Deleting account...' : 'Delete Account'}
|
<Trans>Delete account</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
|
||||||
import { Role } from '@documenso/prisma/client';
|
import { Role } from '@documenso/prisma/client';
|
||||||
@ -59,7 +60,9 @@ const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
|||||||
<PopoverContent className="w-[200px] p-0">
|
<PopoverContent className="w-[200px] p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={selectedValues.join(', ')} />
|
<CommandInput placeholder={selectedValues.join(', ')} />
|
||||||
<CommandEmpty>No value found.</CommandEmpty>
|
<CommandEmpty>
|
||||||
|
<Trans>No value found.</Trans>
|
||||||
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{allRoles.map((value: string, i: number) => (
|
{allRoles.map((value: string, i: number) => (
|
||||||
<CommandItem key={i} onSelect={() => handleSelect(value)}>
|
<CommandItem key={i} onSelect={() => handleSelect(value)}>
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
@ -28,7 +30,9 @@ const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
|
|||||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||||
|
|
||||||
export default function UserPage({ params }: { params: { id: number } }) {
|
export default function UserPage({ params }: { params: { id: number } }) {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { data: user } = trpc.profile.getUser.useQuery(
|
const { data: user } = trpc.profile.getUser.useQuery(
|
||||||
@ -65,14 +69,14 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Profile updated',
|
title: _(msg`Profile updated`),
|
||||||
description: 'Your profile has been updated.',
|
description: _(msg`Your profile has been updated.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while updating your profile.',
|
description: _(msg`An error occurred while updating your profile.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -80,7 +84,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-semibold">Manage {user?.name}'s profile</h2>
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<Trans>Manage {user?.name}'s profile</Trans>
|
||||||
|
</h2>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<fieldset className="mt-6 flex w-full flex-col gap-y-4">
|
<fieldset className="mt-6 flex w-full flex-col gap-y-4">
|
||||||
@ -89,7 +95,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-muted-foreground">Name</FormLabel>
|
<FormLabel className="text-muted-foreground">
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" {...field} value={field.value ?? ''} />
|
<Input type="text" {...field} value={field.value ?? ''} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -102,7 +110,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-muted-foreground">Email</FormLabel>
|
<FormLabel className="text-muted-foreground">
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" {...field} />
|
<Input type="text" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -117,7 +127,9 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
render={({ field: { onChange } }) => (
|
render={({ field: { onChange } }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
<FormLabel className="text-muted-foreground">
|
||||||
|
<Trans>Roles</Trans>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<MultiSelectRoleCombobox
|
<MultiSelectRoleCombobox
|
||||||
listValues={roles}
|
listValues={roles}
|
||||||
@ -132,7 +144,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
Update user
|
<Trans>Update user</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useTransition } from 'react';
|
import { useEffect, useMemo, useState, useTransition } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Edit, Loader } from 'lucide-react';
|
import { Edit, Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { Document, Role, Subscription } from '@documenso/prisma/client';
|
import type { Document, Role, Subscription } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
@ -45,11 +48,70 @@ export const UsersDataTable = ({
|
|||||||
page,
|
page,
|
||||||
individualPriceIds,
|
individualPriceIds,
|
||||||
}: UsersDataTableProps) => {
|
}: UsersDataTableProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
const [searchString, setSearchString] = useState('');
|
const [searchString, setSearchString] = useState('');
|
||||||
const debouncedSearchString = useDebouncedValue(searchString, 1000);
|
const debouncedSearchString = useDebouncedValue(searchString, 1000);
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: 'ID',
|
||||||
|
accessorKey: 'id',
|
||||||
|
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Name`),
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: ({ row }) => <div>{row.original.name}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Email`),
|
||||||
|
accessorKey: 'email',
|
||||||
|
cell: ({ row }) => <div>{row.original.email}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Roles`),
|
||||||
|
accessorKey: 'roles',
|
||||||
|
cell: ({ row }) => row.original.roles.join(', '),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Subscription`),
|
||||||
|
accessorKey: 'subscription',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
|
||||||
|
individualPriceIds.includes(sub.priceId),
|
||||||
|
);
|
||||||
|
|
||||||
|
return foundIndividualSubscription?.status ?? 'NONE';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Documents`),
|
||||||
|
accessorKey: 'documents',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return <div>{row.original.Document.length}</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '',
|
||||||
|
accessorKey: 'edit',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<Button className="w-24" asChild>
|
||||||
|
<Link href={`/admin/users/${row.original.id}`}>
|
||||||
|
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof users)[number]>[];
|
||||||
|
}, [individualPriceIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
updateSearchParams({
|
updateSearchParams({
|
||||||
@ -79,65 +141,12 @@ export const UsersDataTable = ({
|
|||||||
<Input
|
<Input
|
||||||
className="my-6 flex flex-row gap-4"
|
className="my-6 flex flex-row gap-4"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name or email"
|
placeholder={_(msg`Search by name or email`)}
|
||||||
value={searchString}
|
value={searchString}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={columns}
|
||||||
{
|
|
||||||
header: 'ID',
|
|
||||||
accessorKey: 'id',
|
|
||||||
cell: ({ row }) => <div>{row.original.id}</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Name',
|
|
||||||
accessorKey: 'name',
|
|
||||||
cell: ({ row }) => <div>{row.original.name}</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Email',
|
|
||||||
accessorKey: 'email',
|
|
||||||
cell: ({ row }) => <div>{row.original.email}</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Roles',
|
|
||||||
accessorKey: 'roles',
|
|
||||||
cell: ({ row }) => row.original.roles.join(', '),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Subscription',
|
|
||||||
accessorKey: 'subscription',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
|
|
||||||
individualPriceIds.includes(sub.priceId),
|
|
||||||
);
|
|
||||||
|
|
||||||
return foundIndividualSubscription?.status ?? 'NONE';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Documents',
|
|
||||||
accessorKey: 'documents',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return <div>{row.original.Document.length}</div>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: '',
|
|
||||||
accessorKey: 'edit',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return (
|
|
||||||
<Button className="w-24" asChild>
|
|
||||||
<Link href={`/admin/users/${row.original.id}`}>
|
|
||||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
data={users}
|
data={users}
|
||||||
perPage={perPage}
|
perPage={perPage}
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
|
||||||
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
|
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||||
|
|
||||||
import { UsersDataTable } from './data-table-users';
|
import { UsersDataTable } from './data-table-users';
|
||||||
@ -13,6 +16,8 @@ type AdminManageUsersProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) {
|
export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const page = Number(searchParams.page) || 1;
|
const page = Number(searchParams.page) || 1;
|
||||||
const perPage = Number(searchParams.perPage) || 10;
|
const perPage = Number(searchParams.perPage) || 10;
|
||||||
const searchString = searchParams.search || '';
|
const searchString = searchParams.search || '';
|
||||||
@ -26,7 +31,10 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-semibold">Manage users</h2>
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<Trans>Manage users</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<UsersDataTable
|
<UsersDataTable
|
||||||
users={users}
|
users={users}
|
||||||
individualPriceIds={individualPriceIds}
|
individualPriceIds={individualPriceIds}
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@ -26,6 +28,7 @@ export type DocumentPageViewButtonProps = {
|
|||||||
export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => {
|
export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
@ -57,8 +60,8 @@ export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButto
|
|||||||
await downloadPDF({ documentData, fileName: documentWithData.title });
|
await downloadPDF({ documentData, fileName: documentWithData.title });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'An error occurred while downloading your document.',
|
description: _(msg`An error occurred while downloading your document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -77,19 +80,19 @@ export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButto
|
|||||||
.with(RecipientRole.SIGNER, () => (
|
.with(RecipientRole.SIGNER, () => (
|
||||||
<>
|
<>
|
||||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Sign
|
<Trans>Sign</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.with(RecipientRole.APPROVER, () => (
|
.with(RecipientRole.APPROVER, () => (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Approve
|
<Trans>Approve</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.otherwise(() => (
|
.otherwise(() => (
|
||||||
<>
|
<>
|
||||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
View
|
<Trans>View</Trans>
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
</Link>
|
</Link>
|
||||||
@ -97,13 +100,15 @@ export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButto
|
|||||||
))
|
))
|
||||||
.with({ isComplete: false }, () => (
|
.with({ isComplete: false }, () => (
|
||||||
<Button className="w-full" asChild>
|
<Button className="w-full" asChild>
|
||||||
<Link href={`${documentsPath}/${document.id}/edit`}>Edit</Link>
|
<Link href={`${documentsPath}/${document.id}/edit`}>
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<Button className="w-full" onClick={onDownloadClick}>
|
<Button className="w-full" onClick={onDownloadClick}>
|
||||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
Download
|
<Trans>Download</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.otherwise(() => null);
|
.otherwise(() => null);
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
@ -47,6 +49,7 @@ export type DocumentPageViewDropdownProps = {
|
|||||||
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
|
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
@ -82,8 +85,8 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
await downloadPDF({ documentData, fileName: document.title });
|
await downloadPDF({ documentData, fileName: document.title });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'An error occurred while downloading your document.',
|
description: _(msg`An error occurred while downloading your document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -98,13 +101,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="end" forceMount>
|
<DropdownMenuContent className="w-52" align="end" forceMount>
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
<Trans>Action</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
{(isOwner || isCurrentTeamDocument) && !isComplete && (
|
{(isOwner || isCurrentTeamDocument) && !isComplete && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`${documentsPath}/${document.id}/edit`}>
|
<Link href={`${documentsPath}/${document.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
@ -112,20 +117,20 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
{isComplete && (
|
{isComplete && (
|
||||||
<DropdownMenuItem onClick={onDownloadClick}>
|
<DropdownMenuItem onClick={onDownloadClick}>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Download
|
<Trans>Download</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`${documentsPath}/${document.id}/logs`}>
|
<Link href={`${documentsPath}/${document.id}/logs`}>
|
||||||
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||||
Audit Log
|
<Trans>Audit Log</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Duplicate
|
<Trans>Duplicate</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@ -133,10 +138,12 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
|
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
<Trans>Delete</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
<Trans>Share</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<ResendDocumentActionItem
|
<ResendDocumentActionItem
|
||||||
document={document}
|
document={document}
|
||||||
@ -151,7 +158,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
||||||
Share Signing Card
|
<Trans>Share Signing Card</Trans>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export type DocumentPageViewInformationProps = {
|
export type DocumentPageViewInformationProps = {
|
||||||
@ -22,47 +23,43 @@ export const DocumentPageViewInformation = ({
|
|||||||
}: DocumentPageViewInformationProps) => {
|
}: DocumentPageViewInformationProps) => {
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
const { locale } = useLocale();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const documentInformation = useMemo(() => {
|
const documentInformation = useMemo(() => {
|
||||||
let createdValue = DateTime.fromJSDate(document.createdAt).toFormat('MMMM d, yyyy');
|
|
||||||
let lastModifiedValue = DateTime.fromJSDate(document.updatedAt).toRelative();
|
|
||||||
|
|
||||||
if (!isMounted) {
|
|
||||||
createdValue = DateTime.fromJSDate(document.createdAt)
|
|
||||||
.setLocale(locale)
|
|
||||||
.toFormat('MMMM d, yyyy');
|
|
||||||
|
|
||||||
lastModifiedValue = DateTime.fromJSDate(document.updatedAt).setLocale(locale).toRelative();
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
description: 'Uploaded by',
|
description: msg`Uploaded by`,
|
||||||
value: userId === document.userId ? 'You' : document.User.name ?? document.User.email,
|
value: userId === document.userId ? _(msg`You`) : document.User.name ?? document.User.email,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Created',
|
description: msg`Created`,
|
||||||
value: createdValue,
|
value: DateTime.fromJSDate(document.createdAt)
|
||||||
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
|
.toFormat('MMMM d, yyyy'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Last modified',
|
description: msg`Last modified`,
|
||||||
value: lastModifiedValue,
|
value: DateTime.fromJSDate(document.updatedAt)
|
||||||
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
|
.toRelative(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [isMounted, document, locale, userId]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isMounted, document, userId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
|
<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">Information</h1>
|
<h1 className="px-4 py-3 font-medium">
|
||||||
|
<Trans>Information</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
<ul className="divide-y border-t">
|
<ul className="divide-y border-t">
|
||||||
{documentInformation.map((item) => (
|
{documentInformation.map((item, i) => (
|
||||||
<li
|
<li
|
||||||
key={item.description}
|
key={i}
|
||||||
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
|
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 className="text-muted-foreground">{_(item.description)}</span>
|
||||||
<span>{item.value}</span>
|
<span>{item.value}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
|
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@ -21,6 +23,8 @@ export const DocumentPageViewRecentActivity = ({
|
|||||||
documentId,
|
documentId,
|
||||||
userId,
|
userId,
|
||||||
}: DocumentPageViewRecentActivityProps) => {
|
}: DocumentPageViewRecentActivityProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
@ -49,7 +53,9 @@ export const DocumentPageViewRecentActivity = ({
|
|||||||
return (
|
return (
|
||||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||||
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
|
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
|
||||||
<h1 className="text-foreground font-medium">Recent activity</h1>
|
<h1 className="text-foreground font-medium">
|
||||||
|
<Trans>Recent activity</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
{/* Can add dropdown menu here for additional options. */}
|
{/* Can add dropdown menu here for additional options. */}
|
||||||
</div>
|
</div>
|
||||||
@ -62,12 +68,14 @@ export const DocumentPageViewRecentActivity = ({
|
|||||||
|
|
||||||
{isLoadingError && (
|
{isLoadingError && (
|
||||||
<div className="flex h-full flex-col items-center justify-center py-16">
|
<div className="flex h-full flex-col items-center justify-center py-16">
|
||||||
<p className="text-foreground/80 text-sm">Unable to load document history</p>
|
<p className="text-foreground/80 text-sm">
|
||||||
|
<Trans>Unable to load document history</Trans>
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={async () => refetch()}
|
onClick={async () => refetch()}
|
||||||
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
||||||
>
|
>
|
||||||
Click here to retry
|
<Trans>Click here to retry</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -89,14 +97,16 @@ export const DocumentPageViewRecentActivity = ({
|
|||||||
onClick={async () => fetchNextPage()}
|
onClick={async () => fetchNextPage()}
|
||||||
className="text-foreground/70 hover:text-muted-foreground text-xs"
|
className="text-foreground/70 hover:text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
{isFetchingNextPage ? 'Loading...' : 'Load older activity'}
|
{isFetchingNextPage ? _(msg`Loading...`) : _(msg`Load older activity`)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{documentAuditLogs.length === 0 && (
|
{documentAuditLogs.length === 0 && (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className="flex items-center justify-center py-4">
|
||||||
<p className="text-muted-foreground/70 text-sm">No recent activity</p>
|
<p className="text-muted-foreground/70 text-sm">
|
||||||
|
<Trans>No recent activity</Trans>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -133,6 +143,7 @@ export const DocumentPageViewRecentActivity = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Todo: Translations. */}
|
||||||
<p
|
<p
|
||||||
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
|
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
|
||||||
title={`${formatDocumentAuditLogAction(auditLog, userId).prefix} ${
|
title={`${formatDocumentAuditLogAction(auditLog, userId).prefix} ${
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
|
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -21,17 +23,21 @@ export const DocumentPageViewRecipients = ({
|
|||||||
document,
|
document,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
}: DocumentPageViewRecipientsProps) => {
|
}: DocumentPageViewRecipientsProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const recipients = document.Recipient;
|
const recipients = document.Recipient;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||||
<h1 className="text-foreground font-medium">Recipients</h1>
|
<h1 className="text-foreground font-medium">
|
||||||
|
<Trans>Recipients</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
{document.status !== DocumentStatus.COMPLETED && (
|
{document.status !== DocumentStatus.COMPLETED && (
|
||||||
<Link
|
<Link
|
||||||
href={`${documentRootPath}/${document.id}/edit?step=signers`}
|
href={`${documentRootPath}/${document.id}/edit?step=signers`}
|
||||||
title="Modify recipients"
|
title={_(msg`Modify recipients`)}
|
||||||
className="flex flex-row items-center justify-between"
|
className="flex flex-row items-center justify-between"
|
||||||
>
|
>
|
||||||
{recipients.length === 0 ? (
|
{recipients.length === 0 ? (
|
||||||
@ -45,7 +51,9 @@ export const DocumentPageViewRecipients = ({
|
|||||||
|
|
||||||
<ul className="text-muted-foreground divide-y border-t">
|
<ul className="text-muted-foreground divide-y border-t">
|
||||||
{recipients.length === 0 && (
|
{recipients.length === 0 && (
|
||||||
<li className="flex flex-col items-center justify-center py-6 text-sm">No recipients</li>
|
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||||
|
<Trans>No recipients</Trans>
|
||||||
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipients.map((recipient) => (
|
{recipients.map((recipient) => (
|
||||||
@ -55,7 +63,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||||
secondaryText={
|
secondaryText={
|
||||||
<p className="text-muted-foreground/70 text-xs">
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -67,19 +75,19 @@ export const DocumentPageViewRecipients = ({
|
|||||||
.with(RecipientRole.APPROVER, () => (
|
.with(RecipientRole.APPROVER, () => (
|
||||||
<>
|
<>
|
||||||
<CheckIcon className="mr-1 h-3 w-3" />
|
<CheckIcon className="mr-1 h-3 w-3" />
|
||||||
Approved
|
<Trans>Approved</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.with(RecipientRole.CC, () =>
|
.with(RecipientRole.CC, () =>
|
||||||
document.status === DocumentStatus.COMPLETED ? (
|
document.status === DocumentStatus.COMPLETED ? (
|
||||||
<>
|
<>
|
||||||
<MailIcon className="mr-1 h-3 w-3" />
|
<MailIcon className="mr-1 h-3 w-3" />
|
||||||
Sent
|
<Trans>Sent</Trans>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CheckIcon className="mr-1 h-3 w-3" />
|
<CheckIcon className="mr-1 h-3 w-3" />
|
||||||
Ready
|
<Trans>Ready</Trans>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -87,13 +95,13 @@ export const DocumentPageViewRecipients = ({
|
|||||||
.with(RecipientRole.SIGNER, () => (
|
.with(RecipientRole.SIGNER, () => (
|
||||||
<>
|
<>
|
||||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||||
Signed
|
<Trans>Signed</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.with(RecipientRole.VIEWER, () => (
|
.with(RecipientRole.VIEWER, () => (
|
||||||
<>
|
<>
|
||||||
<MailOpenIcon className="mr-1 h-3 w-3" />
|
<MailOpenIcon className="mr-1 h-3 w-3" />
|
||||||
Viewed
|
<Trans>Viewed</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
@ -104,7 +112,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
Pending
|
<Trans>Pending</Trans>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Plural, Trans } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -42,6 +44,7 @@ export type DocumentPageViewProps = {
|
|||||||
|
|
||||||
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const documentId = Number(id);
|
const documentId = Number(id);
|
||||||
|
|
||||||
@ -107,7 +110,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Documents
|
<Trans>Documents</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-row justify-between truncate">
|
<div className="flex flex-row justify-between truncate">
|
||||||
@ -132,12 +135,18 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
documentStatus={document.status}
|
documentStatus={document.status}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
>
|
>
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<span>
|
||||||
|
<Trans>{recipients.length} Recipient(s)</Trans>
|
||||||
|
</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{document.deletedAt && <Badge variant="destructive">Document deleted</Badge>}
|
{document.deletedAt && (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
<Trans>Document deleted</Trans>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -146,7 +155,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
<DocumentHistorySheet documentId={document.id} userId={user.id}>
|
<DocumentHistorySheet documentId={document.id} userId={user.id}>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
<Clock9 className="mr-1.5 h-4 w-4" />
|
<Clock9 className="mr-1.5 h-4 w-4" />
|
||||||
Document history
|
<Trans>Document history</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DocumentHistorySheet>
|
</DocumentHistorySheet>
|
||||||
</div>
|
</div>
|
||||||
@ -172,7 +181,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-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">
|
<div className="flex flex-row items-center justify-between px-4">
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
|
{_(FRIENDLY_STATUS_MAP[document.status].labelExtended)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
|
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
|
||||||
@ -180,22 +189,24 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
|
|
||||||
<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)
|
{match(document.status)
|
||||||
.with(
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
DocumentStatus.COMPLETED,
|
<Trans>This document has been signed by all recipients</Trans>
|
||||||
() => 'This document has been signed by all recipients',
|
))
|
||||||
)
|
.with(DocumentStatus.DRAFT, () => (
|
||||||
.with(
|
<Trans>This document is currently a draft and has not been sent</Trans>
|
||||||
DocumentStatus.DRAFT,
|
))
|
||||||
() => 'This document is currently a draft and has not been sent',
|
|
||||||
)
|
|
||||||
.with(DocumentStatus.PENDING, () => {
|
.with(DocumentStatus.PENDING, () => {
|
||||||
const pendingRecipients = recipients.filter(
|
const pendingRecipients = recipients.filter(
|
||||||
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
||||||
);
|
);
|
||||||
|
|
||||||
return `Waiting on ${pendingRecipients.length} recipient${
|
return (
|
||||||
pendingRecipients.length > 1 ? 's' : ''
|
<Plural
|
||||||
}`;
|
value={pendingRecipients.length}
|
||||||
|
one="Waiting on 1 recipient"
|
||||||
|
other="Waiting on # recipients"
|
||||||
|
/>
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
SKIP_QUERY_BATCH_META,
|
SKIP_QUERY_BATCH_META,
|
||||||
@ -45,6 +48,7 @@ export const EditDocumentForm = ({
|
|||||||
isDocumentEnterprise,
|
isDocumentEnterprise,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@ -125,23 +129,23 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
settings: {
|
settings: {
|
||||||
title: 'General',
|
title: msg`General`,
|
||||||
description: 'Configure general settings for the document.',
|
description: msg`Configure general settings for the document.`,
|
||||||
stepIndex: 1,
|
stepIndex: 1,
|
||||||
},
|
},
|
||||||
signers: {
|
signers: {
|
||||||
title: 'Add Signers',
|
title: msg`Add Signers`,
|
||||||
description: 'Add the people who will sign the document.',
|
description: msg`Add the people who will sign the document.`,
|
||||||
stepIndex: 2,
|
stepIndex: 2,
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Add Fields',
|
title: msg`Add Fields`,
|
||||||
description: 'Add all relevant fields for each recipient.',
|
description: msg`Add all relevant fields for each recipient.`,
|
||||||
stepIndex: 3,
|
stepIndex: 3,
|
||||||
},
|
},
|
||||||
subject: {
|
subject: {
|
||||||
title: 'Add Subject',
|
title: msg`Add Subject`,
|
||||||
description: 'Add the subject and message you wish to send to signers.',
|
description: msg`Add the subject and message you wish to send to signers.`,
|
||||||
stepIndex: 4,
|
stepIndex: 4,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -191,8 +195,8 @@ export const EditDocumentForm = ({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while updating the document settings.',
|
description: _(msg`An error occurred while updating the document settings.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -218,8 +222,8 @@ export const EditDocumentForm = ({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while adding signers.',
|
description: _(msg`An error occurred while adding signers.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -248,8 +252,8 @@ export const EditDocumentForm = ({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while adding the fields.',
|
description: _(msg`An error occurred while adding the fields.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -269,8 +273,8 @@ export const EditDocumentForm = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document sent',
|
title: _(msg`Document sent`),
|
||||||
description: 'Your document has been sent successfully.',
|
description: _(msg`Your document has been sent successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -279,8 +283,8 @@ export const EditDocumentForm = ({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while sending the document.',
|
description: _(msg`An error occurred while sending the document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Plural, Trans } from '@lingui/macro';
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
|
|
||||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
@ -78,7 +79,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Documents
|
<Trans>Documents</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
@ -97,7 +98,9 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
documentStatus={document.status}
|
documentStatus={document.status}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
>
|
>
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<span>
|
||||||
|
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
|
||||||
|
</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { DocumentEditPageView } from './document-edit-page-view';
|
import { DocumentEditPageView } from './document-edit-page-view';
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
export type DocumentPageProps = {
|
||||||
@ -7,5 +9,7 @@ export type DocumentPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function DocumentEditPage({ params }: DocumentPageProps) {
|
export default function DocumentEditPage({ params }: DocumentPageProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return <DocumentEditPageView params={params} />;
|
return <DocumentEditPageView params={params} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,23 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { ChevronLeft, Loader } from 'lucide-react';
|
import { ChevronLeft, Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||||
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Documents
|
<Trans>Documents</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
Loading Document...
|
<Trans>Loading Document...</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex h-10 items-center">
|
<div className="flex h-10 items-center">
|
||||||
@ -25,7 +29,9 @@ export default function Loading() {
|
|||||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
||||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
<p className="text-muted-foreground mt-4">
|
||||||
|
<Trans>Loading document...</Trans>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import type { DateTimeFormatOptions } from 'luxon';
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
@ -10,13 +14,12 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
|
|||||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
export type DocumentLogsDataTableProps = {
|
export type DocumentLogsDataTableProps = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
};
|
};
|
||||||
@ -27,7 +30,7 @@ const dateFormat: DateTimeFormatOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => {
|
export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => {
|
||||||
const parser = new UAParser();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
@ -66,16 +69,17 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
|
|||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const columns = useMemo(() => {
|
||||||
<DataTable
|
const parser = new UAParser();
|
||||||
columns={[
|
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
header: 'Time',
|
header: _(msg`Time`),
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
|
cell: ({ row }) => i18n.date(row.original.createdAt, dateFormat),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'User',
|
header: _(msg`User`),
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.name || row.original.email ? (
|
row.original.name || row.original.email ? (
|
||||||
@ -97,12 +101,10 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: _(msg`Action`),
|
||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span>
|
<span>{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}</span>
|
||||||
{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -123,7 +125,12 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
|
|||||||
return result.browser.name ?? 'N/A';
|
return result.browser.name ?? 'N/A';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
data={results.data}
|
data={results.data}
|
||||||
perPage={results.perPage}
|
perPage={results.perPage}
|
||||||
currentPage={results.currentPage}
|
currentPage={results.currentPage}
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import type { Recipient, Team } from '@documenso/prisma/client';
|
import type { Recipient, Team } from '@documenso/prisma/client';
|
||||||
@ -29,7 +31,7 @@ export type DocumentLogsPageViewProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
|
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
|
||||||
const locale = getLocale();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
|
||||||
@ -60,39 +62,39 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
|||||||
redirect(documentRootPath);
|
redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentInformation: { description: string; value: string }[] = [
|
const documentInformation: { description: MessageDescriptor; value: string }[] = [
|
||||||
{
|
{
|
||||||
description: 'Document title',
|
description: msg`Document title`,
|
||||||
value: document.title,
|
value: document.title,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Document ID',
|
description: msg`Document ID`,
|
||||||
value: document.id.toString(),
|
value: document.id.toString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Document status',
|
description: msg`Document status`,
|
||||||
value: FRIENDLY_STATUS_MAP[document.status].label,
|
value: _(FRIENDLY_STATUS_MAP[document.status].label),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Created by',
|
description: msg`Created by`,
|
||||||
value: document.User.name
|
value: document.User.name
|
||||||
? `${document.User.name} (${document.User.email})`
|
? `${document.User.name} (${document.User.email})`
|
||||||
: document.User.email,
|
: document.User.email,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Date created',
|
description: msg`Date created`,
|
||||||
value: DateTime.fromJSDate(document.createdAt)
|
value: DateTime.fromJSDate(document.createdAt)
|
||||||
.setLocale(locale)
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Last updated',
|
description: msg`Last updated`,
|
||||||
value: DateTime.fromJSDate(document.updatedAt)
|
value: DateTime.fromJSDate(document.updatedAt)
|
||||||
.setLocale(locale)
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Time zone',
|
description: msg`Time zone`,
|
||||||
value: document.documentMeta?.timezone ?? 'N/A',
|
value: document.documentMeta?.timezone ?? 'N/A',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -114,7 +116,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
|||||||
className="flex items-center text-[#7AC455] hover:opacity-80"
|
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Document
|
<Trans>Document</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-col justify-between truncate sm:flex-row">
|
<div className="flex flex-col justify-between truncate sm:flex-row">
|
||||||
@ -147,7 +149,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
|||||||
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
||||||
{documentInformation.map((info, i) => (
|
{documentInformation.map((info, i) => (
|
||||||
<div className="text-foreground text-sm" key={i}>
|
<div className="text-foreground text-sm" key={i}>
|
||||||
<h3 className="font-semibold">{info.description}</h3>
|
<h3 className="font-semibold">{_(info.description)}</h3>
|
||||||
<p className="text-muted-foreground">{info.value}</p>
|
<p className="text-muted-foreground">{info.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { DownloadIcon } from 'lucide-react';
|
import { DownloadIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -19,6 +21,7 @@ export const DownloadAuditLogButton = ({
|
|||||||
documentId,
|
documentId,
|
||||||
}: DownloadAuditLogButtonProps) => {
|
}: DownloadAuditLogButtonProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { mutateAsync: downloadAuditLogs, isLoading } =
|
const { mutateAsync: downloadAuditLogs, isLoading } =
|
||||||
trpc.document.downloadAuditLogs.useMutation();
|
trpc.document.downloadAuditLogs.useMutation();
|
||||||
@ -59,8 +62,10 @@ export const DownloadAuditLogButton = ({
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'Sorry, we were unable to download the audit logs. Please try again later.',
|
description: _(
|
||||||
|
msg`Sorry, we were unable to download the audit logs. Please try again later.`,
|
||||||
|
),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -73,7 +78,7 @@ export const DownloadAuditLogButton = ({
|
|||||||
onClick={() => void onDownloadAuditLogsClick()}
|
onClick={() => void onDownloadAuditLogsClick()}
|
||||||
>
|
>
|
||||||
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||||
Download Audit Logs
|
<Trans>Download Audit Logs</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { DownloadIcon } from 'lucide-react';
|
import { DownloadIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
@ -20,6 +22,7 @@ export const DownloadCertificateButton = ({
|
|||||||
documentStatus,
|
documentStatus,
|
||||||
}: DownloadCertificateButtonProps) => {
|
}: DownloadCertificateButtonProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { mutateAsync: downloadCertificate, isLoading } =
|
const { mutateAsync: downloadCertificate, isLoading } =
|
||||||
trpc.document.downloadCertificate.useMutation();
|
trpc.document.downloadCertificate.useMutation();
|
||||||
@ -60,8 +63,10 @@ export const DownloadCertificateButton = ({
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'Sorry, we were unable to download the certificate. Please try again later.',
|
description: _(
|
||||||
|
msg`Sorry, we were unable to download the certificate. Please try again later.`,
|
||||||
|
),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -76,7 +81,7 @@ export const DownloadCertificateButton = ({
|
|||||||
onClick={() => void onDownloadCertificatesClick()}
|
onClick={() => void onDownloadCertificatesClick()}
|
||||||
>
|
>
|
||||||
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||||
Download Certificate
|
<Trans>Download Certificate</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { DocumentLogsPageView } from './document-logs-page-view';
|
import { DocumentLogsPageView } from './document-logs-page-view';
|
||||||
|
|
||||||
export type DocumentsLogsPageProps = {
|
export type DocumentsLogsPageProps = {
|
||||||
@ -7,5 +9,7 @@ export type DocumentsLogsPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
|
export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return <DocumentLogsPageView params={params} />;
|
return <DocumentLogsPageView params={params} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { DocumentPageView } from './document-page-view';
|
import { DocumentPageView } from './document-page-view';
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
export type DocumentPageProps = {
|
||||||
@ -7,5 +9,7 @@ export type DocumentPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function DocumentPage({ params }: DocumentPageProps) {
|
export default function DocumentPage({ params }: DocumentPageProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return <DocumentPageView params={params} />;
|
return <DocumentPageView params={params} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
export default function DocumentSentPage() {
|
export default function DocumentSentPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||||
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Documents
|
<Trans>Documents</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
Loading Document...
|
<Trans>Loading Document...</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { History } from 'lucide-react';
|
import { History } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@ -62,6 +64,7 @@ export const ResendDocumentActionItem = ({
|
|||||||
}: ResendDocumentActionItemProps) => {
|
}: ResendDocumentActionItemProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const isOwner = document.userId === session?.user?.id;
|
const isOwner = document.userId === session?.user?.id;
|
||||||
@ -91,16 +94,16 @@ export const ResendDocumentActionItem = ({
|
|||||||
await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
|
await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document re-sent',
|
title: _(msg`Document re-sent`),
|
||||||
description: 'Your document has been re-sent successfully.',
|
description: _(msg`Your document has been re-sent successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'This document could not be re-sent at this time. Please try again.',
|
description: _(msg`This document could not be re-sent at this time. Please try again.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
@ -112,14 +115,16 @@ export const ResendDocumentActionItem = ({
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
|
||||||
<History className="mr-2 h-4 w-4" />
|
<History className="mr-2 h-4 w-4" />
|
||||||
Resend
|
<Trans>Resend</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-sm" hideClose>
|
<DialogContent className="sm:max-w-sm" hideClose>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle asChild>
|
<DialogTitle asChild>
|
||||||
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
<h1 className="text-center text-xl">
|
||||||
|
<Trans>Who do you want to remind?</Trans>
|
||||||
|
</h1>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@ -178,12 +183,12 @@ export const ResendDocumentActionItem = ({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
|
|
||||||
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||||
Send reminder
|
<Trans>Send reminder</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@ -27,6 +29,7 @@ export type DataTableActionButtonProps = {
|
|||||||
export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
|
export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
@ -69,8 +72,8 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
|
|||||||
await downloadPDF({ documentData, fileName: row.title });
|
await downloadPDF({ documentData, fileName: row.title });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'An error occurred while downloading your document.',
|
description: _(msg`An error occurred while downloading your document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -96,7 +99,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
|
|||||||
<Button className="w-32" asChild>
|
<Button className="w-32" asChild>
|
||||||
<Link href={`${documentsPath}/${row.id}/edit`}>
|
<Link href={`${documentsPath}/${row.id}/edit`}>
|
||||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Edit
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
@ -108,19 +111,19 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
|
|||||||
.with(RecipientRole.SIGNER, () => (
|
.with(RecipientRole.SIGNER, () => (
|
||||||
<>
|
<>
|
||||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Sign
|
<Trans>Sign</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.with(RecipientRole.APPROVER, () => (
|
.with(RecipientRole.APPROVER, () => (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Approve
|
<Trans>Approve</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.otherwise(() => (
|
.otherwise(() => (
|
||||||
<>
|
<>
|
||||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
View
|
<Trans>View</Trans>
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
</Link>
|
</Link>
|
||||||
@ -129,13 +132,13 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
|
|||||||
.with({ isPending: true, isSigned: true }, () => (
|
.with({ isPending: true, isSigned: true }, () => (
|
||||||
<Button className="w-32" disabled={true}>
|
<Button className="w-32" disabled={true}>
|
||||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
View
|
<Trans>View</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<Button className="w-32" onClick={onDownloadClick}>
|
<Button className="w-32" onClick={onDownloadClick}>
|
||||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
Download
|
<Trans>Download</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.otherwise(() => <div></div>);
|
.otherwise(() => <div></div>);
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Copy,
|
Copy,
|
||||||
@ -52,6 +54,7 @@ export type DataTableActionDropdownProps = {
|
|||||||
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
@ -98,8 +101,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
await downloadPDF({ documentData, fileName: row.title });
|
await downloadPDF({ documentData, fileName: row.title });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'An error occurred while downloading your document.',
|
description: _(msg`An error occurred while downloading your document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -114,7 +117,9 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
<Trans>Action</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
|
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
|
||||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||||
@ -122,21 +127,21 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
{recipient?.role === RecipientRole.VIEWER && (
|
{recipient?.role === RecipientRole.VIEWER && (
|
||||||
<>
|
<>
|
||||||
<EyeIcon className="mr-2 h-4 w-4" />
|
<EyeIcon className="mr-2 h-4 w-4" />
|
||||||
View
|
<Trans>View</Trans>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipient?.role === RecipientRole.SIGNER && (
|
{recipient?.role === RecipientRole.SIGNER && (
|
||||||
<>
|
<>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
Sign
|
<Trans>Sign</Trans>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipient?.role === RecipientRole.APPROVER && (
|
{recipient?.role === RecipientRole.APPROVER && (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="mr-2 h-4 w-4" />
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
Approve
|
<Trans>Approve</Trans>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
@ -146,25 +151,25 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
||||||
<Link href={`${documentsPath}/${row.id}/edit`}>
|
<Link href={`${documentsPath}/${row.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
|
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Download
|
<Trans>Download</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Duplicate
|
<Trans>Duplicate</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{/* We don't want to allow teams moving documents across at the moment. */}
|
{/* We don't want to allow teams moving documents across at the moment. */}
|
||||||
{!team && (
|
{!team && (
|
||||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
||||||
<MoveRight className="mr-2 h-4 w-4" />
|
<MoveRight className="mr-2 h-4 w-4" />
|
||||||
Move to Team
|
<Trans>Move to Team</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -179,10 +184,12 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
disabled={Boolean(!canManageDocument && team?.teamEmail)}
|
disabled={Boolean(!canManageDocument && team?.teamEmail)}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{canManageDocument ? 'Delete' : 'Hide'}
|
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
<Trans>Share</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
|
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
|
||||||
|
|
||||||
@ -193,7 +200,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
||||||
Share Signing Card
|
<Trans>Share Signing Card</Trans>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -12,6 +15,8 @@ type DataTableSenderFilterProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
|
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -49,11 +54,13 @@ export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) =>
|
|||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
emptySelectionPlaceholder={
|
emptySelectionPlaceholder={
|
||||||
<p className="text-muted-foreground font-normal">
|
<p className="text-muted-foreground font-normal">
|
||||||
|
<Trans>
|
||||||
<span className="text-muted-foreground/70">Sender:</span> All
|
<span className="text-muted-foreground/70">Sender:</span> All
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
enableClearAllButton={true}
|
enableClearAllButton={true}
|
||||||
inputPlaceholder="Search"
|
inputPlaceholder={msg`Search`}
|
||||||
loading={!isMounted || isInitialLoading}
|
loading={!isMounted || isInitialLoading}
|
||||||
options={comboBoxOptions}
|
options={comboBoxOptions}
|
||||||
selectedValues={senderIds}
|
selectedValues={senderIds}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTransition } from 'react';
|
import { useMemo, useTransition } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
@ -10,12 +12,12 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
|
|||||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
|
||||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
import { DataTableActionButton } from './data-table-action-button';
|
import { DataTableActionButton } from './data-table-action-button';
|
||||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||||
@ -38,11 +40,60 @@ export const DocumentsDataTable = ({
|
|||||||
showSenderColumn,
|
showSenderColumn,
|
||||||
team,
|
team,
|
||||||
}: DocumentsDataTableProps) => {
|
}: DocumentsDataTableProps) => {
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
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} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sender',
|
||||||
|
header: _(msg`Sender`),
|
||||||
|
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: _(msg`Actions`),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
(!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<DataTableActionButton team={team} row={row.original} />
|
||||||
|
<DataTableActionDropdown team={team} row={row.original} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, [team]);
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
updateSearchParams({
|
updateSearchParams({
|
||||||
@ -59,54 +110,7 @@ export const DocumentsDataTable = ({
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={columns}
|
||||||
{
|
|
||||||
header: 'Created',
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<LocaleDate
|
|
||||||
date={row.original.createdAt}
|
|
||||||
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Title',
|
|
||||||
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sender',
|
|
||||||
header: 'Sender',
|
|
||||||
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Recipient',
|
|
||||||
accessorKey: 'recipient',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<StackAvatarsWithTooltip
|
|
||||||
recipients={row.original.Recipient}
|
|
||||||
documentStatus={row.original.status}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Status',
|
|
||||||
accessorKey: 'status',
|
|
||||||
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
|
||||||
size: 140,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Actions',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
(!row.original.deletedAt ||
|
|
||||||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
|
||||||
<div className="flex items-center gap-x-4">
|
|
||||||
<DataTableActionButton team={team} row={row.original} />
|
|
||||||
<DataTableActionDropdown team={team} row={row.original} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
data={results.data}
|
data={results.data}
|
||||||
perPage={results.perPage}
|
perPage={results.perPage}
|
||||||
currentPage={results.currentPage}
|
currentPage={results.currentPage}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
@ -43,6 +45,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { refreshLimits } = useLimits();
|
const { refreshLimits } = useLimits();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||||
@ -53,8 +56,8 @@ export const DeleteDocumentDialog = ({
|
|||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document deleted',
|
title: _(msg`Document deleted`),
|
||||||
description: `"${documentTitle}" has been successfully deleted`,
|
description: _(msg`"${documentTitle}" has been successfully deleted`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -74,8 +77,8 @@ export const DeleteDocumentDialog = ({
|
|||||||
await deleteDocument({ id, teamId });
|
await deleteDocument({ id, teamId });
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'This document could not be deleted at this time. Please try again.',
|
description: _(msg`This document could not be deleted at this time. Please try again.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
@ -91,11 +94,20 @@ export const DeleteDocumentDialog = ({
|
|||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Are you sure?</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Are you sure?</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
You are about to {canManageDocument ? 'delete' : 'hide'}{' '}
|
{canManageDocument ? (
|
||||||
<strong>"{documentTitle}"</strong>
|
<Trans>
|
||||||
|
You are about to delete <strong>"{documentTitle}"</strong>
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
You are about to hide <strong>"{documentTitle}"</strong>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@ -104,33 +116,53 @@ export const DeleteDocumentDialog = ({
|
|||||||
{match(status)
|
{match(status)
|
||||||
.with(DocumentStatus.DRAFT, () => (
|
.with(DocumentStatus.DRAFT, () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
|
<Trans>
|
||||||
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||||
this document will be permanently deleted.
|
this document will be permanently deleted.
|
||||||
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
.with(DocumentStatus.PENDING, () => (
|
.with(DocumentStatus.PENDING, () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>
|
<p>
|
||||||
|
<Trans>
|
||||||
Please note that this action is <strong>irreversible</strong>.
|
Please note that this action is <strong>irreversible</strong>.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-1">Once confirmed, the following will occur:</p>
|
<p className="mt-1">
|
||||||
|
<Trans>Once confirmed, the following will occur:</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
<ul className="mt-0.5 list-inside list-disc">
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
<li>Document will be permanently deleted</li>
|
<li>
|
||||||
<li>Document signing process will be cancelled</li>
|
<Trans>Document will be permanently deleted</Trans>
|
||||||
<li>All inserted signatures will be voided</li>
|
</li>
|
||||||
<li>All recipients will be notified</li>
|
<li>
|
||||||
|
<Trans>Document signing process will be cancelled</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>All inserted signatures will be voided</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>All recipients will be notified</Trans>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
.with(DocumentStatus.COMPLETED, () => (
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>By deleting this document, the following will occur:</p>
|
<p>
|
||||||
|
<Trans>By deleting this document, the following will occur:</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
<ul className="mt-0.5 list-inside list-disc">
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
<li>The document will be hidden from your account</li>
|
<li>
|
||||||
<li>Recipients will still retain their copy of the document</li>
|
<Trans>The document will be hidden from your account</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Recipients will still retain their copy of the document</Trans>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
@ -139,7 +171,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
) : (
|
) : (
|
||||||
<Alert variant="warning" className="-mt-1">
|
<Alert variant="warning" className="-mt-1">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Please contact support if you would like to revert this action.
|
<Trans>Please contact support if you would like to revert this action.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@ -149,13 +181,13 @@ export const DeleteDocumentDialog = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={onInputChange}
|
onChange={onInputChange}
|
||||||
placeholder="Type 'delete' to confirm"
|
placeholder={_(msg`Type 'delete' to confirm`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -165,7 +197,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
disabled={!isDeleteEnabled && canManageDocument}
|
disabled={!isDeleteEnabled && canManageDocument}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
>
|
>
|
||||||
{canManageDocument ? 'Delete' : 'Hide'}
|
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
@ -104,7 +106,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h1 className="text-4xl font-semibold">Documents</h1>
|
<h1 className="text-4xl font-semibold">
|
||||||
|
<Trans>Documents</Trans>
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import type { Team } from '@documenso/prisma/client';
|
import type { Team } from '@documenso/prisma/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
@ -28,7 +31,9 @@ export const DuplicateDocumentDialog = ({
|
|||||||
team,
|
team,
|
||||||
}: DuplicateDocumentDialogProps) => {
|
}: DuplicateDocumentDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
||||||
id,
|
id,
|
||||||
@ -50,8 +55,8 @@ export const DuplicateDocumentDialog = ({
|
|||||||
router.push(`${documentsPath}/${newId}/edit`);
|
router.push(`${documentsPath}/${newId}/edit`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document Duplicated',
|
title: _(msg`Document Duplicated`),
|
||||||
description: 'Your document has been successfully duplicated.',
|
description: _(msg`Your document has been successfully duplicated.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -64,8 +69,8 @@ export const DuplicateDocumentDialog = ({
|
|||||||
await duplicateDocument({ id, teamId: team?.id });
|
await duplicateDocument({ id, teamId: team?.id });
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'This document could not be duplicated at this time. Please try again.',
|
description: _(msg`This document could not be duplicated at this time. Please try again.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
@ -76,12 +81,14 @@ export const DuplicateDocumentDialog = ({
|
|||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Duplicate</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Duplicate</Trans>
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{!documentData || isLoading ? (
|
{!documentData || isLoading ? (
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
Loading Document...
|
<Trans>Loading Document...</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -98,7 +105,7 @@ export const DuplicateDocumentDialog = ({
|
|||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -108,7 +115,7 @@ export const DuplicateDocumentDialog = ({
|
|||||||
onClick={onDuplicate}
|
onClick={onDuplicate}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
Duplicate
|
<Trans>Duplicate</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Bird, CheckCircle2 } from 'lucide-react';
|
import { Bird, CheckCircle2 } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -6,33 +8,31 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
|
|||||||
export type EmptyDocumentProps = { status: ExtendedDocumentStatus };
|
export type EmptyDocumentProps = { status: ExtendedDocumentStatus };
|
||||||
|
|
||||||
export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
} = match(status)
|
} = match(status)
|
||||||
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
||||||
title: 'Nothing to do',
|
title: msg`Nothing to do`,
|
||||||
message:
|
message: msg`There are no completed documents yet. Documents that you have created or received will appear here once completed.`,
|
||||||
'There are no completed documents yet. Documents that you have created or received will appear here once completed.',
|
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
}))
|
}))
|
||||||
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||||
title: 'No active drafts',
|
title: msg`No active drafts`,
|
||||||
message:
|
message: msg`There are no active drafts at the current moment. You can upload a document to start drafting.`,
|
||||||
'There are no active drafts at the current moment. You can upload a document to start drafting.',
|
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
}))
|
}))
|
||||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||||
title: "We're all empty",
|
title: msg`We're all empty`,
|
||||||
message:
|
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
|
||||||
'You have not yet created or received any documents. To create a document please upload one.',
|
|
||||||
icon: Bird,
|
icon: Bird,
|
||||||
}))
|
}))
|
||||||
.otherwise(() => ({
|
.otherwise(() => ({
|
||||||
title: 'Nothing to do',
|
title: msg`Nothing to do`,
|
||||||
message:
|
message: msg`All documents have been processed. Any new documents that are sent or received will show here.`,
|
||||||
'All documents have been processed. Any new documents that are sent or received will show here.',
|
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -44,9 +44,9 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
|||||||
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold">{title}</h3>
|
<h3 className="text-lg font-semibold">{_(title)}</h3>
|
||||||
|
|
||||||
<p className="mt-2 max-w-[60ch]">{message}</p>
|
<p className="mt-2 max-w-[60ch]">{_(message)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
@ -30,25 +33,29 @@ type MoveDocumentDialogProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocumentDialogProps) => {
|
export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocumentDialogProps) => {
|
||||||
const router = useRouter();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||||
|
|
||||||
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||||
|
|
||||||
const { mutateAsync: moveDocument, isLoading } = trpc.document.moveDocumentToTeam.useMutation({
|
const { mutateAsync: moveDocument, isLoading } = trpc.document.moveDocumentToTeam.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
toast({
|
toast({
|
||||||
title: 'Document moved',
|
title: _(msg`Document moved`),
|
||||||
description: 'The document has been successfully moved to the selected team.',
|
description: _(msg`The document has been successfully moved to the selected team.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: error.message || 'An error occurred while moving the document.',
|
description: error.message || _(msg`An error occurred while moving the document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
@ -56,7 +63,10 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onMove = async () => {
|
const onMove = async () => {
|
||||||
if (!selectedTeamId) return;
|
if (!selectedTeamId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await moveDocument({ documentId, teamId: selectedTeamId });
|
await moveDocument({ documentId, teamId: selectedTeamId });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -64,20 +74,22 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Move Document to Team</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Move Document to Team</Trans>
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Select a team to move this document to. This action cannot be undone.
|
<Trans>Select a team to move this document to. This action cannot be undone.</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a team" />
|
<SelectValue placeholder={_(msg`Select a team`)} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{isLoadingTeams ? (
|
{isLoadingTeams ? (
|
||||||
<SelectItem value="loading" disabled>
|
<SelectItem value="loading" disabled>
|
||||||
Loading teams...
|
<Trans>Loading teams...</Trans>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
) : (
|
) : (
|
||||||
teams?.map((team) => (
|
teams?.map((team) => (
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
import type { DocumentsPageViewProps } from './documents-page-view';
|
import type { DocumentsPageViewProps } from './documents-page-view';
|
||||||
@ -15,7 +16,10 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UpcomingProfileClaimTeaser user={user} />
|
<UpcomingProfileClaimTeaser user={user} />
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@ -12,6 +15,7 @@ export type UpcomingProfileClaimTeaserProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const UpcomingProfileClaimTeaser = ({ user }: UpcomingProfileClaimTeaserProps) => {
|
export const UpcomingProfileClaimTeaser = ({ user }: UpcomingProfileClaimTeaserProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -21,14 +25,17 @@ export const UpcomingProfileClaimTeaser = ({ user }: UpcomingProfileClaimTeaserP
|
|||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
if (!open && !claimed) {
|
if (!open && !claimed) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Claim your profile later',
|
title: _(msg`Claim your profile later`),
|
||||||
description: 'You can claim your profile later on by going to your profile settings!',
|
description: _(
|
||||||
|
msg`You can claim your profile later on by going to your profile settings!`,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpen(open);
|
setOpen(open);
|
||||||
localStorage.setItem('app.hasShownProfileClaimDialog', 'true');
|
localStorage.setItem('app.hasShownProfileClaimDialog', 'true');
|
||||||
},
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[claimed, toast],
|
[claimed, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
@ -34,6 +36,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { quota, remaining, refreshLimits } = useLimits();
|
const { quota, remaining, refreshLimits } = useLimits();
|
||||||
@ -45,13 +48,14 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
const disabledMessage = useMemo(() => {
|
const disabledMessage = useMemo(() => {
|
||||||
if (remaining.documents === 0) {
|
if (remaining.documents === 0) {
|
||||||
return team
|
return team
|
||||||
? 'Document upload disabled due to unpaid invoices'
|
? msg`Document upload disabled due to unpaid invoices`
|
||||||
: 'You have reached your document limit.';
|
: msg`You have reached your document limit.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session?.user.emailVerified) {
|
if (!session?.user.emailVerified) {
|
||||||
return 'Verify your email to upload documents.';
|
return msg`Verify your email to upload documents.`;
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [remaining.documents, session?.user.emailVerified, team]);
|
}, [remaining.documents, session?.user.emailVerified, team]);
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
@ -74,8 +78,8 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document uploaded',
|
title: _(msg`Document uploaded`),
|
||||||
description: 'Your document has been uploaded successfully.',
|
description: _(msg`Your document has been uploaded successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -93,20 +97,20 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
if (error.code === 'INVALID_DOCUMENT_FILE') {
|
if (error.code === 'INVALID_DOCUMENT_FILE') {
|
||||||
toast({
|
toast({
|
||||||
title: 'Invalid file',
|
title: _(msg`Invalid file`),
|
||||||
description: 'You cannot upload encrypted PDFs',
|
description: _(msg`You cannot upload encrypted PDFs`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else if (err instanceof TRPCClientError) {
|
} else if (err instanceof TRPCClientError) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: err.message,
|
description: err.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while uploading your document.',
|
description: _(msg`An error occurred while uploading your document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -117,8 +121,8 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
const onFileDropRejected = () => {
|
const onFileDropRejected = () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Your document failed to upload.',
|
title: _(msg`Your document failed to upload.`),
|
||||||
description: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@ -139,7 +143,9 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
remaining.documents > 0 &&
|
remaining.documents > 0 &&
|
||||||
Number.isFinite(remaining.documents) && (
|
Number.isFinite(remaining.documents) && (
|
||||||
<p className="text-muted-foreground/60 text-xs">
|
<p className="text-muted-foreground/60 text-xs">
|
||||||
|
<Trans>
|
||||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
|
|||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
|
|
||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||||
@ -22,6 +23,8 @@ export type AuthenticatedDashboardLayoutProps = {
|
|||||||
export default async function AuthenticatedDashboardLayout({
|
export default async function AuthenticatedDashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: AuthenticatedDashboardLayoutProps) {
|
}: AuthenticatedDashboardLayoutProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const session = await getServerSession(NEXT_AUTH_OPTIONS);
|
const session = await getServerSession(NEXT_AUTH_OPTIONS);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
@ -21,11 +24,11 @@ const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
|
|||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
|
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
|
||||||
|
|
||||||
const FRIENDLY_INTERVALS: Record<Interval, string> = {
|
const FRIENDLY_INTERVALS: Record<Interval, MessageDescriptor> = {
|
||||||
day: 'Daily',
|
day: msg`Daily`,
|
||||||
week: 'Weekly',
|
week: msg`Weekly`,
|
||||||
month: 'Monthly',
|
month: msg`Monthly`,
|
||||||
year: 'Yearly',
|
year: msg`Yearly`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MotionCard = motion(Card);
|
const MotionCard = motion(Card);
|
||||||
@ -35,6 +38,7 @@ export type BillingPlansProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
@ -55,8 +59,8 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
|||||||
window.open(url);
|
window.open(url);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'An error occurred while trying to create a checkout session.',
|
description: _(msg`An error occurred while trying to create a checkout session.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@ -72,7 +76,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
|||||||
(interval) =>
|
(interval) =>
|
||||||
prices[interval].length > 0 && (
|
prices[interval].length > 0 && (
|
||||||
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
|
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
|
||||||
{FRIENDLY_INTERVALS[interval]}
|
{_(FRIENDLY_INTERVALS[interval])}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@ -121,7 +125,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
|||||||
loading={isFetchingCheckoutSession}
|
loading={isFetchingCheckoutSession}
|
||||||
onClick={() => void onSubscribeClick(price.id)}
|
onClick={() => void onSubscribeClick(price.id)}
|
||||||
>
|
>
|
||||||
Subscribe
|
<Trans>Subscribe</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</MotionCard>
|
</MotionCard>
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@ -12,6 +15,7 @@ export type BillingPortalButtonProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
|
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
||||||
@ -32,16 +36,18 @@ export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) =
|
|||||||
|
|
||||||
window.open(sessionUrl, '_blank');
|
window.open(sessionUrl, '_blank');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let description =
|
let description = _(
|
||||||
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.';
|
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
|
||||||
|
);
|
||||||
|
|
||||||
if (e.message === 'CUSTOMER_NOT_FOUND') {
|
if (e.message === 'CUSTOMER_NOT_FOUND') {
|
||||||
description =
|
description = _(
|
||||||
'You do not currently have a customer record, this should not happen. Please contact support for assistance.';
|
msg`You do not currently have a customer record, this should not happen. Please contact support for assistance.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description,
|
description,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
@ -57,7 +63,7 @@ export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) =
|
|||||||
onClick={async () => handleFetchPortalUrl()}
|
onClick={async () => handleFetchPortalUrl()}
|
||||||
loading={isFetchingPortalUrl}
|
loading={isFetchingPortalUrl}
|
||||||
>
|
>
|
||||||
Manage Subscription
|
<Trans>Manage Subscription</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
|
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
|
||||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
@ -14,8 +16,6 @@ import { type Stripe } from '@documenso/lib/server-only/stripe';
|
|||||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
import { BillingPlans } from './billing-plans';
|
import { BillingPlans } from './billing-plans';
|
||||||
import { BillingPortalButton } from './billing-portal-button';
|
import { BillingPortalButton } from './billing-portal-button';
|
||||||
|
|
||||||
@ -24,6 +24,8 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
|
const { i18n } = setupI18nSSR();
|
||||||
|
|
||||||
let { user } = await getRequiredServerComponentSession();
|
let { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
||||||
@ -66,15 +68,20 @@ export default async function BillingSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-semibold">Billing</h3>
|
<h3 className="text-2xl font-semibold">
|
||||||
|
<Trans>Billing</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-2 text-sm">
|
<div className="text-muted-foreground mt-2 text-sm">
|
||||||
{isMissingOrInactiveOrFreePlan && (
|
{isMissingOrInactiveOrFreePlan && (
|
||||||
<p>
|
<p>
|
||||||
|
<Trans>
|
||||||
You are currently on the <span className="font-semibold">Free Plan</span>.
|
You are currently on the <span className="font-semibold">Free Plan</span>.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Todo: Translation */}
|
||||||
{!isMissingOrInactiveOrFreePlan &&
|
{!isMissingOrInactiveOrFreePlan &&
|
||||||
match(subscription.status)
|
match(subscription.status)
|
||||||
.with('ACTIVE', () => (
|
.with('ACTIVE', () => (
|
||||||
@ -95,12 +102,12 @@ export default async function BillingSettingsPage() {
|
|||||||
{subscription.cancelAtPeriodEnd ? (
|
{subscription.cancelAtPeriodEnd ? (
|
||||||
<span>
|
<span>
|
||||||
end on{' '}
|
end on{' '}
|
||||||
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
|
<span className="font-semibold">{i18n.date(subscription.periodEnd)}.</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
automatically renew on{' '}
|
automatically renew on{' '}
|
||||||
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
|
<span className="font-semibold">{i18n.date(subscription.periodEnd)}.</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@ -108,7 +115,11 @@ export default async function BillingSettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
))
|
))
|
||||||
.with('PAST_DUE', () => (
|
.with('PAST_DUE', () => (
|
||||||
<p>Your current plan is past due. Please update your payment information.</p>
|
<p>
|
||||||
|
<Trans>
|
||||||
|
Your current plan is past due. Please update your payment information.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
))
|
))
|
||||||
.otherwise(() => null)}
|
.otherwise(() => null)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { DesktopNav } from '~/components/(dashboard)/settings/layout/desktop-nav';
|
import { DesktopNav } from '~/components/(dashboard)/settings/layout/desktop-nav';
|
||||||
import { MobileNav } from '~/components/(dashboard)/settings/layout/mobile-nav';
|
import { MobileNav } from '~/components/(dashboard)/settings/layout/mobile-nav';
|
||||||
|
|
||||||
@ -8,9 +12,13 @@ export type DashboardSettingsLayoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
|
export default function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<h1 className="text-4xl font-semibold">Settings</h1>
|
<h1 className="text-4xl font-semibold">
|
||||||
|
<Trans>Settings</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||||
<DesktopNav className="hidden md:col-span-3 md:flex" />
|
<DesktopNav className="hidden md:col-span-3 md:flex" />
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
@ -28,6 +30,7 @@ export type DeleteAccountDialogProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => {
|
export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const hasTwoFactorAuthentication = user.twoFactorEnabled;
|
const hasTwoFactorAuthentication = user.twoFactorEnabled;
|
||||||
@ -42,8 +45,8 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
|
|||||||
await deleteAccount();
|
await deleteAccount();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Account deleted',
|
title: _(msg`Account deleted`),
|
||||||
description: 'Your account has been deleted successfully.',
|
description: _(msg`Your account has been deleted successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -51,17 +54,19 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||||
toast({
|
toast({
|
||||||
title: 'An error occurred',
|
title: _(msg`An error occurred`),
|
||||||
description: err.message,
|
description: err.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'An unknown error occurred',
|
title: _(msg`An unknown error occurred`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description:
|
description:
|
||||||
err.message ??
|
err.message ??
|
||||||
'We encountered an unknown error while attempting to delete your account. Please try again later.',
|
_(
|
||||||
|
msg`We encountered an unknown error while attempting to delete your account. Please try again later.`,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -74,50 +79,63 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<AlertTitle>Delete Account</AlertTitle>
|
<AlertTitle>
|
||||||
|
<Trans>Delete Account</Trans>
|
||||||
|
</AlertTitle>
|
||||||
<AlertDescription className="mr-2">
|
<AlertDescription className="mr-2">
|
||||||
Delete your account and all its contents, including completed documents. This action is
|
<Trans>
|
||||||
irreversible and will cancel your subscription, so proceed with caution.
|
Delete your account and all its contents, including completed documents. This action
|
||||||
|
is irreversible and will cancel your subscription, so proceed with caution.
|
||||||
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Dialog onOpenChange={() => setEnteredEmail('')}>
|
<Dialog onOpenChange={() => setEnteredEmail('')}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="destructive">Delete Account</Button>
|
<Button variant="destructive">
|
||||||
|
<Trans>Delete Account</Trans>
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="space-y-4">
|
||||||
<DialogTitle>Delete Account</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Delete Account</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription className="selection:bg-red-100">
|
<AlertDescription className="selection:bg-red-100">
|
||||||
This action is not reversible. Please be certain.
|
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{hasTwoFactorAuthentication && (
|
{hasTwoFactorAuthentication && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription className="selection:bg-red-100">
|
<AlertDescription className="selection:bg-red-100">
|
||||||
Disable Two Factor Authentication before deleting your account.
|
<Trans>Disable Two Factor Authentication before deleting your account.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Documenso will delete <span className="font-semibold">all of your documents</span>
|
<Trans>
|
||||||
, along with all of your completed documents, signatures, and all other resources
|
Documenso will delete{' '}
|
||||||
belonging to your Account.
|
<span className="font-semibold">all of your documents</span>, along with all of
|
||||||
|
your completed documents, signatures, and all other resources belonging to your
|
||||||
|
Account.
|
||||||
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{!hasTwoFactorAuthentication && (
|
{!hasTwoFactorAuthentication && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Label>
|
<Label>
|
||||||
|
<Trans>
|
||||||
Please type{' '}
|
Please type{' '}
|
||||||
<span className="text-muted-foreground font-semibold">{user.email}</span> to
|
<span className="text-muted-foreground font-semibold">{user.email}</span> to
|
||||||
confirm.
|
confirm.
|
||||||
|
</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
@ -136,7 +154,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={hasTwoFactorAuthentication || enteredEmail !== user.email}
|
disabled={hasTwoFactorAuthentication || enteredEmail !== user.email}
|
||||||
>
|
>
|
||||||
{isDeletingAccount ? 'Deleting account...' : 'Confirm Deletion'}
|
{isDeletingAccount ? _(msg`Deleting account...`) : _(msg`Confirm Deletion`)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
@ -13,11 +17,17 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function ProfileSettingsPage() {
|
export default async function ProfileSettingsPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
|
<SettingsHeader
|
||||||
|
title={_(msg`Profile`)}
|
||||||
|
subtitle={_(msg`Here you can edit your personal details.`)}
|
||||||
|
/>
|
||||||
|
|
||||||
<AvatarImageForm className="mb-8 max-w-xl" user={user} />
|
<AvatarImageForm className="mb-8 max-w-xl" user={user} />
|
||||||
<ProfileForm className="mb-8 max-w-xl" user={user} />
|
<ProfileForm className="mb-8 max-w-xl" user={user} />
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
|
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
|
||||||
|
|
||||||
import { PublicProfilePageView } from './public-profile-page-view';
|
import { PublicProfilePageView } from './public-profile-page-view';
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const { profile } = await getUserPublicProfile({
|
const { profile } = await getUserPublicProfile({
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
||||||
import type {
|
import type {
|
||||||
Team,
|
Team,
|
||||||
@ -36,22 +39,21 @@ type DirectTemplate = FindTemplateRow & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const userProfileText = {
|
const userProfileText = {
|
||||||
settingsTitle: 'Public Profile',
|
settingsTitle: msg`Public Profile`,
|
||||||
settingsSubtitle: 'You can choose to enable or disable your profile for public view.',
|
settingsSubtitle: msg`You can choose to enable or disable your profile for public view.`,
|
||||||
templatesTitle: 'My templates',
|
templatesTitle: msg`My templates`,
|
||||||
templatesSubtitle:
|
templatesSubtitle: msg`Show templates in your public profile for your audience to sign and get started quickly`,
|
||||||
'Show templates in your public profile for your audience to sign and get started quickly',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const teamProfileText = {
|
const teamProfileText = {
|
||||||
settingsTitle: 'Team Public Profile',
|
settingsTitle: msg`Team Public Profile`,
|
||||||
settingsSubtitle: 'You can choose to enable or disable your team profile for public view.',
|
settingsSubtitle: msg`You can choose to enable or disable your team profile for public view.`,
|
||||||
templatesTitle: 'Team templates',
|
templatesTitle: msg`Team templates`,
|
||||||
templatesSubtitle:
|
templatesSubtitle: msg`Show templates in your team public profile for your audience to sign and get started quickly`,
|
||||||
'Show templates in your team public profile for your audience to sign and get started quickly',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePageViewOptions) => {
|
export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePageViewOptions) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
|
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
|
||||||
@ -104,7 +106,7 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
|
|||||||
|
|
||||||
if (isVisible && !user.url) {
|
if (isVisible && !user.url) {
|
||||||
toast({
|
toast({
|
||||||
title: 'You must set a profile URL before enabling your public profile.',
|
title: _(msg`You must set a profile URL before enabling your public profile.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -119,8 +121,8 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
|
|||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: 'We were unable to set your public profile to public. Please try again.',
|
description: _(msg`We were unable to set your public profile to public. Please try again.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -134,7 +136,10 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<SettingsHeader title={profileText.settingsTitle} subtitle={profileText.settingsSubtitle}>
|
<SettingsHeader
|
||||||
|
title={_(profileText.settingsTitle)}
|
||||||
|
subtitle={_(profileText.settingsSubtitle)}
|
||||||
|
>
|
||||||
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
|
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
@ -146,13 +151,17 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>Hide</span>
|
<span>
|
||||||
|
<Trans>Hide</Trans>
|
||||||
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
disabled={isUpdating}
|
disabled={isUpdating}
|
||||||
checked={isPublicProfileVisible}
|
checked={isPublicProfileVisible}
|
||||||
onCheckedChange={togglePublicProfileVisibility}
|
onCheckedChange={togglePublicProfileVisibility}
|
||||||
/>
|
/>
|
||||||
<span>Show</span>
|
<span>
|
||||||
|
<Trans>Show</Trans>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
@ -160,18 +169,26 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
|
|||||||
{isPublicProfileVisible ? (
|
{isPublicProfileVisible ? (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
|
<Trans>
|
||||||
Profile is currently <strong>visible</strong>.
|
Profile is currently <strong>visible</strong>.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>Toggle the switch to hide your profile from the public.</p>
|
<p>
|
||||||
|
<Trans>Toggle the switch to hide your profile from the public.</Trans>
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
|
<Trans>
|
||||||
Profile is currently <strong>hidden</strong>.
|
Profile is currently <strong>hidden</strong>.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>Toggle the switch to show your profile to the public.</p>
|
<p>
|
||||||
|
<Trans>Toggle the switch to show your profile to the public.</Trans>
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@ -187,14 +204,18 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
|
|||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title={profileText.templatesTitle}
|
title={_(profileText.templatesTitle)}
|
||||||
subtitle={profileText.templatesSubtitle}
|
subtitle={_(profileText.templatesSubtitle)}
|
||||||
hideDivider={true}
|
hideDivider={true}
|
||||||
className="mt-8 [&>*>h3]:text-base"
|
className="mt-8 [&>*>h3]:text-base"
|
||||||
>
|
>
|
||||||
<ManagePublicTemplateDialog
|
<ManagePublicTemplateDialog
|
||||||
directTemplates={enabledPrivateDirectTemplates}
|
directTemplates={enabledPrivateDirectTemplates}
|
||||||
trigger={<Button variant="outline">Link template</Button>}
|
trigger={
|
||||||
|
<Button variant="outline">
|
||||||
|
<Trans>Link template</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
|
import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
|
||||||
|
|
||||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
@ -30,6 +32,7 @@ type DirectTemplate = FindTemplateRow & {
|
|||||||
export const PublicTemplatesDataTable = () => {
|
export const PublicTemplatesDataTable = () => {
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [, copy] = useCopyToClipboard();
|
const [, copy] = useCopyToClipboard();
|
||||||
@ -71,8 +74,8 @@ export const PublicTemplatesDataTable = () => {
|
|||||||
const onCopyClick = async (token: string) =>
|
const onCopyClick = async (token: string) =>
|
||||||
copy(formatDirectTemplatePath(token)).then(() => {
|
copy(formatDirectTemplatePath(token)).then(() => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Copied to clipboard',
|
title: _(msg`Copied to clipboard`),
|
||||||
description: 'The direct link has been copied to your clipboard',
|
description: _(msg`The direct link has been copied to your clipboard`),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -105,26 +108,26 @@ export const PublicTemplatesDataTable = () => {
|
|||||||
|
|
||||||
{isLoadingError && (
|
{isLoadingError && (
|
||||||
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
|
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
|
||||||
Unable to load your public profile templates at this time
|
<Trans>Unable to load your public profile templates at this time</Trans>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void refetch();
|
void refetch();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Click here to retry
|
<Trans>Click here to retry</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isInitialLoading && (
|
{!isInitialLoading && (
|
||||||
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
|
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
|
||||||
No public profile templates found
|
<Trans>No public profile templates found</Trans>
|
||||||
<ManagePublicTemplateDialog
|
<ManagePublicTemplateDialog
|
||||||
directTemplates={privateDirectTemplates}
|
directTemplates={privateDirectTemplates}
|
||||||
trigger={
|
trigger={
|
||||||
<button className="hover:text-muted-foreground/80 mt-1 text-xs">
|
<button className="hover:text-muted-foreground/80 mt-1 text-xs">
|
||||||
Click here to get started
|
<Trans>Click here to get started</Trans>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -157,11 +160,13 @@ export const PublicTemplatesDataTable = () => {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="center" side="left">
|
<DropdownMenuContent className="w-52" align="center" side="left">
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
<Trans>Action</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => void onCopyClick(template.directLink.token)}>
|
<DropdownMenuItem onClick={() => void onCopyClick(template.directLink.token)}>
|
||||||
<LinkIcon className="mr-2 h-4 w-4" />
|
<LinkIcon className="mr-2 h-4 w-4" />
|
||||||
Copy sharable link
|
<Trans>Copy sharable link</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@ -173,7 +178,7 @@ export const PublicTemplatesDataTable = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditIcon className="mr-2 h-4 w-4" />
|
<EditIcon className="mr-2 h-4 w-4" />
|
||||||
Update
|
<Trans>Update</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@ -185,7 +190,7 @@ export const PublicTemplatesDataTable = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||||
Remove
|
<Trans>Remove</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
|
||||||
import ActivityPageBackButton from '../../../../../components/(dashboard)/settings/layout/activity-back';
|
import ActivityPageBackButton from '../../../../../components/(dashboard)/settings/layout/activity-back';
|
||||||
@ -10,11 +15,15 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SettingsSecurityActivityPage() {
|
export default function SettingsSecurityActivityPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title="Security activity"
|
title={_(msg`Security activity`)}
|
||||||
subtitle="View all recent security activity related to your account."
|
subtitle={_(msg`View all security activity related to your account.`)}
|
||||||
hideDivider={true}
|
hideDivider={true}
|
||||||
>
|
>
|
||||||
<ActivityPageBackButton />
|
<ActivityPageBackButton />
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import type { DateTimeFormatOptions } from 'luxon';
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
@ -10,20 +14,19 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
|
|||||||
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
|
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
|
||||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
const dateFormat: DateTimeFormatOptions = {
|
const dateFormat: DateTimeFormatOptions = {
|
||||||
...DateTime.DATETIME_SHORT,
|
...DateTime.DATETIME_SHORT,
|
||||||
hourCycle: 'h12',
|
hourCycle: 'h12',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UserSecurityActivityDataTable = () => {
|
export const UserSecurityActivityDataTable = () => {
|
||||||
const parser = new UAParser();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -59,16 +62,17 @@ export const UserSecurityActivityDataTable = () => {
|
|||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const columns = useMemo(() => {
|
||||||
<DataTable
|
const parser = new UAParser();
|
||||||
columns={[
|
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
header: 'Date',
|
header: _(msg`Date`),
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
|
cell: ({ row }) => i18n.date(row.original.createdAt, dateFormat),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Device',
|
header: _(msg`Device`),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (!row.original.userAgent) {
|
if (!row.original.userAgent) {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@ -92,7 +96,7 @@ export const UserSecurityActivityDataTable = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Browser',
|
header: _(msg`Browser`),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (!row.original.userAgent) {
|
if (!row.original.userAgent) {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@ -111,11 +115,16 @@ export const UserSecurityActivityDataTable = () => {
|
|||||||
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
|
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: _(msg`Action`),
|
||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
|
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
|
||||||
},
|
},
|
||||||
]}
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
data={results.data}
|
data={results.data}
|
||||||
perPage={results.perPage}
|
perPage={results.perPage}
|
||||||
currentPage={results.currentPage}
|
currentPage={results.currentPage}
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
@ -17,6 +21,9 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function SecuritySettingsPage() {
|
export default async function SecuritySettingsPage() {
|
||||||
|
setupI18nSSR();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||||
@ -24,8 +31,8 @@ export default async function SecuritySettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title="Security"
|
title={_(msg`Security`)}
|
||||||
subtitle="Here you can manage your password and security settings."
|
subtitle={_(msg`Here you can manage your password and security settings.`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{user.identityProvider === 'DOCUMENSO' && (
|
{user.identityProvider === 'DOCUMENSO' && (
|
||||||
@ -41,13 +48,22 @@ export default async function SecuritySettingsPage() {
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<div className="mb-4 sm:mb-0">
|
<div className="mb-4 sm:mb-0">
|
||||||
<AlertTitle>Two factor authentication</AlertTitle>
|
<AlertTitle>
|
||||||
|
<Trans>Two factor authentication</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
<AlertDescription className="mr-4">
|
<AlertDescription className="mr-4">
|
||||||
Add an authenticator to serve as a secondary authentication method{' '}
|
{user.identityProvider === 'DOCUMENSO' ? (
|
||||||
{user.identityProvider === 'DOCUMENSO'
|
<Trans>
|
||||||
? 'when signing in, or when signing documents.'
|
Add an authenticator to serve as a secondary authentication method when signing in,
|
||||||
: 'for signing documents.'}
|
or when signing documents.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Add an authenticator to serve as a secondary authentication method for signing
|
||||||
|
documents.
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -64,11 +80,15 @@ export default async function SecuritySettingsPage() {
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<div className="mb-4 sm:mb-0">
|
<div className="mb-4 sm:mb-0">
|
||||||
<AlertTitle>Recovery codes</AlertTitle>
|
<AlertTitle>
|
||||||
|
<Trans>Recovery codes</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
<AlertDescription className="mr-4">
|
<AlertDescription className="mr-4">
|
||||||
Two factor authentication recovery codes are used to access your account in the event
|
<Trans>
|
||||||
that you lose access to your authenticator app.
|
Two factor authentication recovery codes are used to access your account in the
|
||||||
|
event that you lose access to your authenticator app.
|
||||||
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -82,15 +102,21 @@ export default async function SecuritySettingsPage() {
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<div className="mb-4 sm:mb-0">
|
<div className="mb-4 sm:mb-0">
|
||||||
<AlertTitle>Passkeys</AlertTitle>
|
<AlertTitle>
|
||||||
|
<Trans>Passkeys</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
<AlertDescription className="mr-4">
|
<AlertDescription className="mr-4">
|
||||||
|
<Trans>
|
||||||
Allows authenticating using biometrics, password managers, hardware keys, etc.
|
Allows authenticating using biometrics, password managers, hardware keys, etc.
|
||||||
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button asChild variant="outline" className="bg-background">
|
<Button asChild variant="outline" className="bg-background">
|
||||||
<Link href="/settings/security/passkeys">Manage passkeys</Link>
|
<Link href="/settings/security/passkeys">
|
||||||
|
<Trans>Manage passkeys</Trans>
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@ -100,15 +126,19 @@ export default async function SecuritySettingsPage() {
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<div className="mb-4 mr-4 sm:mb-0">
|
<div className="mb-4 mr-4 sm:mb-0">
|
||||||
<AlertTitle>Recent activity</AlertTitle>
|
<AlertTitle>
|
||||||
|
<Trans>Recent activity</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
<AlertDescription className="mr-2">
|
<AlertDescription className="mr-2">
|
||||||
View all recent security activity related to your account.
|
<Trans>View all recent security activity related to your account.</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button asChild variant="outline" className="bg-background">
|
<Button asChild variant="outline" className="bg-background">
|
||||||
<Link href="/settings/security/activity">View activity</Link>
|
<Link href="/settings/security/activity">
|
||||||
|
<Trans>View activity</Trans>
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user