mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
22 Commits
fun/sign-w
...
v1.8.1-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| a687064a42 | |||
| 8ec69388a5 | |||
| f3da11b3e7 | |||
| fc84ee8ec2 | |||
| 4282a96ee7 | |||
| 2aae7435f8 | |||
| bdd33bd335 | |||
| 9e8d0ac906 | |||
| f27d0f342c | |||
| 4326e27a2a | |||
| 62806298cf | |||
| 87186e08b1 | |||
| b27fd800ed | |||
| 98d85b086d | |||
| 04293968c6 | |||
| e6d4005cd1 | |||
| 337bdb3553 | |||
| ab654a63d8 | |||
| dcb7c2436f | |||
| fa33f83696 | |||
| b15e1d6c47 | |||
| cd5adce7df |
@ -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>
|
||||
```
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/marketing",
|
||||
"version": "1.8.1-rc.0",
|
||||
"version": "1.8.1-rc.6",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/web",
|
||||
"version": "1.8.1-rc.0",
|
||||
"version": "1.8.1-rc.6",
|
||||
"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`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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}
|
||||
@ -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}>
|
||||
|
||||
@ -53,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} />
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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'];
|
||||
|
||||
909
package-lock.json
generated
909
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.8.1-rc.0",
|
||||
"version": "1.8.1-rc.6",
|
||||
"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"
|
||||
},
|
||||
|
||||
@ -302,6 +302,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,
|
||||
@ -132,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(),
|
||||
@ -226,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(
|
||||
@ -252,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);
|
||||
};
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -40,7 +40,9 @@ export const createPasskeyAuthenticationOptions = async ({
|
||||
});
|
||||
|
||||
if (!preferredPasskey) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Requested passkey not found');
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Requested passkey not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -50,7 +50,9 @@ export const createPasskey = async ({
|
||||
});
|
||||
|
||||
if (!verificationToken) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Challenge token not found');
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Challenge token not found',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.verificationToken.deleteMany({
|
||||
@ -61,7 +63,9 @@ export const createPasskey = async ({
|
||||
});
|
||||
|
||||
if (verificationToken.expires < new Date()) {
|
||||
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired');
|
||||
throw new AppError(AppErrorCode.EXPIRED_CODE, {
|
||||
message: 'Challenge token expired',
|
||||
});
|
||||
}
|
||||
|
||||
const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorOptions();
|
||||
@ -74,7 +78,9 @@ export const createPasskey = async ({
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Verification failed');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Verification failed',
|
||||
});
|
||||
}
|
||||
|
||||
const { credentialPublicKey, credentialID, counter, credentialDeviceType, credentialBackedUp } =
|
||||
|
||||
@ -13,6 +13,7 @@ import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { jobs } from '../../jobs/client';
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
@ -203,11 +204,19 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const updatedDocument = await getDocument({ token, documentId });
|
||||
const updatedDocument = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
|
||||
data: updatedDocument,
|
||||
data: ZWebhookDocumentSchema.parse(updatedDocument),
|
||||
userId: updatedDocument.userId,
|
||||
teamId: updatedDocument.teamId ?? undefined,
|
||||
});
|
||||
|
||||
@ -9,6 +9,7 @@ import { DocumentSource, DocumentVisibility, WebhookTriggerEvents } from '@docum
|
||||
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CreateDocumentOptions = {
|
||||
@ -47,7 +48,9 @@ export const createDocument = async ({
|
||||
teamId !== undefined &&
|
||||
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
|
||||
) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Team not found',
|
||||
});
|
||||
}
|
||||
|
||||
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;
|
||||
@ -112,6 +115,7 @@ export const createDocument = async ({
|
||||
documentMeta: {
|
||||
create: {
|
||||
language: team?.teamGlobalSettings?.documentLanguage,
|
||||
typedSignatureEnabled: team?.teamGlobalSettings?.typedSignatureEnabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -132,13 +136,27 @@ export const createDocument = async ({
|
||||
}),
|
||||
});
|
||||
|
||||
const createdDocument = await tx.document.findFirst({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!createdDocument) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: document,
|
||||
data: ZWebhookDocumentSchema.parse(createdDocument),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return document;
|
||||
return createdDocument;
|
||||
});
|
||||
};
|
||||
|
||||
@ -4,6 +4,7 @@ import { prisma } from '@documenso/prisma';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
@ -20,7 +21,7 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
|
||||
teamId,
|
||||
});
|
||||
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
documentData: true,
|
||||
@ -45,6 +46,14 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document could not be found',
|
||||
});
|
||||
}
|
||||
|
||||
return document;
|
||||
};
|
||||
|
||||
export type GetDocumentWhereInputOptions = {
|
||||
|
||||
@ -81,6 +81,17 @@ export const getDocumentAndSenderByToken = async ({
|
||||
token,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
teamEmail: true,
|
||||
teamGlobalSettings: {
|
||||
select: {
|
||||
includeSenderDetails: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -107,7 +118,9 @@ export const getDocumentAndSenderByToken = async ({
|
||||
}
|
||||
|
||||
if (!documentAccessValid) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid access values',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@ -167,7 +180,9 @@ export const getDocumentAndRecipientByToken = async ({
|
||||
}
|
||||
|
||||
if (!documentAccessValid) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid access values',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -106,7 +106,9 @@ export const isRecipientAuthorized = async ({
|
||||
|
||||
// Should not be possible.
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await verifyTwoFactorAuthenticationToken({
|
||||
@ -164,7 +166,9 @@ const verifyPasskey = async ({
|
||||
});
|
||||
|
||||
if (!passkey) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Passkey not found',
|
||||
});
|
||||
}
|
||||
|
||||
const verificationToken = await prisma.verificationToken
|
||||
@ -177,11 +181,15 @@ const verifyPasskey = async ({
|
||||
.catch(() => null);
|
||||
|
||||
if (!verificationToken) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Token not found');
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Token not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationToken.expires < new Date()) {
|
||||
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Token expired');
|
||||
throw new AppError(AppErrorCode.EXPIRED_CODE, {
|
||||
message: 'Token expired',
|
||||
});
|
||||
}
|
||||
|
||||
const { rpId, origin } = getAuthenticatorOptions();
|
||||
@ -199,7 +207,9 @@ const verifyPasskey = async ({
|
||||
}).catch(() => null); // May want to log this for insights.
|
||||
|
||||
if (verification?.verified !== true) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'User is not authorized');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User is not authorized',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.passkey.update({
|
||||
|
||||
@ -3,10 +3,13 @@ import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type RejectDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
@ -31,6 +34,8 @@ export async function rejectDocumentWithToken({
|
||||
Document: {
|
||||
include: {
|
||||
User: true,
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -45,8 +50,6 @@ export async function rejectDocumentWithToken({
|
||||
});
|
||||
}
|
||||
|
||||
// Add the audit log entry before updating the recipient
|
||||
|
||||
// Update the recipient status to rejected
|
||||
const [updatedRecipient] = await prisma.$transaction([
|
||||
prisma.recipient.update({
|
||||
@ -88,5 +91,28 @@ export async function rejectDocumentWithToken({
|
||||
},
|
||||
});
|
||||
|
||||
// Get the updated document with all recipients
|
||||
const updatedDocument = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!updatedDocument) {
|
||||
throw new Error('Document not found after update');
|
||||
}
|
||||
|
||||
// Trigger webhook for document rejection
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_REJECTED,
|
||||
data: ZWebhookDocumentSchema.parse(updatedDocument),
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
});
|
||||
|
||||
return updatedRecipient;
|
||||
}
|
||||
|
||||
@ -134,7 +134,7 @@ export const resendDocument = async ({
|
||||
emailMessage =
|
||||
customEmail?.message ||
|
||||
i18n._(
|
||||
msg`${user.name} on behalf of ${document.team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
|
||||
msg`${user.name} on behalf of "${document.team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
import { signPdf } from '@documenso/signing';
|
||||
|
||||
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
|
||||
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { putPdfFile } from '../../universal/upload/put-file';
|
||||
@ -48,6 +48,15 @@ export const sealDocument = async ({
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
Recipient: true,
|
||||
team: {
|
||||
select: {
|
||||
teamGlobalSettings: {
|
||||
select: {
|
||||
includeSigningCertificate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -92,11 +101,13 @@ export const sealDocument = async ({
|
||||
// !: Need to write the fields onto the document as a hard copy
|
||||
const pdfData = await getFile(documentData);
|
||||
|
||||
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
|
||||
|
||||
const certificate = await getCertificatePdf({ documentId, language: documentLanguage })
|
||||
.then(async (doc) => PDFDocument.load(doc))
|
||||
.catch(() => null);
|
||||
const certificateData =
|
||||
(document.team?.teamGlobalSettings?.includeSigningCertificate ?? true)
|
||||
? await getCertificatePdf({
|
||||
documentId,
|
||||
language: document.documentMeta?.language,
|
||||
}).catch(() => null)
|
||||
: null;
|
||||
|
||||
const doc = await PDFDocument.load(pdfData);
|
||||
|
||||
@ -105,7 +116,9 @@ export const sealDocument = async ({
|
||||
flattenForm(doc);
|
||||
flattenAnnotations(doc);
|
||||
|
||||
if (certificate) {
|
||||
if (certificateData) {
|
||||
const certificate = await PDFDocument.load(certificateData);
|
||||
|
||||
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
|
||||
|
||||
certificatePages.forEach((page) => {
|
||||
@ -124,10 +137,10 @@ export const sealDocument = async ({
|
||||
|
||||
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
|
||||
|
||||
const { name, ext } = path.parse(document.title);
|
||||
const { name } = path.parse(document.title);
|
||||
|
||||
const { data: newData } = await putPdfFile({
|
||||
name: `${name}_signed${ext}`,
|
||||
name: `${name}_signed.pdf`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
||||
});
|
||||
@ -187,13 +200,14 @@ export const sealDocument = async ({
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||
data: updatedDocument,
|
||||
data: ZWebhookDocumentSchema.parse(updatedDocument),
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
});
|
||||
|
||||
@ -14,6 +14,7 @@ import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@ -114,8 +115,14 @@ export const sendDocument = async ({
|
||||
formValues: document.formValues as Record<string, string | number | boolean>,
|
||||
});
|
||||
|
||||
let fileName = document.title;
|
||||
|
||||
if (!document.title.endsWith('.pdf')) {
|
||||
fileName = `${document.title}.pdf`;
|
||||
}
|
||||
|
||||
const newDocumentData = await putPdfFile({
|
||||
name: document.title,
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||
});
|
||||
@ -230,6 +237,7 @@ export const sendDocument = async ({
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
@ -237,7 +245,7 @@ export const sendDocument = async ({
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_SENT,
|
||||
data: updatedDocument,
|
||||
data: ZWebhookDocumentSchema.parse(updatedDocument),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
@ -37,7 +37,9 @@ export const updateDocumentSettings = async ({
|
||||
requestMetadata,
|
||||
}: UpdateDocumentSettingsOptions) => {
|
||||
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Missing data to update',
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@ -96,10 +98,9 @@ export const updateDocumentSettings = async ({
|
||||
!allowedVisibilities.includes(document.visibility) ||
|
||||
(data.visibility && !allowedVisibilities.includes(data.visibility))
|
||||
) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have permission to update the document visibility',
|
||||
);
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to update the document visibility',
|
||||
});
|
||||
}
|
||||
})
|
||||
.with(TeamMemberRole.MEMBER, () => {
|
||||
@ -107,17 +108,15 @@ export const updateDocumentSettings = async ({
|
||||
document.visibility !== DocumentVisibility.EVERYONE ||
|
||||
(data.visibility && data.visibility !== DocumentVisibility.EVERYONE)
|
||||
) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have permission to update the document visibility',
|
||||
);
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to update the document visibility',
|
||||
});
|
||||
}
|
||||
})
|
||||
.otherwise(() => {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have permission to update the document',
|
||||
);
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to update the document',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -142,10 +141,9 @@ export const updateDocumentSettings = async ({
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have permission to set the action auth',
|
||||
);
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,10 +159,9 @@ export const updateDocumentSettings = async ({
|
||||
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
|
||||
|
||||
if (!isTitleSame && document.status !== DocumentStatus.DRAFT) {
|
||||
throw new AppError(
|
||||
AppErrorCode.INVALID_BODY,
|
||||
'You cannot update the title if the document has been sent',
|
||||
);
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'You cannot update the title if the document has been sent',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isTitleSame) {
|
||||
|
||||
@ -45,7 +45,9 @@ export const validateFieldAuth = async ({
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid authentication values',
|
||||
});
|
||||
}
|
||||
|
||||
return derivedRecipientActionAuth;
|
||||
|
||||
@ -6,8 +6,8 @@ import { ReadStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import type { TDocumentAccessAuthTypes } from '../../types/document-auth';
|
||||
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getDocumentAndRecipientByToken } from './get-document-by-token';
|
||||
|
||||
export type ViewedDocumentOptions = {
|
||||
token: string;
|
||||
@ -63,11 +63,23 @@ export const viewedDocument = async ({
|
||||
});
|
||||
});
|
||||
|
||||
const document = await getDocumentAndRecipientByToken({ token, requireAccessAuth: false });
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_OPENED,
|
||||
data: document,
|
||||
data: ZWebhookDocumentSchema.parse(document),
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
});
|
||||
|
||||
@ -104,7 +104,9 @@ export const setFieldsForDocument = async ({
|
||||
|
||||
// Each field MUST have a recipient associated with it.
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, `Recipient not found for field ${field.id}`);
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Recipient not found for field ${field.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check whether the existing field can be modified.
|
||||
@ -113,10 +115,10 @@ export const setFieldsForDocument = async ({
|
||||
hasFieldBeenChanged(existing, field) &&
|
||||
!canRecipientFieldsBeModified(recipient, existingFields)
|
||||
) {
|
||||
throw new AppError(
|
||||
AppErrorCode.INVALID_REQUEST,
|
||||
'Cannot modify a field where the recipient has already interacted with the document',
|
||||
);
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message:
|
||||
'Cannot modify a field where the recipient has already interacted with the document',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -177,6 +177,10 @@ export const signFieldWithToken = async ({
|
||||
throw new Error('Signature field must have a signature');
|
||||
}
|
||||
|
||||
if (isSignatureField && !documentMeta?.typedSignatureEnabled && typedSignature) {
|
||||
throw new Error('Typed signatures are not allowed. Please draw your signature');
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const updatedField = await tx.field.update({
|
||||
where: {
|
||||
|
||||
@ -2,12 +2,13 @@ import { DateTime } from 'luxon';
|
||||
import type { Browser } from 'playwright';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
|
||||
import { encryptSecondaryData } from '../crypto/encrypt';
|
||||
|
||||
export type GetCertificatePdfOptions = {
|
||||
documentId: number;
|
||||
language?: SupportedLanguageCodes;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
language?: SupportedLanguageCodes | (string & {});
|
||||
};
|
||||
|
||||
export const getCertificatePdf = async ({ documentId, language }: GetCertificatePdfOptions) => {
|
||||
@ -38,15 +39,15 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
|
||||
|
||||
const page = await browserContext.newPage();
|
||||
|
||||
if (language) {
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: 'language',
|
||||
value: language,
|
||||
url: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
const lang = isValidLanguageCode(language) ? language : 'en';
|
||||
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: 'language',
|
||||
value: lang,
|
||||
url: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
},
|
||||
]);
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
|
||||
waitUntil: 'networkidle',
|
||||
|
||||
@ -82,7 +82,10 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
const fieldX = pageWidth * (Number(field.positionX) / 100);
|
||||
const fieldY = pageHeight * (Number(field.positionY) / 100);
|
||||
|
||||
const font = await pdf.embedFont(isSignatureField ? fontCaveat : fontNoto);
|
||||
const font = await pdf.embedFont(
|
||||
isSignatureField ? fontCaveat : fontNoto,
|
||||
isSignatureField ? { features: { calt: false } } : undefined,
|
||||
);
|
||||
|
||||
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
||||
await pdf.embedFont(fontCaveat);
|
||||
@ -92,45 +95,89 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
.with(
|
||||
{
|
||||
type: P.union(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE),
|
||||
Signature: { signatureImageAsBase64: P.string },
|
||||
},
|
||||
async (field) => {
|
||||
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
|
||||
if (field.Signature?.signatureImageAsBase64) {
|
||||
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
|
||||
|
||||
let imageWidth = image.width;
|
||||
let imageHeight = image.height;
|
||||
let imageWidth = image.width;
|
||||
let imageHeight = image.height;
|
||||
|
||||
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
||||
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
||||
|
||||
imageWidth = imageWidth * scalingFactor;
|
||||
imageHeight = imageHeight * scalingFactor;
|
||||
imageWidth = imageWidth * scalingFactor;
|
||||
imageHeight = imageHeight * scalingFactor;
|
||||
|
||||
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
|
||||
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
|
||||
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
|
||||
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
|
||||
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
imageY = pageHeight - imageY - imageHeight;
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
imageY = pageHeight - imageY - imageHeight;
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
imageX,
|
||||
imageY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
imageX,
|
||||
imageY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
|
||||
imageX = adjustedPosition.xPos;
|
||||
imageY = adjustedPosition.yPos;
|
||||
imageX = adjustedPosition.xPos;
|
||||
imageY = adjustedPosition.yPos;
|
||||
}
|
||||
|
||||
page.drawImage(image, {
|
||||
x: imageX,
|
||||
y: imageY,
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
} else {
|
||||
const signatureText = field.Signature?.typedSignature ?? '';
|
||||
|
||||
const longestLineInTextForWidth = signatureText
|
||||
.split('\n')
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
|
||||
let fontSize = maxFontSize;
|
||||
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
let textHeight = font.heightAtSize(fontSize);
|
||||
|
||||
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
||||
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
||||
|
||||
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
textHeight = font.heightAtSize(fontSize);
|
||||
|
||||
let textX = fieldX + (fieldWidth - textWidth) / 2;
|
||||
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
||||
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
textY = pageHeight - textY - textHeight;
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
textX,
|
||||
textY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
|
||||
textX = adjustedPosition.xPos;
|
||||
textY = adjustedPosition.yPos;
|
||||
}
|
||||
|
||||
page.drawText(signatureText, {
|
||||
x: textX,
|
||||
y: textY,
|
||||
size: fontSize,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
}
|
||||
|
||||
page.drawImage(image, {
|
||||
x: imageX,
|
||||
y: imageY,
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
},
|
||||
)
|
||||
.with({ type: FieldType.CHECKBOX }, (field) => {
|
||||
|
||||
@ -115,7 +115,9 @@ export const getPublicProfileByUrl = async ({
|
||||
// Log as critical error.
|
||||
if (user?.profile && team?.profile) {
|
||||
console.error('Profile URL is ambiguous', { profileUrl, userId: user.id, teamId: team.id });
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Profile URL is ambiguous');
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Profile URL is ambiguous',
|
||||
});
|
||||
}
|
||||
|
||||
if (user?.profile?.enabled) {
|
||||
@ -177,5 +179,7 @@ export const getPublicProfileByUrl = async ({
|
||||
};
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Profile not found');
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Profile not found',
|
||||
});
|
||||
};
|
||||
|
||||
@ -18,10 +18,9 @@ export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) =>
|
||||
});
|
||||
|
||||
if (teamMember?.role !== TeamMemberRole.ADMIN) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have the required permissions to view this page.',
|
||||
);
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have the required permissions to view this page.',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.apiToken.findMany({
|
||||
|
||||
@ -105,10 +105,9 @@ export const setRecipientsForDocument = async ({
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have permission to set the action auth',
|
||||
);
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,10 +141,9 @@ export const setRecipientsForDocument = async ({
|
||||
hasRecipientBeenChanged(existing, recipient) &&
|
||||
!canRecipientBeModified(existing, document.Field)
|
||||
) {
|
||||
throw new AppError(
|
||||
AppErrorCode.INVALID_REQUEST,
|
||||
'Cannot modify a recipient who has already interacted with the document',
|
||||
);
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot modify a recipient who has already interacted with the document',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -72,10 +72,9 @@ export const setRecipientsForTemplate = async ({
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have permission to set the action auth',
|
||||
);
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,14 +118,15 @@ export const setRecipientsForTemplate = async ({
|
||||
);
|
||||
|
||||
if (updatedDirectRecipient?.role === RecipientRole.CC) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, 'Cannot set direct recipient as CC');
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Cannot set direct recipient as CC',
|
||||
});
|
||||
}
|
||||
|
||||
if (deletedDirectRecipient) {
|
||||
throw new AppError(
|
||||
AppErrorCode.INVALID_BODY,
|
||||
'Cannot delete direct recipient while direct template exists',
|
||||
);
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Cannot delete direct recipient while direct template exists',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -96,10 +96,9 @@ export const updateRecipient = async ({
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have permission to set the action auth',
|
||||
);
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -47,6 +47,8 @@ export const createTeamPendingCheckoutSession = async ({
|
||||
console.error(e);
|
||||
|
||||
// Absorb all the errors incase Stripe throws something sensitive.
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, 'Something went wrong.');
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Something went wrong.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -55,10 +55,9 @@ export const createTeamEmailVerification = async ({
|
||||
});
|
||||
|
||||
if (team.teamEmail || team.emailVerification) {
|
||||
throw new AppError(
|
||||
AppErrorCode.INVALID_REQUEST,
|
||||
'Team already has an email or existing email verification.',
|
||||
);
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Team already has an email or existing email verification.',
|
||||
});
|
||||
}
|
||||
|
||||
const existingTeamEmail = await tx.teamEmail.findFirst({
|
||||
@ -68,7 +67,9 @@ export const createTeamEmailVerification = async ({
|
||||
});
|
||||
|
||||
if (existingTeamEmail) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Email already taken by another team.',
|
||||
});
|
||||
}
|
||||
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
@ -97,7 +98,9 @@ export const createTeamEmailVerification = async ({
|
||||
const target = z.array(z.string()).safeParse(err.meta?.target);
|
||||
|
||||
if (err.code === 'P2002' && target.success && target.data.includes('email')) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Email already taken by another team.',
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
|
||||
@ -69,7 +69,9 @@ export const createTeamMemberInvites = async ({
|
||||
const currentTeamMember = team.members.find((member) => member.user.id === userId);
|
||||
|
||||
if (!currentTeamMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'User not part of team.');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User not part of team.',
|
||||
});
|
||||
}
|
||||
|
||||
const usersToInvite = invitations.filter((invitation) => {
|
||||
@ -91,10 +93,9 @@ export const createTeamMemberInvites = async ({
|
||||
);
|
||||
|
||||
if (unauthorizedRoleAccess) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'User does not have permission to set high level roles',
|
||||
);
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User does not have permission to set high level roles',
|
||||
});
|
||||
}
|
||||
|
||||
const teamMemberInvites = usersToInvite.map(({ email, role }) => ({
|
||||
@ -127,11 +128,10 @@ export const createTeamMemberInvites = async ({
|
||||
if (sendEmailResultErrorList.length > 0) {
|
||||
console.error(JSON.stringify(sendEmailResultErrorList));
|
||||
|
||||
throw new AppError(
|
||||
'EmailDeliveryFailed',
|
||||
'Failed to send invite emails to one or more users.',
|
||||
`Failed to send invites to ${sendEmailResultErrorList.length}/${teamMemberInvites.length} users.`,
|
||||
);
|
||||
throw new AppError('EmailDeliveryFailed', {
|
||||
message: 'Failed to send invite emails to one or more users.',
|
||||
userMessage: `Failed to send invites to ${sendEmailResultErrorList.length}/${teamMemberInvites.length} users.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -87,7 +87,9 @@ export const createTeam = async ({
|
||||
});
|
||||
|
||||
if (existingUserProfileWithUrl) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'URL already taken.');
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'URL already taken.',
|
||||
});
|
||||
}
|
||||
|
||||
await tx.team.create({
|
||||
@ -131,15 +133,21 @@ export const createTeam = async ({
|
||||
});
|
||||
|
||||
if (existingUserProfileWithUrl) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'URL already taken.');
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'URL already taken.',
|
||||
});
|
||||
}
|
||||
|
||||
if (existingTeamWithUrl) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Team URL already exists.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, 'Missing customer ID for pending teams.');
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Missing customer ID for pending teams.',
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.teamPending.create({
|
||||
@ -166,7 +174,9 @@ export const createTeam = async ({
|
||||
const target = z.array(z.string()).safeParse(err.meta?.target);
|
||||
|
||||
if (err.code === 'P2002' && target.success && target.data.includes('url')) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Team URL already exists.',
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
|
||||
@ -60,11 +60,13 @@ export const deleteTeamMembers = async ({
|
||||
);
|
||||
|
||||
if (!currentTeamMember) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist');
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Team member record does not exist',
|
||||
});
|
||||
}
|
||||
|
||||
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Cannot remove the team owner' });
|
||||
}
|
||||
|
||||
const isMemberToRemoveHigherRole = teamMembersToRemove.some(
|
||||
@ -72,7 +74,9 @@ export const deleteTeamMembers = async ({
|
||||
);
|
||||
|
||||
if (isMemberToRemoveHigherRole) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Cannot remove a member with a higher role',
|
||||
});
|
||||
}
|
||||
|
||||
// Remove the team members.
|
||||
|
||||
@ -24,7 +24,9 @@ export const findTeamInvoices = async ({ userId, teamId }: FindTeamInvoicesOptio
|
||||
});
|
||||
|
||||
if (!team.customerId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Team has no customer ID.');
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Team has no customer ID.',
|
||||
});
|
||||
}
|
||||
|
||||
const results = await getInvoices({ customerId: team.customerId });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user