mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
41 Commits
v1.8.0-rc.
...
v1.8.1-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c53f5b061 | |||
| ed6157de80 | |||
| 5e08d0cffb | |||
| 5565aff7a3 | |||
| 428acf4ac3 | |||
| f4b1e5104e | |||
| a687064a42 | |||
| 8ec69388a5 | |||
| f3da11b3e7 | |||
| fc84ee8ec2 | |||
| 4282a96ee7 | |||
| 2aae7435f8 | |||
| bdd33bd335 | |||
| 9e8d0ac906 | |||
| f27d0f342c | |||
| 4326e27a2a | |||
| 62806298cf | |||
| 87186e08b1 | |||
| b27fd800ed | |||
| 98d85b086d | |||
| 04293968c6 | |||
| e6d4005cd1 | |||
| 337bdb3553 | |||
| ab654a63d8 | |||
| dcb7c2436f | |||
| fa33f83696 | |||
| b15e1d6c47 | |||
| cd5adce7df | |||
| 11e483f1c4 | |||
| 2e2bc8382f | |||
| 1f3a9b578b | |||
| 83e7a3c222 | |||
| 9ef8b1f0c3 | |||
| 0eff336175 | |||
| 9bdd5c31cc | |||
| 57ad7c150b | |||
| b0829e6cdf | |||
| 08a446fefd | |||
| f15f9ecdd1 | |||
| 979e3f3e71 | |||
| 876803b5db |
@ -139,3 +139,6 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||
# [[REDIS]]
|
||||
NEXT_PRIVATE_REDIS_URL=
|
||||
NEXT_PRIVATE_REDIS_TOKEN=
|
||||
|
||||
# [[LOGGER]]
|
||||
NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY=
|
||||
|
||||
@ -27,9 +27,6 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
9
apps/documentation/pages/developers/embedding/_meta.json
Normal file
9
apps/documentation/pages/developers/embedding/_meta.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"index": "Get Started",
|
||||
"react": "React Integration",
|
||||
"vue": "Vue Integration",
|
||||
"svelte": "Svelte Integration",
|
||||
"solid": "Solid Integration",
|
||||
"preact": "Preact Integration",
|
||||
"css-variables": "CSS Variables"
|
||||
}
|
||||
120
apps/documentation/pages/developers/embedding/css-variables.mdx
Normal file
120
apps/documentation/pages/developers/embedding/css-variables.mdx
Normal file
@ -0,0 +1,120 @@
|
||||
---
|
||||
title: CSS Variables
|
||||
description: Learn about all available CSS variables for customizing your embedded signing experience
|
||||
---
|
||||
|
||||
# CSS Variables
|
||||
|
||||
Platform customers have access to a comprehensive set of CSS variables that can be used to customize the appearance of the embedded signing experience. These variables control everything from colors to spacing and can be used to match your application's design system.
|
||||
|
||||
## Available Variables
|
||||
|
||||
### Colors
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ----------------------- | ---------------------------------- | -------------- |
|
||||
| `background` | Base background color | System default |
|
||||
| `foreground` | Base text color | System default |
|
||||
| `muted` | Muted/subtle background color | System default |
|
||||
| `mutedForeground` | Muted/subtle text color | System default |
|
||||
| `popover` | Popover/dropdown background color | System default |
|
||||
| `popoverForeground` | Popover/dropdown text color | System default |
|
||||
| `card` | Card background color | System default |
|
||||
| `cardBorder` | Card border color | System default |
|
||||
| `cardBorderTint` | Card border tint/highlight color | System default |
|
||||
| `cardForeground` | Card text color | System default |
|
||||
| `fieldCard` | Field card background color | System default |
|
||||
| `fieldCardBorder` | Field card border color | System default |
|
||||
| `fieldCardForeground` | Field card text color | System default |
|
||||
| `widget` | Widget background color | System default |
|
||||
| `widgetForeground` | Widget text color | System default |
|
||||
| `border` | Default border color | System default |
|
||||
| `input` | Input field border color | System default |
|
||||
| `primary` | Primary action/button color | System default |
|
||||
| `primaryForeground` | Primary action/button text color | System default |
|
||||
| `secondary` | Secondary action/button color | System default |
|
||||
| `secondaryForeground` | Secondary action/button text color | System default |
|
||||
| `accent` | Accent/highlight color | System default |
|
||||
| `accentForeground` | Accent/highlight text color | System default |
|
||||
| `destructive` | Destructive/danger action color | System default |
|
||||
| `destructiveForeground` | Destructive/danger text color | System default |
|
||||
| `ring` | Focus ring color | System default |
|
||||
| `warning` | Warning/alert color | System default |
|
||||
|
||||
### Spacing and Layout
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------- | ------------------------------- | -------------- |
|
||||
| `radius` | Border radius size in REM units | System default |
|
||||
|
||||
## Usage Example
|
||||
|
||||
Here's how to use these variables in your embedding implementation:
|
||||
|
||||
```jsx
|
||||
const cssVars = {
|
||||
// Colors
|
||||
background: '#ffffff',
|
||||
foreground: '#000000',
|
||||
primary: '#0000ff',
|
||||
primaryForeground: '#ffffff',
|
||||
accent: '#4f46e5',
|
||||
destructive: '#ef4444',
|
||||
|
||||
// Spacing
|
||||
radius: '0.5rem'
|
||||
};
|
||||
|
||||
// React/Preact
|
||||
<EmbedDirectTemplate
|
||||
token={token}
|
||||
cssVars={cssVars}
|
||||
/>
|
||||
|
||||
// Vue
|
||||
<EmbedDirectTemplate
|
||||
:token="token"
|
||||
:cssVars="cssVars"
|
||||
/>
|
||||
|
||||
// Svelte
|
||||
<EmbedDirectTemplate
|
||||
{token}
|
||||
cssVars={cssVars}
|
||||
/>
|
||||
|
||||
// Solid
|
||||
<EmbedDirectTemplate
|
||||
token={token}
|
||||
cssVars={cssVars}
|
||||
/>
|
||||
```
|
||||
|
||||
## Color Format
|
||||
|
||||
Colors can be specified in any valid CSS color format:
|
||||
|
||||
- Hexadecimal: `#ff0000`
|
||||
- RGB: `rgb(255, 0, 0)`
|
||||
- HSL: `hsl(0, 100%, 50%)`
|
||||
- Named colors: `red`
|
||||
|
||||
The colors will be automatically converted to the appropriate format internally.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Maintain Contrast**: When customizing colors, ensure there's sufficient contrast between background and foreground colors for accessibility.
|
||||
|
||||
2. **Test Dark Mode**: If you haven't disabled dark mode, test your color variables in both light and dark modes.
|
||||
|
||||
3. **Use Your Brand Colors**: Align the primary and accent colors with your brand's color scheme for a cohesive look.
|
||||
|
||||
4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system.
|
||||
|
||||
## Related
|
||||
|
||||
- [React Integration](/developers/embedding/react)
|
||||
- [Vue Integration](/developers/embedding/vue)
|
||||
- [Svelte Integration](/developers/embedding/svelte)
|
||||
- [Solid Integration](/developers/embedding/solid)
|
||||
- [Preact Integration](/developers/embedding/preact)
|
||||
@ -11,7 +11,11 @@ Our embedding feature lets you integrate our document signing experience into yo
|
||||
|
||||
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.
|
||||
Our **Platform Plan** offers enhanced customization features including:
|
||||
|
||||
- Custom CSS and styling variables
|
||||
- Dark mode controls
|
||||
- The removal of Documenso branding from the embedding experience
|
||||
|
||||
## How Embedding Works
|
||||
|
||||
@ -22,6 +26,49 @@ Embedding with Documenso allows you to handle document signing in two main ways:
|
||||
|
||||
_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._
|
||||
|
||||
## Customization Options
|
||||
|
||||
### Styling and Theming
|
||||
|
||||
Platform customers have access to advanced styling options to customize the embedding experience:
|
||||
|
||||
1. **Custom CSS**: You can provide custom CSS to style the embedded component:
|
||||
|
||||
```jsx
|
||||
<EmbedDirectTemplate
|
||||
token={token}
|
||||
css={`
|
||||
.documenso-embed {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
/>
|
||||
```
|
||||
|
||||
2. **CSS Variables**: Fine-tune the appearance using CSS variables for colors, spacing, and more:
|
||||
|
||||
```jsx
|
||||
<EmbedDirectTemplate
|
||||
token={token}
|
||||
cssVars={{
|
||||
colorPrimary: '#0000FF',
|
||||
colorBackground: '#F5F5F5',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
For a complete list of available CSS variables and their usage, see our [CSS Variables](/developers/embedding/css-variables) documentation.
|
||||
|
||||
3. **Dark Mode Control**: Disable dark mode if it doesn't match your application's theme:
|
||||
|
||||
```jsx
|
||||
<EmbedDirectTemplate token={token} darkModeDisabled={true} />
|
||||
```
|
||||
|
||||
These customization options are available for both Direct Templates and Signing Token embeds.
|
||||
|
||||
## Supported Frameworks
|
||||
|
||||
We support embedding across a range of popular JavaScript frameworks, including:
|
||||
@ -120,12 +167,11 @@ Once you've obtained the appropriate tokens, you can integrate the signing exper
|
||||
|
||||
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
|
||||
## Related
|
||||
|
||||
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.
|
||||
- [React Integration](/developers/embedding/react)
|
||||
- [Vue Integration](/developers/embedding/vue)
|
||||
- [Svelte Integration](/developers/embedding/svelte)
|
||||
- [Solid Integration](/developers/embedding/solid)
|
||||
- [Preact Integration](/developers/embedding/preact)
|
||||
- [CSS Variables](/developers/embedding/css-variables)
|
||||
|
||||
@ -44,6 +44,9 @@ const MyEmbeddingComponent = () => {
|
||||
| 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 |
|
||||
| css | string (optional) | Custom CSS to style the embedded component (Platform Plan only) |
|
||||
| cssVars | object (optional) | CSS variables for customizing colors, spacing, etc. (Platform Plan only) |
|
||||
| darkModeDisabled | boolean (optional) | Disable dark mode functionality (Platform Plan only) |
|
||||
| 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 |
|
||||
@ -75,3 +78,30 @@ const MyEmbeddingComponent = () => {
|
||||
| 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 |
|
||||
|
||||
### Styling and Theming (Platform Plan)
|
||||
|
||||
Platform customers have access to advanced styling options:
|
||||
|
||||
```jsx
|
||||
import { EmbedDirectTemplate } from '@documenso/embed-preact';
|
||||
|
||||
const MyEmbeddingComponent = () => {
|
||||
const token = 'your-token';
|
||||
const customCss = `
|
||||
.documenso-embed {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
`;
|
||||
const cssVars = {
|
||||
colorPrimary: '#0000FF',
|
||||
colorBackground: '#F5F5F5',
|
||||
borderRadius: '8px',
|
||||
};
|
||||
|
||||
return (
|
||||
<EmbedDirectTemplate token={token} css={customCss} cssVars={cssVars} darkModeDisabled={true} />
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@ -44,6 +44,9 @@ const MyEmbeddingComponent = () => {
|
||||
| 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 |
|
||||
| css | string (optional) | Custom CSS to style the embedded component (Platform Plan only) |
|
||||
| cssVars | object (optional) | CSS variables for customizing colors, spacing, etc. (Platform Plan only) |
|
||||
| darkModeDisabled | boolean (optional) | Disable dark mode functionality (Platform Plan only) |
|
||||
| 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 |
|
||||
@ -75,3 +78,34 @@ const MyEmbeddingComponent = () => {
|
||||
| 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 |
|
||||
|
||||
### Styling and Theming (Platform Plan)
|
||||
|
||||
Platform customers have access to advanced styling options:
|
||||
|
||||
```jsx
|
||||
import { EmbedDirectTemplate } from '@documenso/embed-react';
|
||||
|
||||
const MyEmbeddingComponent = () => {
|
||||
return (
|
||||
<EmbedDirectTemplate
|
||||
token="your-token"
|
||||
// Custom CSS
|
||||
css={`
|
||||
.documenso-embed {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
// CSS Variables
|
||||
cssVars={{
|
||||
colorPrimary: '#0000FF',
|
||||
colorBackground: '#F5F5F5',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
// Dark Mode Control
|
||||
darkModeDisabled={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@ -44,6 +44,9 @@ const MyEmbeddingComponent = () => {
|
||||
| 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 |
|
||||
| css | string (optional) | Custom CSS to style the embedded component (Platform Plan only) |
|
||||
| cssVars | object (optional) | CSS variables for customizing colors, spacing, etc. (Platform Plan only) |
|
||||
| darkModeDisabled | boolean (optional) | Disable dark mode functionality (Platform Plan only) |
|
||||
| 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 |
|
||||
@ -75,3 +78,30 @@ const MyEmbeddingComponent = () => {
|
||||
| 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 |
|
||||
|
||||
### Styling and Theming (Platform Plan)
|
||||
|
||||
Platform customers have access to advanced styling options:
|
||||
|
||||
```jsx
|
||||
import { EmbedDirectTemplate } from '@documenso/embed-solid';
|
||||
|
||||
const MyEmbeddingComponent = () => {
|
||||
const token = 'your-token';
|
||||
const customCss = `
|
||||
.documenso-embed {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
`;
|
||||
const cssVars = {
|
||||
colorPrimary: '#0000FF',
|
||||
colorBackground: '#F5F5F5',
|
||||
borderRadius: '8px',
|
||||
};
|
||||
|
||||
return (
|
||||
<EmbedDirectTemplate token={token} css={customCss} cssVars={cssVars} darkModeDisabled={true} />
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@ -46,6 +46,9 @@ If you have a direct link template, you can simply provide the token for the tem
|
||||
| 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 |
|
||||
| css | string (optional) | Custom CSS to style the embedded component (Platform Plan only) |
|
||||
| cssVars | object (optional) | CSS variables for customizing colors, spacing, etc. (Platform Plan only) |
|
||||
| darkModeDisabled | boolean (optional) | Disable dark mode functionality (Platform Plan only) |
|
||||
| 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,3 +80,28 @@ const MyEmbeddingComponent = () => {
|
||||
| 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 |
|
||||
|
||||
### Styling and Theming (Platform Plan)
|
||||
|
||||
Platform customers have access to advanced styling options:
|
||||
|
||||
```html
|
||||
<script lang="ts">
|
||||
import { EmbedDirectTemplate } from '@documenso/embed-svelte';
|
||||
|
||||
const token = 'your-token';
|
||||
const customCss = `
|
||||
.documenso-embed {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
`;
|
||||
const cssVars = {
|
||||
colorPrimary: '#0000FF',
|
||||
colorBackground: '#F5F5F5',
|
||||
borderRadius: '8px',
|
||||
};
|
||||
</script>
|
||||
|
||||
<EmbedDirectTemplate {token} css="{customCss}" cssVars="{cssVars}" darkModeDisabled="{true}" />
|
||||
```
|
||||
|
||||
@ -46,6 +46,9 @@ If you have a direct link template, you can simply provide the token for the tem
|
||||
| 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 |
|
||||
| css | string (optional) | Custom CSS to style the embedded component (Platform Plan only) |
|
||||
| cssVars | object (optional) | CSS variables for customizing colors, spacing, etc. (Platform Plan only) |
|
||||
| darkModeDisabled | boolean (optional) | Disable dark mode functionality (Platform Plan only) |
|
||||
| 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,3 +80,35 @@ const MyEmbeddingComponent = () => {
|
||||
| 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 |
|
||||
|
||||
### Styling and Theming (Platform Plan)
|
||||
|
||||
Platform customers have access to advanced styling options:
|
||||
|
||||
```html
|
||||
<script setup lang="ts">
|
||||
import { EmbedDirectTemplate } from '@documenso/embed-vue';
|
||||
|
||||
const token = ref('your-token');
|
||||
const customCss = `
|
||||
.documenso-embed {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
`;
|
||||
const cssVars = {
|
||||
colorPrimary: '#0000FF',
|
||||
colorBackground: '#F5F5F5',
|
||||
borderRadius: '8px',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmbedDirectTemplate
|
||||
:token="token"
|
||||
:css="customCss"
|
||||
:cssVars="cssVars"
|
||||
:darkModeDisabled="true"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
@ -500,8 +500,35 @@ Now you can make a `POST` request to the `/api/v1/documents/{documentId}/fields`
|
||||
value, this is the value that will be used to sign the field.
|
||||
</Callout>
|
||||
|
||||
<Callout type="warning">
|
||||
It's important to pass the `type` in the `fieldMeta` property for the advanced fields. [Read more
|
||||
here](#a-note-on-advanced-fields)
|
||||
</Callout>
|
||||
|
||||
A successful request will return a JSON response with the newly added fields. The image below illustrates the fields added to the document via the API.
|
||||
|
||||

|
||||
|
||||
</Steps>
|
||||
|
||||
#### A Note on Advanced Fields
|
||||
|
||||
The advanced fields are: text, checkbox, radio, number, and select. Whenever you append any of these advanced fields to a document, you need to pass the `type` in the `fieldMeta` property:
|
||||
|
||||
```json
|
||||
...
|
||||
"fieldMeta": {
|
||||
"type": "text",
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
Replace the `text` value with the corresponding field type:
|
||||
|
||||
- For the `TEXT` field it should be `text`.
|
||||
- For the `CHECKBOX` field it should be `checkbox`.
|
||||
- For the `RADIO` field it should be `radio`.
|
||||
- For the `NUMBER` field it should be `number`.
|
||||
- For the `SELECT` field it should be `select`. (check this before merge)
|
||||
|
||||
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.
|
||||
|
||||
@ -20,6 +20,7 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
|
||||
- `document.opened`
|
||||
- `document.signed`
|
||||
- `document.completed`
|
||||
- `document.rejected`
|
||||
|
||||
## Create a webhook subscription
|
||||
|
||||
@ -36,7 +37,7 @@ Clicking on the "**Create Webhook**" button opens a modal to create a new webhoo
|
||||
To create a new webhook subscription, you need to provide the following information:
|
||||
|
||||
- Enter the webhook URL that will receive the event payload.
|
||||
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`.
|
||||
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`.
|
||||
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
|
||||
|
||||

|
||||
@ -53,45 +54,55 @@ You can edit or delete your webhook subscriptions by clicking the "**Edit**" or
|
||||
|
||||
The payload sent to the webhook URL contains the following fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------------------------------------- | --------- | ---------------------------------------------------- |
|
||||
| `event` | string | The type of event that triggered the webhook. |
|
||||
| `payload.id` | number | The id of the document. |
|
||||
| `payload.userId` | number | The id of the user who owns the document. |
|
||||
| `payload.authOptions` | json? | Authentication options for the document. |
|
||||
| `payload.formValues` | json? | Form values for the document. |
|
||||
| `payload.title` | string | The name of the document. |
|
||||
| `payload.status` | string | The current status of the document. |
|
||||
| `payload.documentDataId` | string | The identifier for the document data. |
|
||||
| `payload.createdAt` | datetime | The creation date and time of the document. |
|
||||
| `payload.updatedAt` | datetime | The last update date and time of the document. |
|
||||
| `payload.completedAt` | datetime? | The completion date and time of the document. |
|
||||
| `payload.deletedAt` | datetime? | The deletion date and time of the document. |
|
||||
| `payload.teamId` | number? | The id of the team. |
|
||||
| `payload.documentData.id` | string | The id of the document data. |
|
||||
| `payload.documentData.type` | string | The type of the document data. |
|
||||
| `payload.documentData.data` | string | The data of the document. |
|
||||
| `payload.documentData.initialData` | string | The initial data of the document. |
|
||||
| `payload.Recipient[].id` | number | The id of the recipient. |
|
||||
| `payload.Recipient[].documentId` | number? | The id the document associated with the recipient. |
|
||||
| `payload.Recipient[].templateId` | number? | The template identifier for the recipient. |
|
||||
| `payload.Recipient[].email` | string | The email address of the recipient. |
|
||||
| `payload.Recipient[].name` | string | The name of the recipient. |
|
||||
| `payload.Recipient[].token` | string | The token associated with the recipient. |
|
||||
| `payload.Recipient[].expired` | datetime? | The expiration status of the recipient. |
|
||||
| `payload.Recipient[].signedAt` | datetime? | The date and time the recipient signed the document. |
|
||||
| `payload.Recipient[].authOptions.accessAuth` | json? | Access authentication options. |
|
||||
| `payload.Recipient[].authOptions.actionAuth` | json? | Action authentication options. |
|
||||
| `payload.Recipient[].role` | string | The role of the recipient. |
|
||||
| `payload.Recipient[].readStatus` | string | The read status of the document by the recipient. |
|
||||
| `payload.Recipient[].signingStatus` | string | The signing status of the recipient. |
|
||||
| `payload.Recipient[].sendStatus` | string | The send status of the document to the recipient. |
|
||||
| `createdAt` | datetime | The creation date and time of the webhook event. |
|
||||
| `webhookEndpoint` | string | The endpoint URL where the webhook is sent. |
|
||||
|
||||
## Webhook event payload example
|
||||
|
||||
When an event that you have subscribed to occurs, Documenso will send a POST request to the specified webhook URL with a payload containing information about the event.
|
||||
| Field | Type | Description |
|
||||
| -------------------------------------------- | --------- | ----------------------------------------------------- |
|
||||
| `event` | string | The type of event that triggered the webhook. |
|
||||
| `payload.id` | number | The id of the document. |
|
||||
| `payload.externalId` | string? | External identifier for the document. |
|
||||
| `payload.userId` | number | The id of the user who owns the document. |
|
||||
| `payload.authOptions` | json? | Authentication options for the document. |
|
||||
| `payload.formValues` | json? | Form values for the document. |
|
||||
| `payload.visibility` | string | Document visibility (e.g., EVERYONE). |
|
||||
| `payload.title` | string | The title of the document. |
|
||||
| `payload.status` | string | The current status of the document. |
|
||||
| `payload.documentDataId` | string | The identifier for the document data. |
|
||||
| `payload.createdAt` | datetime | The creation date and time of the document. |
|
||||
| `payload.updatedAt` | datetime | The last update date and time of the document. |
|
||||
| `payload.completedAt` | datetime? | The completion date and time of the document. |
|
||||
| `payload.deletedAt` | datetime? | The deletion date and time of the document. |
|
||||
| `payload.teamId` | number? | The id of the team if document belongs to a team. |
|
||||
| `payload.templateId` | number? | The id of the template if created from template. |
|
||||
| `payload.source` | string | The source of the document (e.g., DOCUMENT, TEMPLATE) |
|
||||
| `payload.documentMeta.id` | string | The id of the document metadata. |
|
||||
| `payload.documentMeta.subject` | string? | The subject of the document. |
|
||||
| `payload.documentMeta.message` | string? | The message associated with the document. |
|
||||
| `payload.documentMeta.timezone` | string | The timezone setting for the document. |
|
||||
| `payload.documentMeta.password` | string? | The password protection if set. |
|
||||
| `payload.documentMeta.dateFormat` | string | The date format used in the document. |
|
||||
| `payload.documentMeta.redirectUrl` | string? | The URL to redirect after signing. |
|
||||
| `payload.documentMeta.signingOrder` | string | The signing order (e.g., PARALLEL, SEQUENTIAL). |
|
||||
| `payload.documentMeta.typedSignatureEnabled` | boolean | Whether typed signatures are enabled. |
|
||||
| `payload.documentMeta.language` | string | The language of the document. |
|
||||
| `payload.documentMeta.distributionMethod` | string | The method of distributing the document. |
|
||||
| `payload.documentMeta.emailSettings` | json? | Email notification settings. |
|
||||
| `payload.Recipient[].id` | number | The id of the recipient. |
|
||||
| `payload.Recipient[].documentId` | number? | The id of the document for this recipient. |
|
||||
| `payload.Recipient[].templateId` | number? | The template id if from a template. |
|
||||
| `payload.Recipient[].email` | string | The email address of the recipient. |
|
||||
| `payload.Recipient[].name` | string | The name of the recipient. |
|
||||
| `payload.Recipient[].token` | string | The unique token for this recipient. |
|
||||
| `payload.Recipient[].documentDeletedAt` | datetime? | When the document was deleted for this recipient. |
|
||||
| `payload.Recipient[].expired` | datetime? | When the recipient's access expired. |
|
||||
| `payload.Recipient[].signedAt` | datetime? | When the recipient signed the document. |
|
||||
| `payload.Recipient[].authOptions` | json? | Authentication options for this recipient. |
|
||||
| `payload.Recipient[].signingOrder` | number? | The order in which this recipient should sign. |
|
||||
| `payload.Recipient[].rejectionReason` | string? | The reason if the recipient rejected the document. |
|
||||
| `payload.Recipient[].role` | string | The role of the recipient (e.g., SIGNER, VIEWER). |
|
||||
| `payload.Recipient[].readStatus` | string | Whether the recipient has read the document. |
|
||||
| `payload.Recipient[].signingStatus` | string | The signing status of this recipient. |
|
||||
| `payload.Recipient[].sendStatus` | string | The sending status for this recipient. |
|
||||
| `createdAt` | datetime | The creation date and time of the webhook event. |
|
||||
| `webhookEndpoint` | string | The endpoint URL where the webhook is sent. |
|
||||
|
||||
## Example payloads
|
||||
|
||||
@ -104,9 +115,11 @@ Example payload for the `document.created` event:
|
||||
"event": "DOCUMENT_CREATED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"visibility": "EVERYONE",
|
||||
"title": "documenso.pdf",
|
||||
"status": "DRAFT",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
@ -114,7 +127,43 @@ Example payload for the `document.created` event:
|
||||
"updatedAt": "2024-04-22T11:44:43.341Z",
|
||||
"completedAt": null,
|
||||
"deletedAt": null,
|
||||
"teamId": null
|
||||
"teamId": null,
|
||||
"templateId": null,
|
||||
"source": "DOCUMENT",
|
||||
"documentMeta": {
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"typedSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 52,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@documenso.com",
|
||||
"name": "John Doe",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": null,
|
||||
"authOptions": null,
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "NOT_SENT"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2024-04-22T11:44:44.779Z",
|
||||
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
|
||||
@ -128,9 +177,11 @@ Example payload for the `document.sent` event:
|
||||
"event": "DOCUMENT_SENT",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"visibility": "EVERYONE",
|
||||
"title": "documenso.pdf",
|
||||
"status": "PENDING",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
@ -139,6 +190,22 @@ Example payload for the `document.sent` event:
|
||||
"completedAt": null,
|
||||
"deletedAt": null,
|
||||
"teamId": null,
|
||||
"templateId": null,
|
||||
"source": "DOCUMENT",
|
||||
"documentMeta": {
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"typedSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 52,
|
||||
@ -147,12 +214,12 @@ Example payload for the `document.sent` event:
|
||||
"email": "signer2@documenso.com",
|
||||
"name": "Signer 2",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": null,
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"authOptions": null,
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "VIEWER",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
@ -165,12 +232,12 @@ Example payload for the `document.sent` event:
|
||||
"email": "signer1@documenso.com",
|
||||
"name": "Signer 1",
|
||||
"token": "HkrptwS42ZBXdRKj1TyUo",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": null,
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"authOptions": null,
|
||||
"signingOrder": 2,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
@ -190,9 +257,11 @@ Example payload for the `document.opened` event:
|
||||
"event": "DOCUMENT_OPENED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"visibility": "EVERYONE",
|
||||
"title": "documenso.pdf",
|
||||
"status": "PENDING",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
@ -201,6 +270,22 @@ Example payload for the `document.opened` event:
|
||||
"completedAt": null,
|
||||
"deletedAt": null,
|
||||
"teamId": null,
|
||||
"templateId": null,
|
||||
"source": "DOCUMENT",
|
||||
"documentMeta": {
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"typedSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 52,
|
||||
@ -209,24 +294,18 @@ Example payload for the `document.opened` event:
|
||||
"email": "signer2@documenso.com",
|
||||
"name": "Signer 2",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": null,
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"authOptions": null,
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "VIEWER",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
],
|
||||
"documentData": {
|
||||
"id": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"type": "S3_PATH",
|
||||
"data": "9753/xzqrshtlpokm/documenso.pdf",
|
||||
"initialData": "9753/xzqrshtlpokm/documenso.pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2024-04-22T11:50:26.174Z",
|
||||
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
|
||||
@ -240,9 +319,11 @@ Example payload for the `document.signed` event:
|
||||
"event": "DOCUMENT_SIGNED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"visibility": "EVERYONE",
|
||||
"title": "documenso.pdf",
|
||||
"status": "COMPLETED",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
@ -251,6 +332,22 @@ Example payload for the `document.signed` event:
|
||||
"completedAt": "2024-04-22T11:52:05.707Z",
|
||||
"deletedAt": null,
|
||||
"teamId": null,
|
||||
"templateId": null,
|
||||
"source": "DOCUMENT",
|
||||
"documentMeta": {
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"typedSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 51,
|
||||
@ -259,12 +356,15 @@ Example payload for the `document.signed` event:
|
||||
"email": "signer1@documenso.com",
|
||||
"name": "Signer 1",
|
||||
"token": "HkrptwS42ZBXdRKj1TyUo",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": "2024-04-22T11:52:05.688Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "SIGNED",
|
||||
@ -284,9 +384,11 @@ Example payload for the `document.completed` event:
|
||||
"event": "DOCUMENT_COMPLETED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"visibility": "EVERYONE",
|
||||
"title": "documenso.pdf",
|
||||
"status": "COMPLETED",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
@ -295,11 +397,21 @@ Example payload for the `document.completed` event:
|
||||
"completedAt": "2024-04-22T11:52:05.707Z",
|
||||
"deletedAt": null,
|
||||
"teamId": null,
|
||||
"documentData": {
|
||||
"id": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"type": "S3_PATH",
|
||||
"data": "bk9p1h7x0s3m/documenso-signed.pdf",
|
||||
"initialData": "9753/xzqrshtlpokm/documenso.pdf"
|
||||
"templateId": null,
|
||||
"source": "DOCUMENT",
|
||||
"documentMeta": {
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"typedSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"Recipient": [
|
||||
{
|
||||
@ -309,12 +421,15 @@ Example payload for the `document.completed` event:
|
||||
"email": "signer2@documenso.com",
|
||||
"name": "Signer 2",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": "2024-04-22T11:51:10.055Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "VIEWER",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "SIGNED",
|
||||
@ -327,12 +442,15 @@ Example payload for the `document.completed` event:
|
||||
"email": "signer1@documenso.com",
|
||||
"name": "Signer 1",
|
||||
"token": "HkrptwS42ZBXdRKj1TyUo",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": "2024-04-22T11:52:05.688Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"signingOrder": 2,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "SIGNED",
|
||||
@ -345,6 +463,71 @@ Example payload for the `document.completed` event:
|
||||
}
|
||||
```
|
||||
|
||||
Example payload for the `document.rejected` event:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "DOCUMENT_REJECTED",
|
||||
"payload": {
|
||||
"id": 10,
|
||||
"externalId": null,
|
||||
"userId": 1,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"visibility": "EVERYONE",
|
||||
"title": "documenso.pdf",
|
||||
"status": "PENDING",
|
||||
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
|
||||
"createdAt": "2024-04-22T11:44:43.341Z",
|
||||
"updatedAt": "2024-04-22T11:48:07.569Z",
|
||||
"completedAt": null,
|
||||
"deletedAt": null,
|
||||
"teamId": null,
|
||||
"templateId": null,
|
||||
"source": "DOCUMENT",
|
||||
"documentMeta": {
|
||||
"id": "doc_meta_123",
|
||||
"subject": "Please sign this document",
|
||||
"message": "Hello, please review and sign this document.",
|
||||
"timezone": "UTC",
|
||||
"password": null,
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"redirectUrl": null,
|
||||
"signingOrder": "PARALLEL",
|
||||
"typedSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
},
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 52,
|
||||
"documentId": 10,
|
||||
"templateId": null,
|
||||
"email": "signer@documenso.com",
|
||||
"name": "Signer",
|
||||
"token": "vbT8hi3jKQmrFP_LN1WcS",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": "2024-04-22T11:48:07.569Z",
|
||||
"authOptions": {
|
||||
"accessAuth": null,
|
||||
"actionAuth": null
|
||||
},
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": "I do not agree with the terms",
|
||||
"role": "SIGNER",
|
||||
"readStatus": "OPENED",
|
||||
"signingStatus": "REJECTED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2024-04-22T11:48:07.945Z",
|
||||
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
|
||||
}
|
||||
```
|
||||
|
||||
## Availability
|
||||
|
||||
Webhooks are available to individual users and teams.
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
"signing-documents": "Signing Documents",
|
||||
"templates": "Templates",
|
||||
"direct-links": "Direct Signing Links",
|
||||
"document-visibility": "Document Visibility",
|
||||
"teams": "Teams",
|
||||
"-- Legal Overview": {
|
||||
"type": "separator",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"general-settings": "General Settings",
|
||||
"preferences": "Preferences",
|
||||
"document-visibility": "Document Visibility",
|
||||
"sender-details": "Email Sender Details"
|
||||
"sender-details": "Email Sender Details",
|
||||
"branding-preferences": "Branding Preferences"
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
---
|
||||
title: Branding Preferences
|
||||
description: Learn how to set the branding preferences for your team account.
|
||||
---
|
||||
|
||||
# Branding Preferences
|
||||
|
||||
You can set the branding preferences for your team account by going to the **Branding Preferences** tab in the team's settings dashboard.
|
||||
|
||||

|
||||
|
||||
On this page, you can:
|
||||
|
||||
- **Upload a Logo** - Upload your team's logo to be displayed instead of the default Documenso logo.
|
||||
- **Set the Brand Website** - Enter the URL of your team's website to be displayed in the email communications sent by the team.
|
||||
- **Add Additional Brand Details** - You can add additional information to display at the bottom of the emails sent by the team. This can include contact information, social media links, and other relevant details.
|
||||
@ -13,9 +13,9 @@ The default document visibility option allows you to control who can view and ac
|
||||
- **Managers and above** - The document is visible to team members with the role of _Manager or above_ and _Admin_.
|
||||
- **Admin only** - The document is only visible to the team's admins.
|
||||
|
||||

|
||||

|
||||
|
||||
The default document visibility is set to "_EVERYONE_" by default. You can change this setting by going to the [team's general settings page](/users/teams/general-settings) and selecting a different visibility option.
|
||||
The default document visibility is set to "_EVERYONE_" by default. You can change this setting by going to the [team's general preferences page](/users/teams/preferences) and selecting a different visibility option.
|
||||
|
||||
<Callout type="warning">
|
||||
If the team member uploading the document has a role lower than the default document visibility,
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
---
|
||||
title: General Settings
|
||||
description: Learn how to manage your team's General settings.
|
||||
---
|
||||
|
||||
# General Settings
|
||||
|
||||
You can manage your team's general settings by clicking on the **General Settings** tab in the team's settings dashboard.
|
||||
|
||||

|
||||
|
||||
The general settings page allows you to update the following settings:
|
||||
|
||||
- **Document Visibility** - Set the default visibility of the documents created by team members. Learn more about [document visibility](/users/teams/document-visibility).
|
||||
- **Sender Details** - Set whether the sender's name should be included in the emails sent by the team. Learn more about [sender details](/users/teams/sender-details).
|
||||
19
apps/documentation/pages/users/teams/preferences.mdx
Normal file
19
apps/documentation/pages/users/teams/preferences.mdx
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
title: Preferences
|
||||
description: Learn how to manage your team's global preferences.
|
||||
---
|
||||
|
||||
# Preferences
|
||||
|
||||
You can manage your team's global preferences by clicking on the **Preferences** tab in the team's settings dashboard.
|
||||
|
||||

|
||||
|
||||
The preferences page allows you to update the following settings:
|
||||
|
||||
- **Document Visibility** - Set the default visibility of the documents created by team members. Learn more about [document visibility](/users/teams/document-visibility).
|
||||
- **Default Document Language** - This setting allows you to set the default language for the documents uploaded in the team account. The default language is used as the default language in the email communications with the document recipients. You can change the language for individual documents when uploading them.
|
||||
- **Sender Details** - Set whether the sender's name should be included in the emails sent by the team. Learn more about [sender details](/users/teams/sender-details).
|
||||
- **Typed Signature** - It controls whether the document recipients can sign the documents with a typed signature or not. If enabled, the recipients can sign the document using either a drawn or a typed signature. If disabled, the recipients can only sign the documents usign a drawn signature. This setting can also be changed for individual documents when uploading them.
|
||||
- **Include the Signing Certificate** - This setting controls whether the signing certificate should be included in the signed documents. If enabled, the signing certificate is included in the signed documents. If disabled, the signing certificate is not included in the signed documents. Regardless of this setting, the signing certificate is always available in the document's audit log page.
|
||||
- **Branding Preferences** - Set the branding preferences and defaults for the team account. Learn more about [branding preferences](/users/teams/branding-preferences).
|
||||
BIN
apps/documentation/public/teams/team-branding-preferences.webp
Normal file
BIN
apps/documentation/public/teams/team-branding-preferences.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/documentation/public/teams/team-preferences.webp
Normal file
BIN
apps/documentation/public/teams/team-preferences.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
@ -8,6 +8,59 @@ Check out what's new in the latest version and read our thoughts on it. For more
|
||||
|
||||
---
|
||||
|
||||
# Documenso v1.8.0: Team Preferences, Signature Rejection, and Document Distribution
|
||||
|
||||
We're excited to announce the release of Documenso v1.8.0! This update brings powerful new features to enhance your document signing process. Here's what's new:
|
||||
|
||||
## 🌟 Key New Features
|
||||
|
||||
### 1. Team Preferences
|
||||
|
||||
Introducing **Team Preferences**, allowing administrators to configure settings and preferences that apply to documents across the entire team. This feature ensures consistency and simplifies management by letting you set default options, permissions, and preferences that automatically apply to all team members.
|
||||
|
||||

|
||||
|
||||
### 2. Signature Rejection
|
||||
|
||||
Recipients now have the option to **reject signatures**. This feature enhances communication by allowing recipients to decline signing, providing feedback or requesting changes before the document is finalized.
|
||||
|
||||
<video
|
||||
src="/changelog/v1_8_0/reject-document.mp4"
|
||||
className="aspect-video w-full"
|
||||
autoPlay
|
||||
loop
|
||||
controls
|
||||
/>
|
||||
|
||||
### 3. Document Distribution Settings
|
||||
|
||||
With the new **Document Distribution Settings**, you have greater control over how your documents are shared. Distribute communications via our automated emails and templates or take full control using our API and your own notifications infrastructure.
|
||||
|
||||
## 🔧 Other Improvements
|
||||
|
||||
- **Support for Gmail SMTP Service**: Adds support for using Gmail as your SMTP service provider.
|
||||
- **Certificate and Email Translations**: Added support for multiple languages in document certificates and emails, enhancing the experience for international users.
|
||||
- **Field Movement Fixes**: Resolved issues related to moving fields within documents, improving the document preparation experience.
|
||||
- **Docker Environment Update**: Improved Docker setup for smoother deployments and better environment consistency.
|
||||
- **Billing Access Improvements**: Users now have uninterrupted access to billing information, simplifying account management.
|
||||
- **Support Time Windows for 2FA Tokens**: Enhanced two-factor authentication by supporting time windows in 2FA tokens, improving flexibility.
|
||||
|
||||
## 💡 Recent Features
|
||||
|
||||
Don't forget to take advantage of these powerful features from our recent releases:
|
||||
|
||||
- **Signing Order**: Define the sequence in which recipients sign your documents for a structured signing process.
|
||||
- **Document Visibility Controls**: Manage who can view your documents and at what stages, offering greater privacy and control.
|
||||
- **Embedded Signing Experience**: Integrate the signing process directly into your own applications for a seamless user experience.
|
||||
|
||||
**👏 Thank You**
|
||||
|
||||
As always, we're grateful for the community's contributions and feedback. Your support helps us improve Documenso and deliver a top-notch open-source document signing solution.
|
||||
|
||||
We hope you enjoy the new features in Documenso v1.8.0. Happy signing!
|
||||
|
||||
---
|
||||
|
||||
# Documenso v1.7.1: Signing order and document visibility
|
||||
|
||||
We're excited to introduce Documenso v1.7.1, bringing you improved control over your document signing process. Here are the key updates:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/marketing",
|
||||
"version": "1.8.0-rc.2",
|
||||
"version": "1.8.1-rc.8",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
BIN
apps/marketing/public/changelog/v1_8_0/reject-document.mp4
Normal file
BIN
apps/marketing/public/changelog/v1_8_0/reject-document.mp4
Normal file
Binary file not shown.
BIN
apps/marketing/public/changelog/v1_8_0/team-global-settings.jpeg
Normal file
BIN
apps/marketing/public/changelog/v1_8_0/team-global-settings.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/web",
|
||||
"version": "1.8.0-rc.2",
|
||||
"version": "1.8.1-rc.8",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -28,6 +28,7 @@
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"colord": "^2.9.3",
|
||||
"cookie-es": "^1.0.0",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
@ -53,7 +54,7 @@
|
||||
"react-icons": "^4.11.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"recharts": "^2.7.2",
|
||||
"remeda": "^2.12.1",
|
||||
"remeda": "^2.17.3",
|
||||
"sharp": "0.32.6",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
|
||||
@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from 'react';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { ChevronDownIcon as CaretSortIcon, Loader } from 'lucide-react';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
export type SigningVolume = {
|
||||
id: number;
|
||||
name: string;
|
||||
signingVolume: number;
|
||||
createdAt: Date;
|
||||
planId: string;
|
||||
};
|
||||
|
||||
type LeaderboardTableProps = {
|
||||
signingVolume: SigningVolume[];
|
||||
totalPages: number;
|
||||
perPage: number;
|
||||
page: number;
|
||||
sortBy: 'name' | 'createdAt' | 'signingVolume';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export const LeaderboardTable = ({
|
||||
signingVolume,
|
||||
totalPages,
|
||||
perPage,
|
||||
page,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}: LeaderboardTableProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const [searchString, setSearchString] = useState('');
|
||||
const debouncedSearchString = useDebouncedValue(searchString, 1000);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: () => (
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={() => handleColumnSort('name')}
|
||||
>
|
||||
{_(msg`Name`)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
),
|
||||
accessorKey: 'name',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div>
|
||||
<a
|
||||
className="text-primary underline"
|
||||
href={`https://dashboard.stripe.com/subscriptions/${row.original.planId}`}
|
||||
target="_blank"
|
||||
>
|
||||
{row.getValue('name')}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 250,
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={() => handleColumnSort('signingVolume')}
|
||||
>
|
||||
{_(msg`Signing Volume`)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
),
|
||||
accessorKey: 'signingVolume',
|
||||
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
|
||||
},
|
||||
{
|
||||
header: () => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={() => handleColumnSort('createdAt')}
|
||||
>
|
||||
{_(msg`Created`)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||
},
|
||||
] satisfies DataTableColumnDef<SigningVolume>[];
|
||||
}, [sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
search: debouncedSearchString,
|
||||
page: 1,
|
||||
perPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchString]);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchString(e.target.value);
|
||||
};
|
||||
|
||||
const handleColumnSort = (column: 'name' | 'createdAt' | 'signingVolume') => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
sortBy: column,
|
||||
sortOrder: sortOrder === 'asc' ? 'desc' : 'asc',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="my-6 flex flex-row gap-4"
|
||||
type="text"
|
||||
placeholder={_(msg`Search by name or email`)}
|
||||
value={searchString}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={signingVolume}
|
||||
perPage={perPage}
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,25 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
|
||||
|
||||
type SearchOptions = {
|
||||
search: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
sortBy: 'name' | 'createdAt' | 'signingVolume';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export async function search({ search, page, perPage, sortBy, sortOrder }: SearchOptions) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const results = await getSigningVolume({ search, page, perPage, sortBy, sortOrder });
|
||||
|
||||
return results;
|
||||
}
|
||||
60
apps/web/src/app/(dashboard)/admin/leaderboard/page.tsx
Normal file
60
apps/web/src/app/(dashboard)/admin/leaderboard/page.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
|
||||
import { LeaderboardTable } from './data-table-leaderboard';
|
||||
import { search } from './fetch-leaderboard.actions';
|
||||
|
||||
type AdminLeaderboardProps = {
|
||||
searchParams?: {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
sortBy?: 'name' | 'createdAt' | 'signingVolume';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
};
|
||||
};
|
||||
|
||||
export default async function Leaderboard({ searchParams = {} }: AdminLeaderboardProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 10;
|
||||
const searchString = searchParams.search || '';
|
||||
const sortBy = searchParams.sortBy || 'signingVolume';
|
||||
const sortOrder = searchParams.sortOrder || 'desc';
|
||||
|
||||
const { leaderboard: signingVolume, totalPages } = await search({
|
||||
search: searchString,
|
||||
page,
|
||||
perPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Signing Volume</Trans>
|
||||
</h2>
|
||||
<div className="mt-8">
|
||||
<LeaderboardTable
|
||||
signingVolume={signingVolume}
|
||||
totalPages={totalPages}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,7 +6,7 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { BarChart3, FileStack, Settings, Users, Wallet2 } from 'lucide-react';
|
||||
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -80,6 +80,20 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/leaderboard">
|
||||
<Trophy className="mr-2 h-5 w-5" />
|
||||
<Trans>Leaderboard</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
|
||||
@ -146,7 +146,10 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
|
||||
<div className="flex flex-row justify-between truncate">
|
||||
<div>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
|
||||
@ -109,7 +109,10 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
|
||||
@ -121,7 +121,10 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
||||
|
||||
<div className="flex flex-col justify-between truncate sm:flex-row">
|
||||
<div>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
|
||||
@ -47,6 +47,8 @@ export const DeleteDocumentDialog = ({
|
||||
const { refreshLimits } = useLimits();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const deleteMessage = msg`delete`;
|
||||
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||
|
||||
@ -87,7 +89,7 @@ export const DeleteDocumentDialog = ({
|
||||
|
||||
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
setIsDeleteEnabled(event.target.value === _(msg`delete`));
|
||||
setIsDeleteEnabled(event.target.value === _(deleteMessage));
|
||||
};
|
||||
|
||||
return (
|
||||
@ -181,7 +183,7 @@ export const DeleteDocumentDialog = ({
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
placeholder={_(msg`Type 'delete' to confirm`)}
|
||||
placeholder={_(msg`Please type ${`'${_(deleteMessage)}'`} to confirm`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -12,9 +12,10 @@ import { createBillingPortal } from './create-billing-portal.action';
|
||||
|
||||
export type BillingPortalButtonProps = {
|
||||
buttonProps?: React.ComponentProps<typeof Button>;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
|
||||
export const BillingPortalButton = ({ buttonProps, children }: BillingPortalButtonProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -63,7 +64,7 @@ export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) =
|
||||
onClick={async () => handleFetchPortalUrl()}
|
||||
loading={isFetchingPortalUrl}
|
||||
>
|
||||
<Trans>Manage Subscription</Trans>
|
||||
{children || <Trans>Manage Subscription</Trans>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@ -41,7 +41,7 @@ export default async function BillingSettingsPage() {
|
||||
|
||||
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
|
||||
getSubscriptionsByUserId({ userId: user.id }),
|
||||
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.REGULAR }),
|
||||
getPricesByInterval({ plans: [STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.PLATFORM] }),
|
||||
getPrimaryAccountPlanPrices(),
|
||||
]);
|
||||
|
||||
@ -68,60 +68,74 @@ export default async function BillingSettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">
|
||||
<Trans>Billing</Trans>
|
||||
</h3>
|
||||
<div className="flex flex-row items-end justify-between">
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">
|
||||
<Trans>Billing</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
<p>
|
||||
<Trans>
|
||||
You are currently on the <span className="font-semibold">Free Plan</span>.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Todo: Translation */}
|
||||
{!isMissingOrInactiveOrFreePlan &&
|
||||
match(subscription.status)
|
||||
.with('ACTIVE', () => (
|
||||
<p>
|
||||
{subscriptionProduct ? (
|
||||
<span>
|
||||
You are currently subscribed to{' '}
|
||||
<span className="font-semibold">{subscriptionProduct.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>You currently have an active plan</span>
|
||||
)}
|
||||
|
||||
{subscription.periodEnd && (
|
||||
<span>
|
||||
{' '}
|
||||
which is set to{' '}
|
||||
{subscription.cancelAtPeriodEnd ? (
|
||||
<span>
|
||||
end on{' '}
|
||||
<span className="font-semibold">{i18n.date(subscription.periodEnd)}.</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
automatically renew on{' '}
|
||||
<span className="font-semibold">{i18n.date(subscription.periodEnd)}.</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
))
|
||||
.with('PAST_DUE', () => (
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
<p>
|
||||
<Trans>
|
||||
Your current plan is past due. Please update your payment information.
|
||||
You are currently on the <span className="font-semibold">Free Plan</span>.
|
||||
</Trans>
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
)}
|
||||
|
||||
{/* Todo: Translation */}
|
||||
{!isMissingOrInactiveOrFreePlan &&
|
||||
match(subscription.status)
|
||||
.with('ACTIVE', () => (
|
||||
<p>
|
||||
{subscriptionProduct ? (
|
||||
<span>
|
||||
You are currently subscribed to{' '}
|
||||
<span className="font-semibold">{subscriptionProduct.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>You currently have an active plan</span>
|
||||
)}
|
||||
|
||||
{subscription.periodEnd && (
|
||||
<span>
|
||||
{' '}
|
||||
which is set to{' '}
|
||||
{subscription.cancelAtPeriodEnd ? (
|
||||
<span>
|
||||
end on{' '}
|
||||
<span className="font-semibold">
|
||||
{i18n.date(subscription.periodEnd)}.
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
automatically renew on{' '}
|
||||
<span className="font-semibold">
|
||||
{i18n.date(subscription.periodEnd)}.
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
))
|
||||
.with('PAST_DUE', () => (
|
||||
<p>
|
||||
<Trans>
|
||||
Your current plan is past due. Please update your payment information.
|
||||
</Trans>
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
<BillingPortalButton>
|
||||
<Trans>Manage billing</Trans>
|
||||
</BillingPortalButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
@ -141,6 +141,23 @@ export const EditTemplateForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTypedSignature } =
|
||||
trpc.template.updateTemplateTypedSignatureSettings.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newData) => {
|
||||
utils.template.getTemplateWithDetailsById.setData(
|
||||
{
|
||||
id: initialTemplate.id,
|
||||
},
|
||||
(oldData) => ({
|
||||
...(oldData || initialTemplate),
|
||||
...newData,
|
||||
id: Number(newData.id),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||
try {
|
||||
await updateTemplateSettings({
|
||||
@ -211,6 +228,12 @@ export const EditTemplateForm = ({
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
await updateTypedSignature({
|
||||
templateId: template.id,
|
||||
teamId: team?.id,
|
||||
typedSignatureEnabled: data.typedSignatureEnabled,
|
||||
});
|
||||
|
||||
// Clear all field data from localStorage
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
@ -225,14 +248,13 @@ export const EditTemplateForm = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||
router.refresh();
|
||||
|
||||
router.push(templateRootPath);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while adding signers.`),
|
||||
description: _(msg`An error occurred while adding fields.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -301,6 +323,7 @@ export const EditTemplateForm = ({
|
||||
fields={fields}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
teamId={team?.id}
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
</Stepper>
|
||||
</DocumentFlowFormContainer>
|
||||
|
||||
@ -63,7 +63,10 @@ export const TemplateEditPageView = async ({ params, team }: TemplateEditPageVie
|
||||
<Trans>Template</Trans>
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={template.title}
|
||||
>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
|
||||
@ -73,7 +73,6 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
|
||||
const mockedDocumentMeta = templateMeta
|
||||
? {
|
||||
typedSignatureEnabled: false,
|
||||
...templateMeta,
|
||||
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
|
||||
documentId: 0,
|
||||
@ -89,7 +88,10 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
|
||||
<div className="flex flex-row justify-between truncate">
|
||||
<div>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={template.title}
|
||||
>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
@ -155,7 +157,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 px-4 text-sm ">
|
||||
<p className="text-muted-foreground mt-2 px-4 text-sm">
|
||||
<Trans>Manage and view template</Trans>
|
||||
</p>
|
||||
|
||||
|
||||
@ -209,11 +209,19 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`${signature.Signature?.signatureImageAsBase64}`}
|
||||
alt="Signature"
|
||||
className="max-h-12 max-w-full"
|
||||
/>
|
||||
{signature.Signature?.signatureImageAsBase64 && (
|
||||
<img
|
||||
src={`${signature.Signature?.signatureImageAsBase64}`}
|
||||
alt="Signature"
|
||||
className="max-h-12 max-w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
{signature.Signature?.typedSignature && (
|
||||
<p className="font-signature text-center text-sm">
|
||||
{signature.Signature?.typedSignature}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
|
||||
@ -12,7 +12,6 @@ import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
|
||||
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
|
||||
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
import { DirectTemplatePageView } from './direct-template';
|
||||
import { DirectTemplateAuthPageView } from './signing-auth-page';
|
||||
@ -72,8 +71,11 @@ export default async function TemplatesDirectPage({ params }: TemplatesDirectPag
|
||||
user={user}
|
||||
>
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{truncateTitle(template.title)}
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={template.title}
|
||||
>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
|
||||
|
||||
@ -102,9 +102,9 @@ export const SignDirectTemplateForm = ({
|
||||
created: new Date(),
|
||||
recipientId: 1,
|
||||
fieldId: 1,
|
||||
signatureImageAsBase64: value.value,
|
||||
typedSignature: null,
|
||||
};
|
||||
signatureImageAsBase64: value.value.startsWith('data:') ? value.value : null,
|
||||
typedSignature: value.value.startsWith('data:') ? null : value.value,
|
||||
} satisfies Signature;
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DATE) {
|
||||
|
||||
237
apps/web/src/app/(signing)/sign/[token]/auto-sign.tsx
Normal file
237
apps/web/src/app/(signing)/sign/[token]/auto-sign.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Plural, Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
|
||||
import { DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
|
||||
const AUTO_SIGNABLE_FIELD_TYPES: string[] = [
|
||||
FieldType.NAME,
|
||||
FieldType.INITIALS,
|
||||
FieldType.EMAIL,
|
||||
FieldType.DATE,
|
||||
];
|
||||
|
||||
// The action auth types that are not allowed to be auto signed
|
||||
//
|
||||
// Reasoning: If the action auth is a passkey or 2FA, it's likely that the owner of the document
|
||||
// intends on having the user manually sign due to the additional security measures employed for
|
||||
// other field types.
|
||||
const NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES: string[] = [
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
];
|
||||
|
||||
// The threshold for the number of fields that could be autosigned before displaying the dialog
|
||||
//
|
||||
// Reasoning: If there aren't that many fields, it's likely going to be easier to manually sign each one
|
||||
// while for larger documents with many fields it will be beneficial to sign away the boilerplate fields.
|
||||
const AUTO_SIGN_THRESHOLD = 5;
|
||||
|
||||
export type AutoSignProps = {
|
||||
recipient: Pick<Recipient, 'id' | 'token'>;
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
export const AutoSign = ({ recipient, fields }: AutoSignProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { email, fullName } = useRequiredSigningContext();
|
||||
const { derivedRecipientActionAuth } = useRequiredDocumentAuthContext();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const form = useForm();
|
||||
|
||||
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
|
||||
|
||||
const autoSignableFields = fields.filter((field) => {
|
||||
if (field.inserted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!AUTO_SIGNABLE_FIELD_TYPES.includes(field.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.type === FieldType.NAME && !fullName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.type === FieldType.INITIALS && !fullName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.type === FieldType.EMAIL && !email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const actionAuthAllowsAutoSign = !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(
|
||||
derivedRecipientActionAuth ?? '',
|
||||
);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const results = await Promise.allSettled(
|
||||
autoSignableFields.map(async (field) => {
|
||||
const value = match(field.type)
|
||||
.with(FieldType.NAME, () => fullName)
|
||||
.with(FieldType.INITIALS, () => extractInitials(fullName))
|
||||
.with(FieldType.EMAIL, () => email)
|
||||
.with(FieldType.DATE, () => new Date().toISOString())
|
||||
.otherwise(() => '');
|
||||
|
||||
const authOptions = match(derivedRecipientActionAuth)
|
||||
.with(DocumentAuth.ACCOUNT, () => ({
|
||||
type: DocumentAuth.ACCOUNT,
|
||||
}))
|
||||
.with(DocumentAuth.EXPLICIT_NONE, () => ({
|
||||
type: DocumentAuth.EXPLICIT_NONE,
|
||||
}))
|
||||
.with(null, () => undefined)
|
||||
.with(
|
||||
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH),
|
||||
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
|
||||
() => 'NOT_SUPPORTED' as const,
|
||||
)
|
||||
.exhaustive();
|
||||
|
||||
if (authOptions === 'NOT_SUPPORTED') {
|
||||
throw new Error('Action auth is not supported for auto signing');
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
throw new Error('No value to sign');
|
||||
}
|
||||
|
||||
return await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value,
|
||||
isBase64: false,
|
||||
authOptions,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
if (results.some((result) => result.status === 'rejected')) {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(
|
||||
msg`An error occurred while auto-signing the document, some fields may not be signed. Please review and manually sign any remaining fields.`,
|
||||
),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
|
||||
setOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
unsafe_useEffectOnce(() => {
|
||||
if (actionAuthAllowsAutoSign && autoSignableFields.length > AUTO_SIGN_THRESHOLD) {
|
||||
setOpen(true);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Automatically sign fields</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
<p>
|
||||
<Trans>
|
||||
When you sign a document, we can automatically fill in and sign the following fields
|
||||
using information that has already been provided. You can also manually sign or remove
|
||||
any automatically signed fields afterwards if you desire.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 flex list-inside list-disc flex-col gap-y-0.5">
|
||||
{AUTO_SIGNABLE_FIELD_TYPES.map((fieldType) => (
|
||||
<li key={fieldType}>
|
||||
<Trans>{_(FRIENDLY_FIELD_TYPE[fieldType as FieldType])}</Trans>
|
||||
<span className="pl-2 text-sm">
|
||||
(
|
||||
<Plural
|
||||
value={autoSignableFields.filter((f) => f.type === fieldType).length}
|
||||
one="1 matching field"
|
||||
other="# matching fields"
|
||||
/>
|
||||
)
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<SigningDisclosure className="mt-4" />
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogFooter className="flex w-full flex-1 flex-nowrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-w-[6rem]"
|
||||
loading={form.formState.isSubmitting || isPending}
|
||||
disabled={!autoSignableFields.length}
|
||||
>
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -24,8 +24,6 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
import { SigningAuthPageView } from '../signing-auth-page';
|
||||
import { ClaimAccount } from './claim-account';
|
||||
import { DocumentPreviewButton } from './document-preview-button';
|
||||
@ -61,8 +59,6 @@ export default async function CompletedSigningPage({
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
const [fields, recipient] = await Promise.all([
|
||||
@ -118,7 +114,9 @@ export default async function CompletedSigningPage({
|
||||
})}
|
||||
>
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncatedTitle}
|
||||
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
|
||||
{document.title}
|
||||
</span>
|
||||
</Badge>
|
||||
|
||||
{/* Card with recipient */}
|
||||
|
||||
@ -144,13 +144,13 @@ export const DateField = ({
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
|
||||
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
|
||||
<Trans>Date</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
{localDateString}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -178,7 +178,7 @@ export const DropdownField = ({
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200">
|
||||
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200 ">
|
||||
<Select value={localChoice} onValueChange={handleSelectItem}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
@ -190,7 +190,7 @@ export const DropdownField = ({
|
||||
)}
|
||||
>
|
||||
<SelectValue
|
||||
className="text-[clamp(0.625rem,1cqw,0.825rem)]"
|
||||
className="text-[clamp(0.425rem,25cqw,0.825rem)]"
|
||||
placeholder={`${_(msg`Select`)}`}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
@ -206,7 +206,7 @@ export const DropdownField = ({
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -122,13 +122,13 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
|
||||
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
|
||||
<Trans>Email</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -128,13 +128,13 @@ export const InitialsField = ({
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
|
||||
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
|
||||
<Trans>Initials</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -172,7 +172,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -252,14 +252,15 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-x-1 text-sm">
|
||||
<Hash className="h-4 w-4" /> {fieldDisplayName}
|
||||
<span className="flex items-center justify-center gap-x-1">
|
||||
<Hash className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />{' '}
|
||||
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{fieldDisplayName}</span>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
export type SigningContextValue = {
|
||||
fullName: string;
|
||||
@ -44,6 +44,12 @@ export const SigningProvider = ({
|
||||
const [email, setEmail] = useState(initialEmail || '');
|
||||
const [signature, setSignature] = useState(initialSignature || null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSignature) {
|
||||
setSignature(initialSignature);
|
||||
}
|
||||
}, [initialSignature]);
|
||||
|
||||
return (
|
||||
<SigningContext.Provider
|
||||
value={{
|
||||
|
||||
@ -14,7 +14,6 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
export type SignDialogProps = {
|
||||
isSubmitting: boolean;
|
||||
@ -36,7 +35,7 @@ export const SignDialog = ({
|
||||
disabled = false,
|
||||
}: SignDialogProps) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const truncatedTitle = truncateTitle(documentTitle);
|
||||
|
||||
const isComplete = fields.every((field) => field.inserted);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
@ -75,7 +74,13 @@ export const SignDialog = ({
|
||||
{role === RecipientRole.VIEWER && (
|
||||
<span>
|
||||
<Trans>
|
||||
You are about to complete viewing "{truncatedTitle}".
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
@ -83,7 +88,13 @@ export const SignDialog = ({
|
||||
{role === RecipientRole.SIGNER && (
|
||||
<span>
|
||||
<Trans>
|
||||
You are about to complete signing "{truncatedTitle}".
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete signing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
@ -91,7 +102,13 @@ export const SignDialog = ({
|
||||
{role === RecipientRole.APPROVER && (
|
||||
<span>
|
||||
<Trans>
|
||||
You are about to complete approving "{truncatedTitle}".
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete approving{' '}
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
"{documentTitle}"
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useTransition } from 'react';
|
||||
import { useLayoutEffect, useMemo, useRef, useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
@ -51,6 +51,10 @@ export const SignatureField = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const signatureRef = useRef<HTMLParagraphElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [fontSize, setFontSize] = useState(2);
|
||||
|
||||
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
||||
useRequiredSigningContext();
|
||||
|
||||
@ -108,6 +112,7 @@ export const SignatureField = ({
|
||||
actionTarget: field.type,
|
||||
});
|
||||
};
|
||||
|
||||
const onSign = async (authOptions?: TRecipientActionAuth, signature?: string) => {
|
||||
try {
|
||||
const value = signature || providedSignature;
|
||||
@ -117,11 +122,23 @@ export const SignatureField = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const isTypedSignature = !value.startsWith('data:image');
|
||||
|
||||
if (isTypedSignature && !typedSignatureEnabled) {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Typed signatures are not allowed. Please draw your signature.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: TSignFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value,
|
||||
isBase64: true,
|
||||
isBase64: !isTypedSignature,
|
||||
authOptions,
|
||||
};
|
||||
|
||||
@ -176,6 +193,41 @@ export const SignatureField = ({
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!signatureRef.current || !containerRef.current || !signature?.typedSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adjustTextSize = () => {
|
||||
const container = containerRef.current;
|
||||
const text = signatureRef.current;
|
||||
|
||||
if (!container || !text) {
|
||||
return;
|
||||
}
|
||||
|
||||
let size = 2;
|
||||
text.style.fontSize = `${size}rem`;
|
||||
|
||||
while (
|
||||
(text.scrollWidth > container.clientWidth || text.scrollHeight > container.clientHeight) &&
|
||||
size > 0.8
|
||||
) {
|
||||
size -= 0.1;
|
||||
text.style.fontSize = `${size}rem`;
|
||||
}
|
||||
|
||||
setFontSize(size);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(adjustTextSize);
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
adjustTextSize();
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [signature?.typedSignature]);
|
||||
|
||||
return (
|
||||
<SigningFieldContainer
|
||||
field={field}
|
||||
@ -191,7 +243,7 @@ export const SignatureField = ({
|
||||
)}
|
||||
|
||||
{state === 'empty' && (
|
||||
<p className="group-hover:text-primary font-signature text-muted-foreground text-xl duration-200 group-hover:text-yellow-300">
|
||||
<p className="group-hover:text-primary font-signature text-muted-foreground text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200 group-hover:text-yellow-300">
|
||||
<Trans>Signature</Trans>
|
||||
</p>
|
||||
)}
|
||||
@ -205,10 +257,15 @@ export const SignatureField = ({
|
||||
)}
|
||||
|
||||
{state === 'signed-text' && (
|
||||
<p className="font-signature text-muted-foreground dark:text-background text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
||||
{/* This optional chaining is intentional, we don't want to move the check into the condition above */}
|
||||
{signature?.typedSignature}
|
||||
</p>
|
||||
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
|
||||
<p
|
||||
ref={signatureRef}
|
||||
className="font-signature text-muted-foreground dark:text-background w-full overflow-hidden break-all text-center leading-tight duration-200"
|
||||
style={{ fontSize: `${fontSize}rem` }}
|
||||
>
|
||||
{signature?.typedSignature}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={showSignatureModal} onOpenChange={setShowSignatureModal}>
|
||||
|
||||
@ -22,6 +22,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||
|
||||
import { AutoSign } from './auto-sign';
|
||||
import { CheckboxField } from './checkbox-field';
|
||||
import { DateField } from './date-field';
|
||||
import { DropdownField } from './dropdown-field';
|
||||
@ -52,34 +53,62 @@ export const SigningPageView = ({
|
||||
}: SigningPageViewProps) => {
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
const shouldUseTeamDetails =
|
||||
document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false;
|
||||
|
||||
let senderName = document.User.name ?? '';
|
||||
let senderEmail = `(${document.User.email})`;
|
||||
|
||||
if (shouldUseTeamDetails) {
|
||||
senderName = document.team?.name ?? '';
|
||||
senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
||||
<div>
|
||||
<p
|
||||
className="text-muted-foreground truncate"
|
||||
title={document.User.name ? document.User.name : ''}
|
||||
>
|
||||
{document.User.name}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
<div className="max-w-[50ch]">
|
||||
<span className="text-muted-foreground truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<Trans>({document.User.email}) has invited you to view this document</Trans>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<Trans>({document.User.email}) has invited you to sign this document</Trans>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<Trans>({document.User.email}) has invited you to approve this document</Trans>
|
||||
))
|
||||
.with(RecipientRole.VIEWER, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to view this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to view this document</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.SIGNER, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to sign this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to sign this document</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.APPROVER, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to approve this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to approve this document</Trans>
|
||||
),
|
||||
)
|
||||
.otherwise(() => null)}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RejectDocumentDialog document={document} token={recipient.token} />
|
||||
@ -113,6 +142,8 @@ export const SigningPageView = ({
|
||||
|
||||
<DocumentReadOnlyFields fields={completedFields} />
|
||||
|
||||
<AutoSign recipient={recipient} fields={fields} />
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map((field) =>
|
||||
match(field.type)
|
||||
|
||||
@ -252,14 +252,16 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-x-1">
|
||||
<Type />
|
||||
{fieldDisplayName || <Trans>Text</Trans>}
|
||||
<Type className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />
|
||||
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
|
||||
{fieldDisplayName || <Trans>Text</Trans>}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
|
||||
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
{field.customText.length < 20
|
||||
? field.customText
|
||||
: field.customText.substring(0, 15) + '...'}
|
||||
|
||||
@ -52,13 +52,7 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro
|
||||
|
||||
<AvatarImageForm className="mb-8" team={team} user={session.user} />
|
||||
|
||||
<UpdateTeamForm
|
||||
teamId={team.id}
|
||||
teamName={team.name}
|
||||
teamUrl={team.url}
|
||||
documentVisibility={team.teamGlobalSettings?.documentVisibility}
|
||||
includeSenderDetails={team.teamGlobalSettings?.includeSenderDetails}
|
||||
/>
|
||||
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
|
||||
|
||||
<section className="mt-6 space-y-6">
|
||||
{(team.teamEmail || team.emailVerification) && (
|
||||
|
||||
@ -39,6 +39,8 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility),
|
||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||
includeSenderDetails: z.boolean(),
|
||||
typedSignatureEnabled: z.boolean(),
|
||||
includeSigningCertificate: z.boolean(),
|
||||
});
|
||||
|
||||
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
|
||||
@ -68,6 +70,8 @@ export const TeamDocumentPreferencesForm = ({
|
||||
? settings?.documentLanguage
|
||||
: 'en',
|
||||
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
||||
typedSignatureEnabled: settings?.typedSignatureEnabled ?? true,
|
||||
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
|
||||
},
|
||||
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
||||
});
|
||||
@ -76,7 +80,13 @@ export const TeamDocumentPreferencesForm = ({
|
||||
|
||||
const onSubmit = async (data: TTeamDocumentPreferencesFormSchema) => {
|
||||
try {
|
||||
const { documentVisibility, documentLanguage, includeSenderDetails } = data;
|
||||
const {
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
typedSignatureEnabled,
|
||||
} = data;
|
||||
|
||||
await updateTeamDocumentPreferences({
|
||||
teamId: team.id,
|
||||
@ -84,6 +94,8 @@ export const TeamDocumentPreferencesForm = ({
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
includeSenderDetails,
|
||||
typedSignatureEnabled,
|
||||
includeSigningCertificate,
|
||||
},
|
||||
});
|
||||
|
||||
@ -105,7 +117,7 @@ export const TeamDocumentPreferencesForm = ({
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl flex-col gap-y-4"
|
||||
className="flex h-full max-w-xl flex-col gap-y-6"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
@ -227,6 +239,67 @@ export const TeamDocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="typedSignatureEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Enable Typed Signature</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl className="block">
|
||||
<Switch
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls whether the recipients can sign the documents using a typed signature.
|
||||
Enable or disable the typed signature globally.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSigningCertificate"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Include the Signing Certificate in the Document</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl className="block">
|
||||
<Switch
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls whether the signing certificate will be included in the document when
|
||||
it is downloaded. The signing certificate can still be downloaded from the logs
|
||||
page separately.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Save</Trans>
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZCssVarsSchema } from './css-vars';
|
||||
|
||||
export const ZBaseEmbedDataSchema = z.object({
|
||||
darkModeDisabled: z.boolean().optional().default(false),
|
||||
css: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value || undefined),
|
||||
cssVars: ZCssVarsSchema.optional().default({}),
|
||||
});
|
||||
|
||||
@ -10,6 +10,7 @@ export type EmbedDocumentCompletedPageProps = {
|
||||
};
|
||||
|
||||
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
||||
console.log({ signature });
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
|
||||
59
apps/web/src/app/embed/css-vars.ts
Normal file
59
apps/web/src/app/embed/css-vars.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { colord } from 'colord';
|
||||
import { toSnakeCase } from 'remeda';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCssVarsSchema = z
|
||||
.object({
|
||||
background: z.string().optional().describe('Base background color'),
|
||||
foreground: z.string().optional().describe('Base text color'),
|
||||
muted: z.string().optional().describe('Muted/subtle background color'),
|
||||
mutedForeground: z.string().optional().describe('Muted/subtle text color'),
|
||||
popover: z.string().optional().describe('Popover/dropdown background color'),
|
||||
popoverForeground: z.string().optional().describe('Popover/dropdown text color'),
|
||||
card: z.string().optional().describe('Card background color'),
|
||||
cardBorder: z.string().optional().describe('Card border color'),
|
||||
cardBorderTint: z.string().optional().describe('Card border tint/highlight color'),
|
||||
cardForeground: z.string().optional().describe('Card text color'),
|
||||
fieldCard: z.string().optional().describe('Field card background color'),
|
||||
fieldCardBorder: z.string().optional().describe('Field card border color'),
|
||||
fieldCardForeground: z.string().optional().describe('Field card text color'),
|
||||
widget: z.string().optional().describe('Widget background color'),
|
||||
widgetForeground: z.string().optional().describe('Widget text color'),
|
||||
border: z.string().optional().describe('Default border color'),
|
||||
input: z.string().optional().describe('Input field border color'),
|
||||
primary: z.string().optional().describe('Primary action/button color'),
|
||||
primaryForeground: z.string().optional().describe('Primary action/button text color'),
|
||||
secondary: z.string().optional().describe('Secondary action/button color'),
|
||||
secondaryForeground: z.string().optional().describe('Secondary action/button text color'),
|
||||
accent: z.string().optional().describe('Accent/highlight color'),
|
||||
accentForeground: z.string().optional().describe('Accent/highlight text color'),
|
||||
destructive: z.string().optional().describe('Destructive/danger action color'),
|
||||
destructiveForeground: z.string().optional().describe('Destructive/danger text color'),
|
||||
ring: z.string().optional().describe('Focus ring color'),
|
||||
radius: z.string().optional().describe('Border radius size in REM units'),
|
||||
warning: z.string().optional().describe('Warning/alert color'),
|
||||
})
|
||||
.describe('Custom CSS variables for theming');
|
||||
|
||||
export type TCssVarsSchema = z.infer<typeof ZCssVarsSchema>;
|
||||
|
||||
export const toNativeCssVars = (vars: TCssVarsSchema) => {
|
||||
const cssVars: Record<string, string> = {};
|
||||
|
||||
const { radius, ...colorVars } = vars;
|
||||
|
||||
for (const [key, value] of Object.entries(colorVars)) {
|
||||
if (value) {
|
||||
const color = colord(value);
|
||||
const { h, s, l } = color.toHsl();
|
||||
|
||||
cssVars[`--${toSnakeCase(key)}`] = `${h} ${s} ${l}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (radius) {
|
||||
cssVars[`--radius`] = `${radius}`;
|
||||
}
|
||||
|
||||
return cssVars;
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
@ -14,7 +14,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
|
||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@documenso/prisma/client';
|
||||
import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@ -38,6 +38,7 @@ import { Logo } from '~/components/branding/logo';
|
||||
import { EmbedClientLoading } from '../../client-loading';
|
||||
import { EmbedDocumentCompleted } from '../../completed';
|
||||
import { EmbedDocumentFields } from '../../document-fields';
|
||||
import { injectCss } from '../../util';
|
||||
import { ZDirectTemplateEmbedDataSchema } from './schema';
|
||||
|
||||
export type EmbedDirectTemplateClientPageProps = {
|
||||
@ -47,6 +48,8 @@ export type EmbedDirectTemplateClientPageProps = {
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
hidePoweredBy?: boolean;
|
||||
isPlatformOrEnterprise?: boolean;
|
||||
};
|
||||
|
||||
export const EmbedDirectTemplateClientPage = ({
|
||||
@ -56,6 +59,8 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
recipient,
|
||||
fields,
|
||||
metadata,
|
||||
hidePoweredBy = false,
|
||||
isPlatformOrEnterprise = false,
|
||||
}: EmbedDirectTemplateClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@ -108,9 +113,9 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
created: new Date(),
|
||||
recipientId: 1,
|
||||
fieldId: 1,
|
||||
signatureImageAsBase64: payload.value,
|
||||
typedSignature: null,
|
||||
};
|
||||
signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null,
|
||||
typedSignature: payload.value.startsWith('data:') ? null : payload.value,
|
||||
} satisfies Signature;
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DATE) {
|
||||
@ -249,7 +254,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
try {
|
||||
@ -264,6 +269,17 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
setFullName(data.name);
|
||||
setIsNameLocked(!!data.lockName);
|
||||
}
|
||||
|
||||
if (data.darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (isPlatformOrEnterprise) {
|
||||
injectCss({
|
||||
css: data.css,
|
||||
cssVars: data.cssVars,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
@ -296,8 +312,8 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
fieldId: 1,
|
||||
recipientId: 1,
|
||||
created: new Date(),
|
||||
typedSignature: null,
|
||||
signatureImageAsBase64: signature,
|
||||
signatureImageAsBase64: signature?.startsWith('data:') ? signature : null,
|
||||
typedSignature: signature?.startsWith('data:') ? null : signature,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@ -452,10 +468,12 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<Logo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<Logo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,8 +2,11 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
@ -51,6 +54,14 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
||||
documentAuth: template.authOptions,
|
||||
});
|
||||
|
||||
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
|
||||
isDocumentPlatform(template),
|
||||
isUserEnterprise({
|
||||
userId: template.userId,
|
||||
teamId: template.teamId ?? undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
const isAccessAuthValid = match(derivedRecipientAccessAuth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
|
||||
.with(null, () => true)
|
||||
@ -72,6 +83,12 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
||||
|
||||
const fields = template.Field.filter((field) => field.recipientId === directTemplateRecipientId);
|
||||
|
||||
const team = template.teamId
|
||||
? await getTeamById({ teamId: template.teamId, userId: template.userId }).catch(() => null)
|
||||
: null;
|
||||
|
||||
const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false;
|
||||
|
||||
return (
|
||||
<SigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
|
||||
<DocumentAuthProvider
|
||||
@ -86,6 +103,8 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
metadata={template.templateMeta}
|
||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||
/>
|
||||
</DocumentAuthProvider>
|
||||
</SigningProvider>
|
||||
|
||||
@ -58,6 +58,7 @@ export const EmbedDocumentFields = ({
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@ -28,6 +28,7 @@ import { Logo } from '~/components/branding/logo';
|
||||
import { EmbedClientLoading } from '../../client-loading';
|
||||
import { EmbedDocumentCompleted } from '../../completed';
|
||||
import { EmbedDocumentFields } from '../../document-fields';
|
||||
import { injectCss } from '../../util';
|
||||
import { ZSignDocumentEmbedDataSchema } from './schema';
|
||||
|
||||
export type EmbedSignDocumentClientPageProps = {
|
||||
@ -38,6 +39,8 @@ export type EmbedSignDocumentClientPageProps = {
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
isCompleted?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
isPlatformOrEnterprise?: boolean;
|
||||
};
|
||||
|
||||
export const EmbedSignDocumentClientPage = ({
|
||||
@ -48,6 +51,8 @@ export const EmbedSignDocumentClientPage = ({
|
||||
fields,
|
||||
metadata,
|
||||
isCompleted,
|
||||
hidePoweredBy = false,
|
||||
isPlatformOrEnterprise = false,
|
||||
}: EmbedSignDocumentClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@ -131,7 +136,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
try {
|
||||
@ -144,6 +149,17 @@ export const EmbedSignDocumentClientPage = ({
|
||||
// Since a recipient can be provided a name we can lock it without requiring
|
||||
// a to be provided by the parent application, unlike direct templates.
|
||||
setIsNameLocked(!!data.lockName);
|
||||
|
||||
if (data.darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (isPlatformOrEnterprise) {
|
||||
injectCss({
|
||||
css: data.css,
|
||||
cssVars: data.cssVars,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
@ -176,8 +192,8 @@ export const EmbedSignDocumentClientPage = ({
|
||||
fieldId: 1,
|
||||
recipientId: 1,
|
||||
created: new Date(),
|
||||
typedSignature: null,
|
||||
signatureImageAsBase64: signature,
|
||||
signatureImageAsBase64: signature?.startsWith('data:') ? signature : null,
|
||||
typedSignature: signature?.startsWith('data:') ? null : signature,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@ -202,7 +218,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
@ -325,10 +341,12 @@ export const EmbedSignDocumentClientPage = ({
|
||||
<EmbedDocumentFields recipient={recipient} fields={fields} metadata={metadata} />
|
||||
</div>
|
||||
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<Logo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<Logo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,11 +2,14 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
@ -56,6 +59,14 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
||||
return <EmbedPaywall />;
|
||||
}
|
||||
|
||||
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
|
||||
isDocumentPlatform(document),
|
||||
isUserEnterprise({
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
@ -69,11 +80,17 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
||||
return (
|
||||
<EmbedAuthenticateView
|
||||
email={user?.email || recipient.email}
|
||||
returnTo={`/embed/direct/${token}`}
|
||||
returnTo={`/embed/sign/${token}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const team = document.teamId
|
||||
? await getTeamById({ teamId: document.teamId, userId: document.userId }).catch(() => null)
|
||||
: null;
|
||||
|
||||
const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false;
|
||||
|
||||
return (
|
||||
<SigningProvider
|
||||
email={recipient.email}
|
||||
@ -93,6 +110,8 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
||||
fields={fields}
|
||||
metadata={document.documentMeta}
|
||||
isCompleted={document.status === DocumentStatus.COMPLETED}
|
||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||
/>
|
||||
</DocumentAuthProvider>
|
||||
</SigningProvider>
|
||||
|
||||
20
apps/web/src/app/embed/util.ts
Normal file
20
apps/web/src/app/embed/util.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { type TCssVarsSchema, toNativeCssVars } from './css-vars';
|
||||
|
||||
export const injectCss = (options: { css?: string; cssVars?: TCssVarsSchema }) => {
|
||||
const { css, cssVars } = options;
|
||||
|
||||
if (css) {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = css;
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
if (cssVars) {
|
||||
const nativeVars = toNativeCssVars(cssVars);
|
||||
|
||||
for (const [key, value] of Object.entries(nativeVars)) {
|
||||
document.documentElement.style.setProperty(key, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -25,8 +25,6 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
|
||||
zIndexClass = ZIndexes[zIndex] ?? '';
|
||||
}
|
||||
|
||||
console.log({ type, fallbackText });
|
||||
|
||||
switch (type) {
|
||||
case RecipientStatusType.UNSIGNED:
|
||||
classes = 'bg-dawn-200 text-dawn-900';
|
||||
|
||||
@ -6,22 +6,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { DocumentVisibility } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
import {
|
||||
DocumentVisibilitySelect,
|
||||
DocumentVisibilityTooltip,
|
||||
} from '@documenso/ui/components/document/document-visibility-select';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@ -37,29 +29,17 @@ export type UpdateTeamDialogProps = {
|
||||
teamId: number;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
documentVisibility?: DocumentVisibility;
|
||||
includeSenderDetails?: boolean;
|
||||
};
|
||||
|
||||
const ZUpdateTeamFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({
|
||||
name: true,
|
||||
url: true,
|
||||
documentVisibility: true,
|
||||
includeSenderDetails: true,
|
||||
});
|
||||
|
||||
type TUpdateTeamFormSchema = z.infer<typeof ZUpdateTeamFormSchema>;
|
||||
|
||||
export const UpdateTeamForm = ({
|
||||
teamId,
|
||||
teamName,
|
||||
teamUrl,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
}: UpdateTeamDialogProps) => {
|
||||
export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const email = session?.user?.email;
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -68,36 +48,17 @@ export const UpdateTeamForm = ({
|
||||
defaultValues: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation();
|
||||
const includeSenderDetailsCheck = form.watch('includeSenderDetails');
|
||||
|
||||
const mapVisibilityToRole = (visibility: DocumentVisibility): DocumentVisibility =>
|
||||
match(visibility)
|
||||
.with(DocumentVisibility.ADMIN, () => DocumentVisibility.ADMIN)
|
||||
.with(DocumentVisibility.MANAGER_AND_ABOVE, () => DocumentVisibility.MANAGER_AND_ABOVE)
|
||||
.otherwise(() => DocumentVisibility.EVERYONE);
|
||||
|
||||
const currentVisibilityRole = mapVisibilityToRole(
|
||||
documentVisibility ?? DocumentVisibility.EVERYONE,
|
||||
);
|
||||
const onFormSubmit = async ({
|
||||
name,
|
||||
url,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
}: TUpdateTeamFormSchema) => {
|
||||
const onFormSubmit = async ({ name, url }: TUpdateTeamFormSchema) => {
|
||||
try {
|
||||
await updateTeam({
|
||||
data: {
|
||||
name,
|
||||
url,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
},
|
||||
teamId,
|
||||
});
|
||||
@ -111,8 +72,6 @@ export const UpdateTeamForm = ({
|
||||
form.reset({
|
||||
name,
|
||||
url,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
});
|
||||
|
||||
if (url !== teamUrl) {
|
||||
@ -186,68 +145,6 @@ export const UpdateTeamForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentVisibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="mt-4 flex flex-row items-center">
|
||||
<Trans>Default Document Visibility</Trans>
|
||||
<DocumentVisibilityTooltip />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<DocumentVisibilitySelect
|
||||
currentMemberRole={currentVisibilityRole}
|
||||
isTeamSettings={true}
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mb-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSenderDetails"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="mt-6 flex flex-row items-center gap-4">
|
||||
<FormLabel>
|
||||
<Trans>Send on Behalf of Team</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5"
|
||||
checkClassName="text-white"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
{includeSenderDetailsCheck ? (
|
||||
<blockquote className="text-foreground/50 text-xs italic">
|
||||
<Trans>
|
||||
"{email}" on behalf of "{teamName}" has invited you to sign "example
|
||||
document".
|
||||
</Trans>
|
||||
</blockquote>
|
||||
) : (
|
||||
<blockquote className="text-foreground/50 text-xs italic">
|
||||
<Trans>"{teamUrl}" has invited you to sign "example document".</Trans>
|
||||
</blockquote>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<AnimatePresence>
|
||||
{form.formState.isDirty && (
|
||||
|
||||
@ -138,6 +138,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
containerClassName={cn('rounded-lg border bg-background')}
|
||||
defaultValue={user.signature ?? undefined}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
allowTypedSignature={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@ -78,13 +78,14 @@ async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||
if (req.nextUrl.pathname.startsWith('/embed')) {
|
||||
const res = NextResponse.next();
|
||||
|
||||
const origin = req.headers.get('Origin') ?? '*';
|
||||
|
||||
// Allow third parties to iframe the document.
|
||||
res.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.headers.set('Access-Control-Allow-Origin', '*');
|
||||
res.headers.set('Content-Security-Policy', 'frame-ancestors *');
|
||||
res.headers.set('Access-Control-Allow-Origin', origin);
|
||||
res.headers.set('Content-Security-Policy', `frame-ancestors ${origin}`);
|
||||
res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
res.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
res.headers.set('X-Frame-Options', 'ALLOW-ALL');
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildLogger } from '@documenso/lib/utils/logger';
|
||||
import * as trpcNext from '@documenso/trpc/server/adapters/next';
|
||||
import { createTrpcContext } from '@documenso/trpc/server/context';
|
||||
import { appRouter } from '@documenso/trpc/server/router';
|
||||
@ -11,7 +13,44 @@ export const config = {
|
||||
},
|
||||
};
|
||||
|
||||
const logger = buildLogger();
|
||||
|
||||
export default trpcNext.createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
||||
onError(opts) {
|
||||
const { error, path } = opts;
|
||||
|
||||
// Currently trialing changes with template and team router only.
|
||||
if (!path || (!path.startsWith('template') && !path.startsWith('team'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always log the error for now.
|
||||
console.error(error);
|
||||
|
||||
const appError = AppError.parseError(error.cause || error);
|
||||
|
||||
const isAppError = error.cause instanceof AppError;
|
||||
|
||||
// Only log AppErrors that are explicitly set to 500 or the error code
|
||||
// is in the errorCodesToAlertOn list.
|
||||
const isLoggableAppError =
|
||||
isAppError && (appError.statusCode === 500 || errorCodesToAlertOn.includes(appError.code));
|
||||
|
||||
// Only log TRPC errors that are in the `errorCodesToAlertOn` list and is
|
||||
// not an AppError.
|
||||
const isLoggableTrpcError = !isAppError && errorCodesToAlertOn.includes(error.code);
|
||||
|
||||
if (isLoggableAppError || isLoggableTrpcError) {
|
||||
logger.error(error, {
|
||||
method: path,
|
||||
context: {
|
||||
appError: AppError.toJSON(appError),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const errorCodesToAlertOn = [AppErrorCode.UNKNOWN_ERROR, 'INTERNAL_SERVER_ERROR'];
|
||||
|
||||
932
package-lock.json
generated
932
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.8.0-rc.2",
|
||||
"version": "1.8.1-rc.8",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"build:web": "turbo run build --filter=@documenso/web",
|
||||
@ -52,7 +52,7 @@
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"playwright": "1.43.0",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier": "^3.3.3",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
},
|
||||
|
||||
@ -168,6 +168,9 @@ export const ApiContractV1 = c.router(
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Send a document for signing',
|
||||
// I'm aware this should be in the variable itself, which it is, however it's difficult for users to find in our current UI.
|
||||
description:
|
||||
'Notes\n\n`sendEmail` - Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links. Defaults to true',
|
||||
},
|
||||
|
||||
resendDocument: {
|
||||
|
||||
@ -244,18 +244,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
}
|
||||
|
||||
const dateFormat = body.meta.dateFormat
|
||||
? DATE_FORMATS.find((format) => format.label === body.meta.dateFormat)
|
||||
? DATE_FORMATS.find((format) => format.value === body.meta.dateFormat)
|
||||
: DATE_FORMATS.find((format) => format.value === DEFAULT_DOCUMENT_DATE_FORMAT);
|
||||
const timezone = body.meta.timezone
|
||||
? TIME_ZONES.find((tz) => tz === body.meta.timezone)
|
||||
: DEFAULT_DOCUMENT_TIME_ZONE;
|
||||
|
||||
const isDateFormatValid = body.meta.dateFormat
|
||||
? DATE_FORMATS.some((format) => format.label === dateFormat?.label)
|
||||
: true;
|
||||
const isTimeZoneValid = body.meta.timezone ? TIME_ZONES.includes(String(timezone)) : true;
|
||||
|
||||
if (!isDateFormatValid) {
|
||||
if (body.meta.dateFormat && !dateFormat) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
@ -264,6 +256,12 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
};
|
||||
}
|
||||
|
||||
const timezone = body.meta.timezone
|
||||
? TIME_ZONES.find((tz) => tz === body.meta.timezone)
|
||||
: DEFAULT_DOCUMENT_TIME_ZONE;
|
||||
|
||||
const isTimeZoneValid = body.meta.timezone ? TIME_ZONES.includes(String(timezone)) : true;
|
||||
|
||||
if (!isTimeZoneValid) {
|
||||
return {
|
||||
status: 400,
|
||||
@ -302,6 +300,9 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
redirectUrl: body.meta.redirectUrl,
|
||||
signingOrder: body.meta.signingOrder,
|
||||
language: body.meta.language,
|
||||
typedSignatureEnabled: body.meta.typedSignatureEnabled,
|
||||
distributionMethod: body.meta.distributionMethod,
|
||||
emailSettings: body.meta.emailSettings,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@ import { z } from 'zod';
|
||||
|
||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||
import '@documenso/lib/constants/time-zones';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { ZUrlSchema } from '@documenso/lib/schemas/common';
|
||||
import {
|
||||
@ -11,9 +10,11 @@ import {
|
||||
ZDocumentActionAuthTypesSchema,
|
||||
ZRecipientActionAuthTypesSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
DocumentDataType,
|
||||
DocumentDistributionMethod,
|
||||
DocumentSigningOrder,
|
||||
FieldType,
|
||||
ReadStatus,
|
||||
@ -67,7 +68,10 @@ export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocume
|
||||
|
||||
export const ZSendDocumentForSigningMutationSchema = z
|
||||
.object({
|
||||
sendEmail: z.boolean().optional().default(true),
|
||||
sendEmail: z.boolean().optional().default(true).openapi({
|
||||
description:
|
||||
'Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links.',
|
||||
}),
|
||||
})
|
||||
.or(z.literal('').transform(() => ({ sendEmail: true })));
|
||||
|
||||
@ -129,8 +133,13 @@ export const ZCreateDocumentMutationSchema = z.object({
|
||||
redirectUrl: z.string(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
||||
typedSignatureEnabled: z.boolean().optional().default(true),
|
||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
})
|
||||
.partial(),
|
||||
.partial()
|
||||
.optional()
|
||||
.default({}),
|
||||
authOptions: z
|
||||
.object({
|
||||
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
|
||||
@ -223,14 +232,14 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
|
||||
|
||||
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
externalId: z.string().nullish(),
|
||||
externalId: z.string().optional(),
|
||||
recipients: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().min(1),
|
||||
signingOrder: z.number().nullish(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
@ -249,8 +258,11 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
timezone: z.string(),
|
||||
dateFormat: z.string(),
|
||||
redirectUrl: ZUrlSchema,
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
language: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
||||
typedSignatureEnabled: z.boolean(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema,
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
|
||||
@ -0,0 +1,271 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe('Signing Certificate Tests', () => {
|
||||
test('individual document should always include signing certificate', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['signer@example.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const documentData = await prisma.documentData
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.documentDataId,
|
||||
},
|
||||
})
|
||||
.then(async (data) => getFile(data));
|
||||
|
||||
const originalPdf = await PDFDocument.load(documentData);
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Sign the document
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
const canvas = page.locator('canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
for (const field of recipient.Field) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${recipient.token}/complete`);
|
||||
|
||||
await expect(async () => {
|
||||
const { status } = await getDocumentByToken({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
expect(status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass();
|
||||
|
||||
// Get the completed document
|
||||
const completedDocument = await prisma.document.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
include: { documentData: true },
|
||||
});
|
||||
|
||||
const completedDocumentData = await getFile(completedDocument.documentData);
|
||||
|
||||
// Load the PDF and check number of pages
|
||||
const pdfDoc = await PDFDocument.load(completedDocumentData);
|
||||
|
||||
expect(pdfDoc.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
|
||||
});
|
||||
|
||||
test('team document with signing certificate enabled should include certificate', async ({
|
||||
page,
|
||||
}) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: team.owner,
|
||||
recipients: ['signer@example.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.teamGlobalSettings.create({
|
||||
data: {
|
||||
teamId: team.id,
|
||||
includeSigningCertificate: true,
|
||||
},
|
||||
});
|
||||
|
||||
const documentData = await prisma.documentData
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.documentDataId,
|
||||
},
|
||||
})
|
||||
.then(async (data) => getFile(data));
|
||||
|
||||
const originalPdf = await PDFDocument.load(documentData);
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Sign the document
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
const canvas = page.locator('canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
for (const field of recipient.Field) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${recipient.token}/complete`);
|
||||
|
||||
await expect(async () => {
|
||||
const { status } = await getDocumentByToken({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
expect(status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass();
|
||||
|
||||
// Get the completed document
|
||||
const completedDocument = await prisma.document.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
include: { documentData: true },
|
||||
});
|
||||
|
||||
const completedDocumentData = await getFile(completedDocument.documentData);
|
||||
|
||||
// Load the PDF and check number of pages
|
||||
const completedPdf = await PDFDocument.load(completedDocumentData);
|
||||
|
||||
expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
|
||||
});
|
||||
|
||||
test('team document with signing certificate disabled should not include certificate', async ({
|
||||
page,
|
||||
}) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: team.owner,
|
||||
recipients: ['signer@example.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.teamGlobalSettings.create({
|
||||
data: {
|
||||
teamId: team.id,
|
||||
includeSigningCertificate: false,
|
||||
},
|
||||
});
|
||||
|
||||
const documentData = await prisma.documentData
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.documentDataId,
|
||||
},
|
||||
})
|
||||
.then(async (data) => getFile(data));
|
||||
|
||||
const originalPdf = await PDFDocument.load(documentData);
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Sign the document
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
const canvas = page.locator('canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
for (const field of recipient.Field) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${recipient.token}/complete`);
|
||||
|
||||
await expect(async () => {
|
||||
const { status } = await getDocumentByToken({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
expect(status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass();
|
||||
|
||||
// Get the completed document
|
||||
const completedDocument = await prisma.document.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
include: { documentData: true },
|
||||
});
|
||||
|
||||
const completedDocumentData = await getFile(completedDocument.documentData);
|
||||
|
||||
// Load the PDF and check number of pages
|
||||
const completedPdf = await PDFDocument.load(completedDocumentData);
|
||||
|
||||
expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount());
|
||||
});
|
||||
|
||||
test('team can toggle signing certificate setting', async ({ page }) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
redirectPath: `/t/${team.url}/settings/preferences`,
|
||||
});
|
||||
|
||||
// Toggle signing certificate setting
|
||||
await page.getByLabel('Include the Signing Certificate in the Document').click();
|
||||
await page.getByRole('button', { name: /Save/ }).first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify the setting was saved
|
||||
const updatedTeam = await prisma.team.findFirstOrThrow({
|
||||
where: { id: team.id },
|
||||
include: { teamGlobalSettings: true },
|
||||
});
|
||||
|
||||
expect(updatedTeam.teamGlobalSettings?.includeSigningCertificate).toBe(false);
|
||||
|
||||
// Toggle the setting back to true
|
||||
await page.getByLabel('Include the Signing Certificate in the Document').click();
|
||||
await page.getByRole('button', { name: /Save/ }).first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify the setting was saved
|
||||
const updatedTeam2 = await prisma.team.findFirstOrThrow({
|
||||
where: { id: team.id },
|
||||
include: { teamGlobalSettings: true },
|
||||
});
|
||||
|
||||
expect(updatedTeam2.teamGlobalSettings?.includeSigningCertificate).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -17,19 +17,17 @@ test('[TEAMS]: update the default document visibility in the team global setting
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
redirectPath: `/t/${team.url}/settings/preferences`,
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
// !: Brittle selector
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('option', { name: 'Admin' }).click();
|
||||
await page.getByRole('button', { name: 'Update team' }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||
|
||||
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast.getByText('Success', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
toast.getByText('Your team has been successfully updated.', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(toast.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: update the sender details in the team global settings', async ({ page }) => {
|
||||
@ -41,7 +39,7 @@ test('[TEAMS]: update the sender details in the team global settings', async ({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
redirectPath: `/t/${team.url}/settings/preferences`,
|
||||
});
|
||||
|
||||
const checkbox = page.getByLabel('Send on Behalf of Team');
|
||||
@ -49,14 +47,11 @@ test('[TEAMS]: update the sender details in the team global settings', async ({
|
||||
|
||||
await expect(checkbox).toBeChecked();
|
||||
|
||||
await page.getByRole('button', { name: 'Update team' }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||
|
||||
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast.getByText('Success', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
toast.getByText('Your team has been successfully updated.', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(toast.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
|
||||
await expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
@ -7,15 +7,17 @@
|
||||
"scripts": {
|
||||
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
|
||||
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
|
||||
"test:e2e": "NODE_OPTIONS=--experimental-require-module start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
|
||||
"test:e2e": "NODE_OPTIONS=--experimental-require-module start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.18.1",
|
||||
"@types/node": "^20.8.2",
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/web": "*"
|
||||
"@documenso/web": "*",
|
||||
"pdf-lib": "^1.17.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.0.1"
|
||||
|
||||
@ -9,6 +9,7 @@ export const getDocumentRelatedPrices = async () => {
|
||||
return await getPricesByPlan([
|
||||
STRIPE_PLAN_TYPE.REGULAR,
|
||||
STRIPE_PLAN_TYPE.COMMUNITY,
|
||||
STRIPE_PLAN_TYPE.PLATFORM,
|
||||
STRIPE_PLAN_TYPE.ENTERPRISE,
|
||||
]);
|
||||
};
|
||||
|
||||
13
packages/ee/server-only/stripe/get-platform-plan-prices.ts
Normal file
13
packages/ee/server-only/stripe/get-platform-plan-prices.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
export const getPlatformPlanPrices = async () => {
|
||||
return await getPricesByPlan(STRIPE_PLAN_TYPE.PLATFORM);
|
||||
};
|
||||
|
||||
export const getPlatformPlanPriceIds = async () => {
|
||||
const prices = await getPlatformPlanPrices();
|
||||
|
||||
return prices.map((price) => price.id);
|
||||
};
|
||||
@ -12,10 +12,10 @@ export type GetPricesByIntervalOptions = {
|
||||
/**
|
||||
* Filter products by their meta 'plan' attribute.
|
||||
*/
|
||||
plan?: STRIPE_PLAN_TYPE.COMMUNITY | STRIPE_PLAN_TYPE.REGULAR;
|
||||
plans?: STRIPE_PLAN_TYPE[];
|
||||
};
|
||||
|
||||
export const getPricesByInterval = async ({ plan }: GetPricesByIntervalOptions = {}) => {
|
||||
export const getPricesByInterval = async ({ plans }: GetPricesByIntervalOptions = {}) => {
|
||||
let { data: prices } = await stripe.prices.search({
|
||||
query: `active:'true' type:'recurring'`,
|
||||
expand: ['data.product'],
|
||||
@ -27,7 +27,8 @@ export const getPricesByInterval = async ({ plan }: GetPricesByIntervalOptions =
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const product = price.product as Stripe.Product;
|
||||
|
||||
const filter = !plan || product.metadata?.plan === plan;
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const filter = !plans || plans.includes(product.metadata?.plan as STRIPE_PLAN_TYPE);
|
||||
|
||||
// Filter out prices for products that are not active.
|
||||
return product.active && filter;
|
||||
|
||||
@ -9,6 +9,7 @@ export const getPrimaryAccountPlanPrices = async () => {
|
||||
return await getPricesByPlan([
|
||||
STRIPE_PLAN_TYPE.REGULAR,
|
||||
STRIPE_PLAN_TYPE.COMMUNITY,
|
||||
STRIPE_PLAN_TYPE.PLATFORM,
|
||||
STRIPE_PLAN_TYPE.ENTERPRISE,
|
||||
]);
|
||||
};
|
||||
|
||||
@ -13,7 +13,9 @@ export const getTeamPrices = async () => {
|
||||
const priceIds = prices.map((price) => price.id);
|
||||
|
||||
if (!monthlyPrice || !yearlyPrice) {
|
||||
throw new AppError('INVALID_CONFIG', 'Missing monthly or yearly price');
|
||||
throw new AppError('INVALID_CONFIG', {
|
||||
message: 'Missing monthly or yearly price',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -6,7 +6,11 @@ import { getPricesByPlan } from './get-prices-by-plan';
|
||||
* Returns the Stripe prices of items that affect the amount of teams a user can create.
|
||||
*/
|
||||
export const getTeamRelatedPrices = async () => {
|
||||
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
|
||||
return await getPricesByPlan([
|
||||
STRIPE_PLAN_TYPE.COMMUNITY,
|
||||
STRIPE_PLAN_TYPE.PLATFORM,
|
||||
STRIPE_PLAN_TYPE.ENTERPRISE,
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -43,7 +43,9 @@ export const transferTeamSubscription = async ({
|
||||
const teamCustomerId = team.customerId;
|
||||
|
||||
if (!teamCustomerId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.');
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Missing customer ID.',
|
||||
});
|
||||
}
|
||||
|
||||
const [teamRelatedPlanPriceIds, teamSeatPrices] = await Promise.all([
|
||||
|
||||
61
packages/ee/server-only/util/is-document-platform.ts
Normal file
61
packages/ee/server-only/util/is-document-platform.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Document, Subscription } from '@documenso/prisma/client';
|
||||
|
||||
import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices';
|
||||
|
||||
export type IsDocumentPlatformOptions = Pick<Document, 'id' | 'userId' | 'teamId'>;
|
||||
|
||||
/**
|
||||
* Whether the user is platform, or has permission to use platform features on
|
||||
* behalf of their team.
|
||||
*
|
||||
* It is assumed that the provided user is part of the provided team.
|
||||
*/
|
||||
export const isDocumentPlatform = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: IsDocumentPlatformOptions): Promise<boolean> => {
|
||||
let subscriptions: Subscription[] = [];
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
subscriptions = await prisma.team
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
select: {
|
||||
owner: {
|
||||
include: {
|
||||
Subscription: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((team) => team.owner.Subscription);
|
||||
} else {
|
||||
subscriptions = await prisma.user
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
Subscription: true,
|
||||
},
|
||||
})
|
||||
.then((user) => user.Subscription);
|
||||
}
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const platformPlanPriceIds = await getPlatformPlanPriceIds();
|
||||
|
||||
return subscriptionsContainsActivePlan(subscriptions, platformPlanPriceIds);
|
||||
};
|
||||
@ -61,7 +61,7 @@ export const TemplateDocumentInvite = ({
|
||||
<>
|
||||
{includeSenderDetails ? (
|
||||
<Trans>
|
||||
{inviterName} on behalf of {teamName} has invited you to{' '}
|
||||
{inviterName} on behalf of "{teamName}" has invited you to{' '}
|
||||
{_(actionVerb).toLowerCase()}
|
||||
</Trans>
|
||||
) : (
|
||||
|
||||
@ -42,7 +42,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
|
||||
if (isTeamInvite) {
|
||||
previewText = includeSenderDetails
|
||||
? msg`${inviterName} on behalf of ${teamName} has invited you to ${action} ${documentName}`
|
||||
? msg`${inviterName} on behalf of "${teamName}" has invited you to ${action} ${documentName}`
|
||||
: msg`${teamName} has invited you to ${action} ${documentName}`;
|
||||
}
|
||||
|
||||
@ -90,14 +90,16 @@ export const DocumentInviteEmailTemplate = ({
|
||||
|
||||
<Container className="mx-auto mt-12 max-w-xl">
|
||||
<Section>
|
||||
<Text className="my-4 text-base font-semibold">
|
||||
<Trans>
|
||||
{inviterName}{' '}
|
||||
<Link className="font-normal text-slate-400" href="mailto:{inviterEmail}">
|
||||
({inviterEmail})
|
||||
</Link>
|
||||
</Trans>
|
||||
</Text>
|
||||
{!isTeamInvite && (
|
||||
<Text className="my-4 text-base font-semibold">
|
||||
<Trans>
|
||||
{inviterName}{' '}
|
||||
<Link className="font-normal text-slate-400" href="mailto:{inviterEmail}">
|
||||
({inviterEmail})
|
||||
</Link>
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text className="mt-2 text-base text-slate-400">
|
||||
{customBody ? (
|
||||
|
||||
@ -7,5 +7,6 @@ export enum STRIPE_PLAN_TYPE {
|
||||
REGULAR = 'regular',
|
||||
TEAM = 'team',
|
||||
COMMUNITY = 'community',
|
||||
PLATFORM = 'platform',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import type { TRPCError } from '@trpc/server';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
@ -8,46 +8,69 @@ import { TRPCClientError } from '@documenso/trpc/client';
|
||||
* Generic application error codes.
|
||||
*/
|
||||
export enum AppErrorCode {
|
||||
'ALREADY_EXISTS' = 'AlreadyExists',
|
||||
'EXPIRED_CODE' = 'ExpiredCode',
|
||||
'INVALID_BODY' = 'InvalidBody',
|
||||
'INVALID_REQUEST' = 'InvalidRequest',
|
||||
'LIMIT_EXCEEDED' = 'LimitExceeded',
|
||||
'NOT_FOUND' = 'NotFound',
|
||||
'NOT_SETUP' = 'NotSetup',
|
||||
'UNAUTHORIZED' = 'Unauthorized',
|
||||
'UNKNOWN_ERROR' = 'UnknownError',
|
||||
'RETRY_EXCEPTION' = 'RetryException',
|
||||
'SCHEMA_FAILED' = 'SchemaFailed',
|
||||
'TOO_MANY_REQUESTS' = 'TooManyRequests',
|
||||
'PROFILE_URL_TAKEN' = 'ProfileUrlTaken',
|
||||
'PREMIUM_PROFILE_URL' = 'PremiumProfileUrl',
|
||||
'ALREADY_EXISTS' = 'ALREADY_EXISTS',
|
||||
'EXPIRED_CODE' = 'EXPIRED_CODE',
|
||||
'INVALID_BODY' = 'INVALID_BODY',
|
||||
'INVALID_REQUEST' = 'INVALID_REQUEST',
|
||||
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED',
|
||||
'NOT_FOUND' = 'NOT_FOUND',
|
||||
'NOT_SETUP' = 'NOT_SETUP',
|
||||
'UNAUTHORIZED' = 'UNAUTHORIZED',
|
||||
'UNKNOWN_ERROR' = 'UNKNOWN_ERROR',
|
||||
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
|
||||
'SCHEMA_FAILED' = 'SCHEMA_FAILED',
|
||||
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS',
|
||||
'PROFILE_URL_TAKEN' = 'PROFILE_URL_TAKEN',
|
||||
'PREMIUM_PROFILE_URL' = 'PREMIUM_PROFILE_URL',
|
||||
}
|
||||
|
||||
const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
|
||||
[AppErrorCode.ALREADY_EXISTS]: 'BAD_REQUEST',
|
||||
[AppErrorCode.EXPIRED_CODE]: 'BAD_REQUEST',
|
||||
[AppErrorCode.INVALID_BODY]: 'BAD_REQUEST',
|
||||
[AppErrorCode.INVALID_REQUEST]: 'BAD_REQUEST',
|
||||
[AppErrorCode.NOT_FOUND]: 'NOT_FOUND',
|
||||
[AppErrorCode.NOT_SETUP]: 'BAD_REQUEST',
|
||||
[AppErrorCode.UNAUTHORIZED]: 'UNAUTHORIZED',
|
||||
[AppErrorCode.UNKNOWN_ERROR]: 'INTERNAL_SERVER_ERROR',
|
||||
[AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR',
|
||||
[AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR',
|
||||
[AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS',
|
||||
[AppErrorCode.PROFILE_URL_TAKEN]: 'BAD_REQUEST',
|
||||
[AppErrorCode.PREMIUM_PROFILE_URL]: 'BAD_REQUEST',
|
||||
export const genericErrorCodeToTrpcErrorCodeMap: Record<
|
||||
string,
|
||||
{ code: TRPCError['code']; status: number }
|
||||
> = {
|
||||
[AppErrorCode.ALREADY_EXISTS]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.EXPIRED_CODE]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.INVALID_BODY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
|
||||
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
|
||||
[AppErrorCode.UNKNOWN_ERROR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.RETRY_EXCEPTION]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
|
||||
[AppErrorCode.PROFILE_URL_TAKEN]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.PREMIUM_PROFILE_URL]: { code: 'BAD_REQUEST', status: 400 },
|
||||
};
|
||||
|
||||
export const ZAppErrorJsonSchema = z.object({
|
||||
code: z.string(),
|
||||
message: z.string().optional(),
|
||||
userMessage: z.string().optional(),
|
||||
statusCode: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TAppErrorJsonSchema = z.infer<typeof ZAppErrorJsonSchema>;
|
||||
|
||||
type AppErrorOptions = {
|
||||
/**
|
||||
* An internal message for logging.
|
||||
*/
|
||||
message?: string;
|
||||
|
||||
/**
|
||||
* A message which can be potientially displayed to the user.
|
||||
*/
|
||||
userMessage?: string;
|
||||
|
||||
/**
|
||||
* The status code to be associated with the error.
|
||||
*
|
||||
* Mainly used for API -> Frontend communication and logging filtering.
|
||||
*/
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
export class AppError extends Error {
|
||||
/**
|
||||
* The error code.
|
||||
@ -59,6 +82,11 @@ export class AppError extends Error {
|
||||
*/
|
||||
userMessage?: string;
|
||||
|
||||
/**
|
||||
* The status code to be associated with the error.
|
||||
*/
|
||||
statusCode?: number;
|
||||
|
||||
/**
|
||||
* Create a new AppError.
|
||||
*
|
||||
@ -66,10 +94,12 @@ export class AppError extends Error {
|
||||
* @param message An internal error message.
|
||||
* @param userMessage A error message which can be displayed to the user.
|
||||
*/
|
||||
public constructor(errorCode: string, message?: string, userMessage?: string) {
|
||||
super(message || errorCode);
|
||||
public constructor(errorCode: string, options?: AppErrorOptions) {
|
||||
super(options?.message || errorCode);
|
||||
|
||||
this.code = errorCode;
|
||||
this.userMessage = userMessage;
|
||||
this.userMessage = options?.userMessage;
|
||||
this.statusCode = options?.statusCode;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -84,16 +114,21 @@ export class AppError extends Error {
|
||||
|
||||
// Handle TRPC errors.
|
||||
if (error instanceof TRPCClientError) {
|
||||
const parsedJsonError = AppError.parseFromJSONString(error.message);
|
||||
return parsedJsonError || new AppError('UnknownError', error.message);
|
||||
const parsedJsonError = AppError.parseFromJSON(error.data?.appError);
|
||||
|
||||
const fallbackError = new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
return parsedJsonError || fallbackError;
|
||||
}
|
||||
|
||||
// Handle completely unknown errors.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const { code, message, userMessage } = error as {
|
||||
const { code, message, userMessage, statusCode } = error as {
|
||||
code: unknown;
|
||||
message: unknown;
|
||||
status: unknown;
|
||||
statusCode: unknown;
|
||||
userMessage: unknown;
|
||||
};
|
||||
|
||||
@ -102,16 +137,15 @@ export class AppError extends Error {
|
||||
const validUserMessage: string | undefined =
|
||||
typeof userMessage === 'string' ? userMessage : undefined;
|
||||
|
||||
return new AppError(validCode, validMessage, validUserMessage);
|
||||
}
|
||||
const validStatusCode = typeof statusCode === 'number' ? statusCode : undefined;
|
||||
|
||||
static parseErrorToTRPCError(error: unknown): TRPCError {
|
||||
const appError = AppError.parseError(error);
|
||||
const options: AppErrorOptions = {
|
||||
message: validMessage,
|
||||
userMessage: validUserMessage,
|
||||
statusCode: validStatusCode,
|
||||
};
|
||||
|
||||
return new TRPCError({
|
||||
code: genericErrorCodeToTrpcErrorCodeMap[appError.code] || 'BAD_REQUEST',
|
||||
message: AppError.toJSONString(appError),
|
||||
});
|
||||
return new AppError(validCode, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -120,12 +154,26 @@ export class AppError extends Error {
|
||||
* @param appError The AppError to convert to JSON.
|
||||
* @returns A JSON object representing the AppError.
|
||||
*/
|
||||
static toJSON({ code, message, userMessage }: AppError): TAppErrorJsonSchema {
|
||||
return {
|
||||
static toJSON({ code, message, userMessage, statusCode }: AppError): TAppErrorJsonSchema {
|
||||
const data: TAppErrorJsonSchema = {
|
||||
code,
|
||||
message,
|
||||
userMessage,
|
||||
};
|
||||
|
||||
// Explicity only set values if it exists, since TRPC will add meta for undefined
|
||||
// values which clutters up API responses.
|
||||
if (message) {
|
||||
data.message = message;
|
||||
}
|
||||
|
||||
if (userMessage) {
|
||||
data.userMessage = userMessage;
|
||||
}
|
||||
|
||||
if (statusCode) {
|
||||
data.statusCode = statusCode;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -138,15 +186,21 @@ export class AppError extends Error {
|
||||
return JSON.stringify(AppError.toJSON(appError));
|
||||
}
|
||||
|
||||
static parseFromJSONString(jsonString: string): AppError | null {
|
||||
static parseFromJSON(value: unknown): AppError | null {
|
||||
try {
|
||||
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
|
||||
const parsed = ZAppErrorJsonSchema.safeParse(value);
|
||||
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
|
||||
const { message, userMessage, statusCode } = parsed.data;
|
||||
|
||||
return new AppError(parsed.data.code, {
|
||||
message,
|
||||
userMessage,
|
||||
statusCode,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -133,7 +133,7 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
|
||||
if (!emailMessage) {
|
||||
emailMessage = i18n._(
|
||||
team.teamGlobalSettings?.includeSenderDetails
|
||||
? msg`${user.name} on behalf of ${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`
|
||||
? msg`${user.name} on behalf of "${team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".`
|
||||
: msg`${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -17,12 +17,14 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility),
|
||||
documentLanguage: z.string(),
|
||||
includeSenderDetails: z.boolean(),
|
||||
includeSigningCertificate: z.boolean(),
|
||||
brandingEnabled: z.boolean(),
|
||||
brandingLogo: z.string(),
|
||||
brandingUrl: z.string(),
|
||||
brandingCompanyDetails: z.string(),
|
||||
brandingHidePoweredBy: z.boolean(),
|
||||
teamId: z.number(),
|
||||
typedSignatureEnabled: z.boolean(),
|
||||
})
|
||||
.nullish(),
|
||||
}),
|
||||
|
||||
@ -21,6 +21,7 @@ import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
|
||||
import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances';
|
||||
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||
import { ZWebhookDocumentSchema } from '../../../types/webhook-payload';
|
||||
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
|
||||
import { getFile } from '../../../universal/upload/get-file';
|
||||
import { putPdfFile } from '../../../universal/upload/put-file';
|
||||
@ -57,7 +58,17 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
|
||||
},
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
Recipient: true,
|
||||
team: {
|
||||
select: {
|
||||
teamGlobalSettings: {
|
||||
select: {
|
||||
includeSigningCertificate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -117,7 +128,13 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
|
||||
}
|
||||
|
||||
const pdfData = await getFile(documentData);
|
||||
const certificateData = await getCertificatePdf({ documentId }).catch(() => null);
|
||||
const certificateData =
|
||||
(document.team?.teamGlobalSettings?.includeSigningCertificate ?? true)
|
||||
? await getCertificatePdf({
|
||||
documentId,
|
||||
language: document.documentMeta?.language,
|
||||
}).catch(() => null)
|
||||
: null;
|
||||
|
||||
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
|
||||
const pdfDoc = await PDFDocument.load(pdfData);
|
||||
@ -151,10 +168,10 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
|
||||
|
||||
const { name, ext } = path.parse(document.title);
|
||||
const { name } = path.parse(document.title);
|
||||
|
||||
const documentData = await putPdfFile({
|
||||
name: `${name}_signed${ext}`,
|
||||
name: `${name}_signed.pdf`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
||||
});
|
||||
@ -233,13 +250,14 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||
data: updatedDocument,
|
||||
data: ZWebhookDocumentSchema.parse(updatedDocument),
|
||||
userId: updatedDocument.userId,
|
||||
teamId: updatedDocument.teamId ?? undefined,
|
||||
});
|
||||
|
||||
@ -26,6 +26,10 @@ import { extractNextAuthRequestMetadata } from '../universal/extract-request-met
|
||||
import { getAuthenticatorOptions } from '../utils/authenticator';
|
||||
import { ErrorCode } from './error-codes';
|
||||
|
||||
const useSecureCookies =
|
||||
process.env.NODE_ENV === 'production' && String(process.env.NEXTAUTH_URL).startsWith('https://');
|
||||
const cookiePrefix = useSecureCookies ? '__Secure-' : '';
|
||||
|
||||
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
adapter: PrismaAdapter(prisma),
|
||||
secret: process.env.NEXTAUTH_SECRET ?? 'secret',
|
||||
@ -431,5 +435,53 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: `${cookiePrefix}next-auth.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: useSecureCookies ? 'none' : 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies,
|
||||
},
|
||||
},
|
||||
callbackUrl: {
|
||||
name: `${cookiePrefix}next-auth.callback-url`,
|
||||
options: {
|
||||
sameSite: useSecureCookies ? 'none' : 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies,
|
||||
},
|
||||
},
|
||||
csrfToken: {
|
||||
// Default to __Host- for CSRF token for additional protection if using useSecureCookies
|
||||
// NB: The `__Host-` prefix is stricter than the `__Secure-` prefix.
|
||||
name: `${cookiePrefix}next-auth.csrf-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: useSecureCookies ? 'none' : 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies,
|
||||
},
|
||||
},
|
||||
pkceCodeVerifier: {
|
||||
name: `${cookiePrefix}next-auth.pkce.code_verifier`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: useSecureCookies ? 'none' : 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies,
|
||||
},
|
||||
},
|
||||
state: {
|
||||
name: `${cookiePrefix}next-auth.state`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: useSecureCookies ? 'none' : 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Note: `events` are handled in `apps/web/src/pages/api/auth/[...nextauth].ts` to allow access to the request.
|
||||
};
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
"@documenso/email": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/signing": "*",
|
||||
"@honeybadger-io/js": "^6.10.1",
|
||||
"@lingui/core": "^4.11.3",
|
||||
"@lingui/macro": "^4.11.3",
|
||||
"@lingui/react": "^4.11.3",
|
||||
@ -51,7 +52,7 @@
|
||||
"pg": "^8.11.3",
|
||||
"playwright": "1.43.0",
|
||||
"react": "^18",
|
||||
"remeda": "^2.12.1",
|
||||
"remeda": "^2.17.3",
|
||||
"sharp": "0.32.6",
|
||||
"stripe": "^12.7.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
@ -62,4 +63,4 @@
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/pg": "^8.11.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,21 +1,25 @@
|
||||
import { base32 } from '@scure/base';
|
||||
import { TOTPController } from 'oslo/otp';
|
||||
import { generateHOTP } from 'oslo/otp';
|
||||
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricDecrypt } from '../../universal/crypto';
|
||||
|
||||
const totp = new TOTPController();
|
||||
|
||||
type VerifyTwoFactorAuthenticationTokenOptions = {
|
||||
user: User;
|
||||
totpCode: string;
|
||||
// The number of windows to look back
|
||||
window?: number;
|
||||
// The duration that the token is valid for in seconds
|
||||
period?: number;
|
||||
};
|
||||
|
||||
export const verifyTwoFactorAuthenticationToken = async ({
|
||||
user,
|
||||
totpCode,
|
||||
window = 1,
|
||||
period = 30_000,
|
||||
}: VerifyTwoFactorAuthenticationTokenOptions) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
@ -27,7 +31,21 @@ export const verifyTwoFactorAuthenticationToken = async ({
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const isValidToken = await totp.verify(totpCode, base32.decode(secret));
|
||||
const decodedSecret = base32.decode(secret);
|
||||
|
||||
return isValidToken;
|
||||
let now = Date.now();
|
||||
|
||||
for (let i = 0; i < window; i++) {
|
||||
const counter = Math.floor(now / period);
|
||||
|
||||
const hotp = await generateHOTP(decodedSecret, counter);
|
||||
|
||||
if (totpCode === hotp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
now -= period;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
148
packages/lib/server-only/admin/get-signing-volume.ts
Normal file
148
packages/lib/server-only/admin/get-signing-volume.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
export type SigningVolume = {
|
||||
id: number;
|
||||
name: string;
|
||||
signingVolume: number;
|
||||
createdAt: Date;
|
||||
planId: string;
|
||||
};
|
||||
|
||||
export type GetSigningVolumeOptions = {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
sortBy?: 'name' | 'createdAt' | 'signingVolume';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export async function getSigningVolume({
|
||||
search = '',
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
sortBy = 'signingVolume',
|
||||
sortOrder = 'desc',
|
||||
}: GetSigningVolumeOptions) {
|
||||
const whereClause = Prisma.validator<Prisma.SubscriptionWhereInput>()({
|
||||
status: 'ACTIVE',
|
||||
OR: [
|
||||
{
|
||||
User: {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
name: { contains: search, mode: 'insensitive' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const orderByClause = getOrderByClause({ sortBy, sortOrder });
|
||||
|
||||
const [subscriptions, totalCount] = await Promise.all([
|
||||
prisma.subscription.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
User: {
|
||||
include: {
|
||||
Document: {
|
||||
where: {
|
||||
status: 'COMPLETED',
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
include: {
|
||||
document: {
|
||||
where: {
|
||||
status: 'COMPLETED',
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: orderByClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.subscription.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
const leaderboardWithVolume: SigningVolume[] = subscriptions.map((subscription) => {
|
||||
const name =
|
||||
subscription.User?.name || subscription.team?.name || subscription.User?.email || 'Unknown';
|
||||
|
||||
const userSignedDocs = subscription.User?.Document?.length || 0;
|
||||
const teamSignedDocs = subscription.team?.document?.length || 0;
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
name,
|
||||
signingVolume: userSignedDocs + teamSignedDocs,
|
||||
createdAt: subscription.createdAt,
|
||||
planId: subscription.planId,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
leaderboard: leaderboardWithVolume,
|
||||
totalPages: Math.ceil(totalCount / perPage),
|
||||
};
|
||||
}
|
||||
|
||||
function getOrderByClause(options: {
|
||||
sortBy: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}): Prisma.SubscriptionOrderByWithRelationInput | Prisma.SubscriptionOrderByWithRelationInput[] {
|
||||
const { sortBy, sortOrder } = options;
|
||||
|
||||
if (sortBy === 'name') {
|
||||
return [
|
||||
{
|
||||
User: {
|
||||
name: sortOrder,
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
name: sortOrder,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (sortBy === 'createdAt') {
|
||||
return {
|
||||
createdAt: sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
// Default: sort by signing volume
|
||||
return [
|
||||
{
|
||||
User: {
|
||||
Document: {
|
||||
_count: sortOrder,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
document: {
|
||||
_count: sortOrder,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user