Compare commits

...

45 Commits

Author SHA1 Message Date
9e8d0ac906 v1.8.1-rc.4 2024-12-02 22:07:31 +11:00
f27d0f342c fix: putPdfFile to always include file extension 2024-12-02 22:06:53 +11:00
4326e27a2a v1.8.1-rc.3 2024-12-02 07:48:03 +11:00
62806298cf fix: wrong signing invitation message (#1497) 2024-12-02 07:47:11 +11:00
87186e08b1 v1.8.1-rc.2 2024-11-29 15:09:03 +11:00
b27fd800ed fix: add distribution settings to external api 2024-11-29 14:10:48 +11:00
98d85b086d feat: add initial api logging (#1494)
Improve API logging and error handling between client and server side.
2024-11-28 16:05:37 +07:00
04293968c6 chore: update embedding docs 2024-11-28 15:55:17 +11:00
e6d4005cd1 fix: document title truncation (#1467)
Truncates the document title across various instances where it previously hadn't been truncated.
2024-11-27 10:53:48 +11:00
337bdb3553 v1.8.0-rc.1 2024-11-26 21:26:12 +11:00
ab654a63d8 chore: enable typed signature by default (#1436)
Enable typed signature by default and also add the option to set a typed
signature in the profile page.
2024-11-26 21:03:44 +11:00
dcb7c2436f fix: update prettier and tailwind 2024-11-26 11:47:28 +11:00
fa33f83696 feat: download doc without signing certificate (#1477)
## Description

I added the option of downloading a document without the signing
certificate for teams. They can disable/enable the option in the
preferences tab.

The signing certificate can still be downloaded separately from the
`logs` page.
2024-11-25 15:47:26 +11:00
b15e1d6c47 feat: support whitelabelling in the embedding (#1491)
## Description

Adds support for customising the theme and CSS for the embedding
components which is restricted to platform customers and above.

Additionally adds proper support for the platform plan which will let us
update our stripe products.

<img width="1040" alt="image"
src="https://github.com/user-attachments/assets/f694cd1e-ac93-4dc0-9f78-92fa813f6404">
<img width="1015" alt="image"
src="https://github.com/user-attachments/assets/4209972a-b2bd-40c9-9049-0367382a4de5">
<img width="1065" alt="image"
src="https://github.com/user-attachments/assets/fdbaaaa5-a028-4b1d-a58a-ea6224e21abe">


## Related Issue

N/A

## Changes Made

- Added support for using CSS Vars and CSS within the embedding route
- Added a guard for platform and enterprise plans to activate the custom
css
- Added support for the platform plan

## Testing Performed
Yes
2024-11-25 15:47:00 +11:00
cd5adce7df fix: hardcode delete confirmation text to avoid translation mismatch (#1487) 2024-11-22 14:22:31 +07:00
11e483f1c4 chore: update changelog 2024-11-21 13:10:31 +11:00
2e2bc8382f v1.8.1-rc.0 2024-11-20 23:02:32 +11:00
1f3a9b578b chore: add translations (#1485)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Release Notes

- **New Features**
- Enhanced German, Spanish, French, and Polish translations for various
document management and marketing phrases.

- **Improvements**
- Updated translations for clarity and accuracy across multiple
languages, including user notifications and document actions.
- Added new translation entries to improve user experience and
localization in the Documenso application.

- **Chores**
- Updated revision dates in translation files to reflect recent changes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2024-11-20 23:00:59 +11:00
83e7a3c222 fix: improve field sizing (#1486)
## Description

Allows for smaller field sizing in addition to improving our styling
when displaying labels on smaller fields.

This is the minimum currently supported field size until we perform a
more extensive refactor of our current drag and drop system.

## Related Issue

Reported via support channels

## Changes Made

- Updated our minimum size constraints
- Attempted to add a general autosizing component for text and failed
- Updated styling in a bunch of places to use the css `clamp()` method
for dynamic sizing.
2024-11-20 22:49:30 +11:00
9ef8b1f0c3 feat: automatically sign fields in large documents (#1484)
## Description

Adds a dialog that will display when a certain field threshold is
reached asking the user if they would like to sign non-critical fields
such as name, date, initials, and email with information that is already
available.

This has not been added to direct templates since we would often not
have all the pre-requisite knowledge since users are mostly anonymous.
Additionally, this has not been added to the embedding view since it may
detract from the experience for some.

Will not prompt the user if there is action authentication on the
document.

See the below demo:


https://github.com/user-attachments/assets/71739b5c-1323-4da9-89fd-a1145c9714d5

## Related Issue

#1281 (Older PR relating to the feature)

## Changes Made

- Added a new auto-sign dialog that will automatically trigger once
certain criteria is met.

## Testing Performed

- Tested that the dialog displays when the threshold is met
- Tested that the dialog is hidden when the threshold is not met
- Tested that the messaging during errors is correct
- Tested that the dialog does not display when 2FA or Passkeys are
required
2024-11-20 10:59:09 +11:00
0eff336175 v1.8.0-rc.4 2024-11-19 16:44:25 +11:00
9bdd5c31cc fix: sort recipients for template with signing order (#1468) 2024-11-18 15:54:51 +07:00
57ad7c150b chore: add translations (#1474) 2024-11-18 08:40:25 +11:00
b0829e6cdf v1.8.0-rc.3 2024-11-16 09:23:05 +11:00
08a446fefd feat: support windows for 2fa tokens (#1478)
## Description

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

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

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

## Related Issue

N/A

## Changes Made

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

## Testing Performed

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

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

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

## Related Issue


## Changes Made

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

## Testing Performed

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

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

## Related Issue

Fixes #1404

## Changes Made

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

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

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

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

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

## Changes Made

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

## Testing Performed

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

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

### Include Sender Details

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

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

Otherwise, the email will say:

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

### Default Document Visibility

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

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

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

#### Gotcha

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

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

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

## Testing Performed

Tested it locally.
2024-11-08 22:50:49 +11:00
f6bcf921d5 feat: add document distribution setting (#1437)
Add a document distribution setting which will allow us to further
configure how recipients currently receive documents.
2024-11-08 13:32:13 +09:00
451723a8ab chore: extract translations 2024-11-08 00:34:25 +09:00
9b769e7e33 fix: email translations (#1454) 2024-11-08 00:33:48 +09:00
286 changed files with 19477 additions and 4157 deletions

View File

@ -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=

View File

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

View File

@ -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"
}
}

View 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"
}

View 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)

View File

@ -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)

View File

@ -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} />
);
};
```

View File

@ -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}
/>
);
};
```

View File

@ -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} />
);
};
```

View File

@ -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}" />
```

View File

@ -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>
```

View File

@ -11,6 +11,10 @@ Digitally signing documents requires a signing certificate in `.p12` format. You
Follow the steps below to create a free, self-signed certificate for local development.
<Callout type="warning">
These steps should be run on a UNIX based system, otherwise you may run into an error.
</Callout>
<Steps>
### Generate Private Key

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/marketing",
"version": "1.7.2-rc.4",
"version": "1.8.1-rc.4",
"private": true,
"license": "AGPL-3.0",
"scripts": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.7.2-rc.4",
"version": "1.8.1-rc.4",
"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",

View File

@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
import { AlertTriangle, CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
@ -133,6 +133,11 @@ export const DocumentPageViewRecentActivity = ({
<CheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<MailOpen className="h-3 w-3" aria-hidden="true" />

View File

@ -4,7 +4,15 @@ import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
import {
AlertTriangle,
CheckIcon,
Clock,
MailIcon,
MailOpenIcon,
PenIcon,
PlusIcon,
} from 'lucide-react';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
@ -15,6 +23,7 @@ import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewRecipientsProps = {
@ -123,6 +132,26 @@ export const DocumentPageViewRecipients = ({
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.REJECTED && (
<PopoverHover
trigger={
<Badge variant="destructive">
<AlertTriangle className="mr-1 h-3 w-3" />
<Trans>Rejected</Trans>
</Badge>
}
>
<p className="text-sm">
<Trans>Reason for rejection: </Trans>
</p>
<p className="text-muted-foreground mt-1 text-sm">
{recipient.rejectionReason}
</p>
</PopoverHover>
)}
{document.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && (

View File

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

View File

@ -12,6 +12,7 @@ import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@ -177,8 +178,8 @@ export const EditDocumentForm = ({
stepIndex: 3,
},
subject: {
title: msg`Add Subject`,
description: msg`Add the subject and message you wish to send to signers.`,
title: msg`Distribute Document`,
description: msg`Choose how the document will reach recipients`,
stepIndex: 4,
},
};
@ -307,7 +308,7 @@ export const EditDocumentForm = ({
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message } = data.meta;
const { subject, message, distributionMethod, emailSettings } = data.meta;
try {
await sendDocument({
@ -316,16 +317,31 @@ export const EditDocumentForm = ({
meta: {
subject,
message,
distributionMethod,
emailSettings,
},
});
toast({
title: _(msg`Document sent`),
description: _(msg`Your document has been sent successfully.`),
duration: 5000,
});
if (distributionMethod === DocumentDistributionMethod.EMAIL) {
toast({
title: _(msg`Document sent`),
description: _(msg`Your document has been sent successfully.`),
duration: 5000,
});
router.push(documentRootPath);
router.push(documentRootPath);
return;
}
if (document.status === DocumentStatus.DRAFT) {
toast({
title: _(msg`Links Generated`),
description: _(msg`Signing links have been generated for this document.`),
duration: 5000,
});
} else {
router.push(`${documentRootPath}/${document.id}`);
}
} catch (err) {
console.error(err);

View File

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

View File

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

View File

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

View File

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

View File

@ -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`)}
/>
)}

View File

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

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -145,6 +145,7 @@ export const TemplatesDataTable = ({
<UseTemplateDialog
templateId={row.original.id}
templateSigningOrder={row.original.templateMeta?.signingOrder}
documentDistributionMethod={row.original.templateMeta?.distributionMethod}
recipients={row.original.Recipient}
documentRootPath={documentRootPath}
/>

View File

@ -17,7 +17,7 @@ import {
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import type { Recipient } from '@documenso/prisma/client';
import { DocumentSigningOrder } from '@documenso/prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -49,7 +49,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z
.object({
sendDocument: z.boolean(),
distributeDocument: z.boolean(),
recipients: z.array(
z.object({
id: z.number(),
@ -93,12 +93,14 @@ export type UseTemplateDialogProps = {
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
recipients: Recipient[];
documentDistributionMethod?: DocumentDistributionMethod;
documentRootPath: string;
trigger?: React.ReactNode;
};
export function UseTemplateDialog({
recipients,
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
documentRootPath,
templateId,
templateSigningOrder,
@ -116,7 +118,7 @@ export function UseTemplateDialog({
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
sendDocument: false,
distributeDocument: false,
recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => {
@ -147,7 +149,7 @@ export function UseTemplateDialog({
templateId,
teamId: team?.id,
recipients: data.recipients,
sendDocument: data.sendDocument,
distributeDocument: data.distributeDocument,
});
toast({
@ -156,7 +158,16 @@ export function UseTemplateDialog({
duration: 5000,
});
router.push(`${documentRootPath}/${id}`);
let documentPath = `${documentRootPath}/${id}`;
if (
data.distributeDocument &&
documentDistributionMethod === DocumentDistributionMethod.NONE
) {
documentPath += '?action=view-signing-links';
}
router.push(documentPath);
} catch (err) {
const error = AppError.parseError(err);
@ -295,43 +306,76 @@ export function UseTemplateDialog({
<div className="mt-4 flex flex-row items-center">
<FormField
control={form.control}
name="sendDocument"
name="distributeDocument"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="sendDocument"
id="distributeDocument"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="sendDocument"
>
<Trans>Send document</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="distributeDocument"
>
<Trans>Send document</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
{' '}
The document will be immediately sent to recipients if this is
checked.
</Trans>
</p>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
The document will be immediately sent to recipients if this is
checked.
</Trans>
</p>
<p>
<Trans>Otherwise, the document will be created as a draft.</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
<p>
<Trans>
Otherwise, the document will be created as a draft.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
)}
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="distributeDocument"
>
<Trans>Create as pending</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>Create the document as pending and ready to sign.</Trans>
</p>
<p>
<Trans>We won't send anything to notify recipients.</Trans>
</p>
<p className="mt-2">
<Trans>
We will generate signing links for you, which you can send to
the recipients through your method of choice.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
)}
</div>
</FormItem>
)}
@ -347,10 +391,12 @@ export function UseTemplateDialog({
</DialogClose>
<Button type="submit" loading={form.formState.isSubmitting}>
{form.getValues('sendDocument') ? (
{!form.getValues('distributeDocument') ? (
<Trans>Create as draft</Trans>
) : documentDistributionMethod === DocumentDistributionMethod.EMAIL ? (
<Trans>Create and send</Trans>
) : (
<Trans>Create as draft</Trans>
<Trans>Create signing links</Trans>
)}
</Button>
</DialogFooter>

View File

@ -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">

View File

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

View File

@ -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">

View File

@ -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) {

View File

@ -0,0 +1,237 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Plural, Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useForm } from 'react-hook-form';
import { P, match } from 'ts-pattern';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { Form } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SigningDisclosure } from '~/components/general/signing-disclosure';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider';
const AUTO_SIGNABLE_FIELD_TYPES: string[] = [
FieldType.NAME,
FieldType.INITIALS,
FieldType.EMAIL,
FieldType.DATE,
];
// The action auth types that are not allowed to be auto signed
//
// Reasoning: If the action auth is a passkey or 2FA, it's likely that the owner of the document
// intends on having the user manually sign due to the additional security measures employed for
// other field types.
const NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES: string[] = [
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
];
// The threshold for the number of fields that could be autosigned before displaying the dialog
//
// Reasoning: If there aren't that many fields, it's likely going to be easier to manually sign each one
// while for larger documents with many fields it will be beneficial to sign away the boilerplate fields.
const AUTO_SIGN_THRESHOLD = 5;
export type AutoSignProps = {
recipient: Pick<Recipient, 'id' | 'token'>;
fields: Field[];
};
export const AutoSign = ({ recipient, fields }: AutoSignProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const { email, fullName } = useRequiredSigningContext();
const { derivedRecipientActionAuth } = useRequiredDocumentAuthContext();
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const form = useForm();
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
const autoSignableFields = fields.filter((field) => {
if (field.inserted) {
return false;
}
if (!AUTO_SIGNABLE_FIELD_TYPES.includes(field.type)) {
return false;
}
if (field.type === FieldType.NAME && !fullName) {
return false;
}
if (field.type === FieldType.INITIALS && !fullName) {
return false;
}
if (field.type === FieldType.EMAIL && !email) {
return false;
}
return true;
});
const actionAuthAllowsAutoSign = !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(
derivedRecipientActionAuth ?? '',
);
const onSubmit = async () => {
const results = await Promise.allSettled(
autoSignableFields.map(async (field) => {
const value = match(field.type)
.with(FieldType.NAME, () => fullName)
.with(FieldType.INITIALS, () => extractInitials(fullName))
.with(FieldType.EMAIL, () => email)
.with(FieldType.DATE, () => new Date().toISOString())
.otherwise(() => '');
const authOptions = match(derivedRecipientActionAuth)
.with(DocumentAuth.ACCOUNT, () => ({
type: DocumentAuth.ACCOUNT,
}))
.with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE,
}))
.with(null, () => undefined)
.with(
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH),
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
() => 'NOT_SUPPORTED' as const,
)
.exhaustive();
if (authOptions === 'NOT_SUPPORTED') {
throw new Error('Action auth is not supported for auto signing');
}
if (!value) {
throw new Error('No value to sign');
}
return await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value,
isBase64: false,
authOptions,
});
}),
);
if (results.some((result) => result.status === 'rejected')) {
toast({
title: _(msg`Error`),
description: _(
msg`An error occurred while auto-signing the document, some fields may not be signed. Please review and manually sign any remaining fields.`,
),
duration: 5000,
variant: 'destructive',
});
}
startTransition(() => {
router.refresh();
setOpen(false);
});
};
unsafe_useEffectOnce(() => {
if (actionAuthAllowsAutoSign && autoSignableFields.length > AUTO_SIGN_THRESHOLD) {
setOpen(true);
}
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Automatically sign fields</DialogTitle>
</DialogHeader>
<div className="text-muted-foreground max-w-[50ch]">
<p>
<Trans>
When you sign a document, we can automatically fill in and sign the following fields
using information that has already been provided. You can also manually sign or remove
any automatically signed fields afterwards if you desire.
</Trans>
</p>
<ul className="mt-4 flex list-inside list-disc flex-col gap-y-0.5">
{AUTO_SIGNABLE_FIELD_TYPES.map((fieldType) => (
<li key={fieldType}>
<Trans>{_(FRIENDLY_FIELD_TYPE[fieldType as FieldType])}</Trans>
<span className="pl-2 text-sm">
(
<Plural
value={autoSignableFields.filter((f) => f.type === fieldType).length}
one="1 matching field"
other="# matching fields"
/>
)
</span>
</li>
))}
</ul>
</div>
<SigningDisclosure className="mt-4" />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogFooter className="flex w-full flex-1 flex-nowrap gap-2">
<Button
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
}}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
className="min-w-[6rem]"
loading={form.formState.isSubmitting || isPending}
disabled={!autoSignableFields.length}
>
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -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 */}

View File

@ -144,13 +144,13 @@ export const DateField = ({
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<Trans>Date</Trans>
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{localDateString}
</p>
)}

View File

@ -178,7 +178,7 @@ export const DropdownField = ({
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200">
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200 ">
<Select value={localChoice} onValueChange={handleSelectItem}>
<SelectTrigger
className={cn(
@ -190,7 +190,7 @@ export const DropdownField = ({
)}
>
<SelectValue
className="text-[clamp(0.625rem,1cqw,0.825rem)]"
className="text-[clamp(0.425rem,25cqw,0.825rem)]"
placeholder={`${_(msg`Select`)}`}
/>
</SelectTrigger>
@ -206,7 +206,7 @@ export const DropdownField = ({
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
)}

View File

@ -122,13 +122,13 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<Trans>Email</Trans>
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
)}

View File

@ -128,13 +128,13 @@ export const InitialsField = ({
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<Trans>Initials</Trans>
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
)}

View File

@ -172,7 +172,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
)}

View File

@ -252,14 +252,15 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
},
)}
>
<span className="flex items-center justify-center gap-x-1 text-sm">
<Hash className="h-4 w-4" /> {fieldDisplayName}
<span className="flex items-center justify-center gap-x-1">
<Hash className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />{' '}
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{fieldDisplayName}</span>
</span>
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
)}

View File

@ -99,6 +99,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const { documentMeta } = document;
if (recipient.signingStatus === SigningStatus.REJECTED) {
return redirect(`/sign/${token}/rejected`);
}
if (
document.status === DocumentStatus.COMPLETED ||
recipient.signingStatus === SigningStatus.SIGNED

View File

@ -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={{

View File

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

View File

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

View File

@ -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>

View File

@ -1,6 +1,6 @@
'use client';
import { useMemo, useState, useTransition } from 'react';
import { useLayoutEffect, useMemo, useRef, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
@ -51,6 +51,10 @@ export const SignatureField = ({
const { _ } = useLingui();
const { toast } = useToast();
const signatureRef = useRef<HTMLParagraphElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState(2);
const { signature: providedSignature, setSignature: setProvidedSignature } =
useRequiredSigningContext();
@ -108,6 +112,7 @@ export const SignatureField = ({
actionTarget: field.type,
});
};
const onSign = async (authOptions?: TRecipientActionAuth, signature?: string) => {
try {
const value = signature || providedSignature;
@ -117,11 +122,23 @@ export const SignatureField = ({
return;
}
const isTypedSignature = !value.startsWith('data:image');
if (isTypedSignature && !typedSignatureEnabled) {
toast({
title: _(msg`Error`),
description: _(msg`Typed signatures are not allowed. Please draw your signature.`),
variant: 'destructive',
});
return;
}
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value,
isBase64: true,
isBase64: !isTypedSignature,
authOptions,
};
@ -176,6 +193,41 @@ export const SignatureField = ({
}
};
useLayoutEffect(() => {
if (!signatureRef.current || !containerRef.current || !signature?.typedSignature) {
return;
}
const adjustTextSize = () => {
const container = containerRef.current;
const text = signatureRef.current;
if (!container || !text) {
return;
}
let size = 2;
text.style.fontSize = `${size}rem`;
while (
(text.scrollWidth > container.clientWidth || text.scrollHeight > container.clientHeight) &&
size > 0.8
) {
size -= 0.1;
text.style.fontSize = `${size}rem`;
}
setFontSize(size);
};
const resizeObserver = new ResizeObserver(adjustTextSize);
resizeObserver.observe(containerRef.current);
adjustTextSize();
return () => resizeObserver.disconnect();
}, [signature?.typedSignature]);
return (
<SigningFieldContainer
field={field}
@ -191,7 +243,7 @@ export const SignatureField = ({
)}
{state === 'empty' && (
<p className="group-hover:text-primary font-signature text-muted-foreground text-xl duration-200 group-hover:text-yellow-300">
<p className="group-hover:text-primary font-signature text-muted-foreground text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200 group-hover:text-yellow-300">
<Trans>Signature</Trans>
</p>
)}
@ -205,10 +257,15 @@ export const SignatureField = ({
)}
{state === 'signed-text' && (
<p className="font-signature text-muted-foreground dark:text-background text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{/* This optional chaining is intentional, we don't want to move the check into the condition above */}
{signature?.typedSignature}
</p>
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
<p
ref={signatureRef}
className="font-signature text-muted-foreground dark:text-background w-full overflow-hidden break-all text-center leading-tight duration-200"
style={{ fontSize: `${fontSize}rem` }}
>
{signature?.typedSignature}
</p>
</div>
)}
<Dialog open={showSignatureModal} onOpenChange={setShowSignatureModal}>

View File

@ -22,6 +22,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { AutoSign } from './auto-sign';
import { CheckboxField } from './checkbox-field';
import { DateField } from './date-field';
import { DropdownField } from './dropdown-field';
@ -31,6 +32,7 @@ import { InitialsField } from './initials-field';
import { NameField } from './name-field';
import { NumberField } from './number-field';
import { RadioField } from './radio-field';
import { RejectDocumentDialog } from './reject-document-dialog';
import { SignatureField } from './signature-field';
import { TextField } from './text-field';
@ -51,34 +53,66 @@ 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 items-center gap-x-6">
<p
className="text-muted-foreground truncate"
title={document.User.name ? document.User.name : ''}
>
{document.User.name}
</p>
</div>
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
<div 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, () =>
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)}
</span>
</div>
<p className="text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<Trans>({document.User.email}) has invited you to view this document</Trans>
))
.with(RecipientRole.SIGNER, () => (
<Trans>({document.User.email}) has invited you to sign this document</Trans>
))
.with(RecipientRole.APPROVER, () => (
<Trans>({document.User.email}) has invited you to approve this document</Trans>
))
.otherwise(() => null)}
</p>
<RejectDocumentDialog document={document} token={recipient.token} />
</div>
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
<Card
@ -108,6 +142,8 @@ export const SigningPageView = ({
<DocumentReadOnlyFields fields={completedFields} />
<AutoSign recipient={recipient} fields={fields} />
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)

View File

@ -252,14 +252,16 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
)}
>
<span className="flex items-center justify-center gap-x-1">
<Type />
{fieldDisplayName || <Trans>Text</Trans>}
<Type className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
{fieldDisplayName || <Trans>Text</Trans>}
</span>
</span>
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 15) + '...'}

View File

@ -0,0 +1,319 @@
'use client';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const ZTeamBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean(),
brandingLogo: z
.instanceof(File)
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
.refine(
(file) => ACCEPTED_FILE_TYPES.includes(file.type),
'Only .jpg, .png, and .webp files are accepted',
)
.nullish(),
brandingUrl: z.string().url().optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500).optional(),
});
type TTeamBrandingPreferencesFormSchema = z.infer<typeof ZTeamBrandingPreferencesFormSchema>;
export type TeamBrandingPreferencesFormProps = {
team: Team;
settings?: TeamGlobalSettings | null;
};
export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPreferencesFormProps) {
const { _ } = useLingui();
const { toast } = useToast();
const [previewUrl, setPreviewUrl] = useState<string>('');
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
const { mutateAsync: updateTeamBrandingSettings } =
trpc.team.updateTeamBrandingSettings.useMutation();
const form = useForm<TTeamBrandingPreferencesFormSchema>({
defaultValues: {
brandingEnabled: settings?.brandingEnabled ?? false,
brandingUrl: settings?.brandingUrl ?? '',
brandingLogo: undefined,
brandingCompanyDetails: settings?.brandingCompanyDetails ?? '',
},
resolver: zodResolver(ZTeamBrandingPreferencesFormSchema),
});
const isBrandingEnabled = form.watch('brandingEnabled');
const onSubmit = async (data: TTeamBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
let uploadedBrandingLogo = settings?.brandingLogo;
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
if (brandingLogo === null) {
uploadedBrandingLogo = '';
}
await updateTeamBrandingSettings({
teamId: team.id,
settings: {
brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl,
brandingCompanyDetails,
},
});
toast({
title: _(msg`Branding preferences updated`),
description: _(msg`Your branding preferences have been updated`),
});
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We were unable to update your branding preferences at this time, please try again later`,
),
variant: 'destructive',
});
}
};
useEffect(() => {
if (settings?.brandingLogo) {
const file = JSON.parse(settings.brandingLogo);
if ('type' in file && 'data' in file) {
void getFile(file).then((binaryData) => {
const objectUrl = URL.createObjectURL(new Blob([binaryData]));
setPreviewUrl(objectUrl);
setHasLoadedPreview(true);
});
return;
}
}
setHasLoadedPreview(true);
}, [settings?.brandingLogo]);
// Cleanup ObjectURL on unmount or when previewUrl changes
useEffect(() => {
return () => {
if (previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="brandingEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Enable Custom Branding</FormLabel>
<div>
<FormControl>
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormDescription>
<Trans>Enable custom branding for all documents in this team.</Trans>
</FormDescription>
</FormItem>
)}
/>
<div className="relative flex w-full flex-col gap-y-4">
{!isBrandingEnabled && <div className="bg-background/60 absolute inset-0 z-[9999]" />}
<FormField
control={form.control}
name="brandingLogo"
render={({ field: { value: _value, onChange, ...field } }) => (
<FormItem className="flex-1">
<FormLabel>Branding Logo</FormLabel>
<div className="flex flex-col gap-4">
<div className="border-border bg-background relative h-48 w-full overflow-hidden rounded-lg border">
{previewUrl ? (
<img
src={previewUrl}
alt="Logo preview"
className="h-full w-full object-contain p-4"
/>
) : (
<div className="bg-muted/20 dark:bg-muted text-muted-foreground relative flex h-full w-full items-center justify-center text-sm">
Please upload a logo
{!hasLoadedPreview && (
<div className="bg-muted dark:bg-muted absolute inset-0 z-[999] flex items-center justify-center">
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
)}
</div>
)}
</div>
<div className="relative">
<FormControl className="relative">
<Input
type="file"
accept={ACCEPTED_FILE_TYPES.join(',')}
disabled={!isBrandingEnabled}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
if (previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl);
}
const objectUrl = URL.createObjectURL(file);
setPreviewUrl(objectUrl);
onChange(file);
}
}}
className={cn(
'h-auto p-2',
'file:text-primary hover:file:bg-primary/90',
'file:mr-4 file:cursor-pointer file:rounded-md file:border-0',
'file:p-2 file:py-2 file:font-medium',
'file:bg-primary file:text-primary-foreground',
!isBrandingEnabled && 'cursor-not-allowed',
)}
{...field}
/>
</FormControl>
<div className="absolute right-2 top-0 inline-flex h-full items-center justify-center">
<Button
type="button"
variant="link"
size="sm"
className="text-destructive text-xs"
onClick={() => {
setPreviewUrl('');
onChange(null);
}}
>
<Trans>Remove</Trans>
</Button>
</div>
</div>
<FormDescription>
<Trans>Upload your brand logo (max 5MB, JPG, PNG, or WebP)</Trans>
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandingUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Brand Website</FormLabel>
<FormControl>
<Input
type="url"
placeholder="https://example.com"
disabled={!isBrandingEnabled}
{...field}
/>
</FormControl>
<FormDescription>
<Trans>Your brand website URL</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandingCompanyDetails"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Brand Details</FormLabel>
<FormControl>
<Textarea
placeholder={_(msg`Enter your brand details`)}
className="min-h-[100px] resize-y"
disabled={!isBrandingEnabled}
{...field}
/>
</FormControl>
<FormDescription>
<Trans>Additional brand information to display at the bottom of emails</Trans>
</FormDescription>
</FormItem>
)}
/>
</div>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Save</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
}

View File

@ -0,0 +1,312 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { DocumentVisibility } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
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>;
export type TeamDocumentPreferencesFormProps = {
team: Team;
settings?: TeamGlobalSettings | null;
};
export const TeamDocumentPreferencesForm = ({
team,
settings,
}: TeamDocumentPreferencesFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { data } = useSession();
const placeholderEmail = data?.user.email ?? 'user@example.com';
const { mutateAsync: updateTeamDocumentPreferences } =
trpc.team.updateTeamDocumentSettings.useMutation();
const form = useForm<TTeamDocumentPreferencesFormSchema>({
defaultValues: {
documentVisibility: settings?.documentVisibility ?? 'EVERYONE',
documentLanguage: isValidLanguageCode(settings?.documentLanguage)
? settings?.documentLanguage
: 'en',
includeSenderDetails: settings?.includeSenderDetails ?? false,
typedSignatureEnabled: settings?.typedSignatureEnabled ?? true,
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
},
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
});
const includeSenderDetails = form.watch('includeSenderDetails');
const onSubmit = async (data: TTeamDocumentPreferencesFormSchema) => {
try {
const {
documentVisibility,
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
} = data;
await updateTeamDocumentPreferences({
teamId: team.id,
settings: {
documentVisibility,
documentLanguage,
includeSenderDetails,
typedSignatureEnabled,
includeSigningCertificate,
},
});
toast({
title: _(msg`Document preferences updated`),
description: _(msg`Your document preferences have been updated`),
});
} catch (err) {
toast({
title: _(msg`Something went wrong!`),
description: _(
msg`We were unable to update your document preferences at this time, please try again later`,
),
});
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="documentVisibility"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Document Visibility</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>
<Trans>Everyone can access and view the document</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
<Trans>Only managers and above can access and view the document</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>
<Trans>Only admins can access and view the document</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>Controls the default visibility of an uploaded document.</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="documentLanguage"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Document Language</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls the default language of an uploaded document. This will be used as the
language in email communications with the recipients.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="includeSenderDetails"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Send on Behalf of Team</Trans>
</FormLabel>
<div>
<FormControl className="block">
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<div className="pt-2">
<div className="text-muted-foreground text-xs font-medium">
<Trans>Preview</Trans>
</div>
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
{includeSenderDetails ? (
<Trans>
"{placeholderEmail}" on behalf of "{team.name}" has invited you to sign
"example document".
</Trans>
) : (
<Trans>"{team.name}" has invited you to sign "example document".</Trans>
)}
</Alert>
</div>
<FormDescription>
<Trans>
Controls the formatting of the message that will be sent when inviting a
recipient to sign a document. If a custom message has been provided while
configuring the document, it will be used instead.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<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>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,52 @@
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { TeamBrandingPreferencesForm } from './branding-preferences';
import { TeamDocumentPreferencesForm } from './document-preferences';
export type TeamsSettingsPageProps = {
params: {
teamUrl: string;
};
};
export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
await setupI18nSSR();
const { _ } = useLingui();
const { teamUrl } = params;
const session = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
return (
<div>
<SettingsHeader
title={_(msg`Team Preferences`)}
subtitle={_(msg`Here you can set preferences and defaults for your team.`)}
/>
<section>
<TeamDocumentPreferencesForm team={team} settings={team.teamGlobalSettings} />
</section>
<SettingsHeader
title={_(msg`Branding Preferences`)}
subtitle={_(msg`Here you can set preferences and defaults for branding.`)}
className="mt-8"
/>
<section>
<TeamBrandingPreferencesForm team={team} settings={team.teamGlobalSettings} />
</section>
</div>
);
}

View File

@ -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({}),
});

View File

@ -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">

View 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;
};

View File

@ -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>
);
};

View File

@ -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>

View File

@ -58,6 +58,7 @@ export const EmbedDocumentFields = ({
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
typedSignatureEnabled={metadata?.typedSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => (

View File

@ -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>
);
};

View File

@ -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,
});
@ -74,6 +85,12 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
);
}
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>

View 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);
}
}
};

View File

@ -1,3 +1,4 @@
import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
const ZIndexes: { [key: string]: string } = {
@ -12,7 +13,7 @@ export type StackAvatarProps = {
first?: boolean;
zIndex?: string;
fallbackText?: string;
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
type: RecipientStatusType;
};
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
@ -25,18 +26,21 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
}
switch (type) {
case 'unsigned':
case RecipientStatusType.UNSIGNED:
classes = 'bg-dawn-200 text-dawn-900';
break;
case 'opened':
case RecipientStatusType.OPENED:
classes = 'bg-yellow-200 text-yellow-700';
break;
case 'waiting':
case RecipientStatusType.WAITING:
classes = 'bg-water text-water-700';
break;
case 'completed':
case RecipientStatusType.COMPLETED:
classes = 'bg-documenso-200 text-documenso-800';
break;
case RecipientStatusType.REJECTED:
classes = 'bg-red-200 text-red-800';
break;
default:
break;
}

View File

@ -8,7 +8,7 @@ import { useLingui } from '@lingui/react';
import { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
import { type DocumentStatus, type Recipient } from '@documenso/prisma/client';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { AvatarWithRecipient } from './avatar-with-recipient';
@ -46,7 +46,22 @@ export const StackAvatarsWithTooltip = ({
(recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED,
);
const sortedRecipients = useMemo(() => recipients.sort((a, b) => a.id - b.id), [recipients]);
const rejectedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
);
const sortedRecipients = useMemo(() => {
const otherRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
);
return [
...rejectedRecipients.sort((a, b) => a.id - b.id),
...otherRecipients.sort((a, b) => {
return a.id - b.id;
}),
];
}, [recipients]);
return (
<PopoverHover
@ -80,6 +95,30 @@ export const StackAvatarsWithTooltip = ({
</div>
)}
{rejectedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
<Trans>Rejected</Trans>
</h1>
{rejectedRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">

View File

@ -40,7 +40,6 @@ type TUpdateTeamFormSchema = z.infer<typeof ZUpdateTeamFormSchema>;
export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => {
const router = useRouter();
const { _ } = useLingui();
const { toast } = useToast();

View File

@ -6,7 +6,7 @@ import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { Braces, CreditCard, Globe2Icon, Settings, Users, Webhook } from 'lucide-react';
import { Braces, CreditCard, Globe2Icon, Settings, Settings2, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@ -26,6 +26,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
const settingsPath = `/t/${teamUrl}/settings`;
const preferencesPath = `/t/${teamUrl}/settings/preferences`;
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
@ -44,6 +45,20 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
<Link href={preferencesPath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(preferencesPath) && 'bg-secondary',
)}
>
<Settings2 className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
</Button>
</Link>
{isPublicProfileEnabled && (
<Link href={publicProfilePath}>
<Button

View File

@ -6,7 +6,7 @@ import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { Braces, CreditCard, Globe2Icon, Key, User, Webhook } from 'lucide-react';
import { Braces, CreditCard, Globe2Icon, Key, Settings2, User, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@ -26,6 +26,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
const settingsPath = `/t/${teamUrl}/settings`;
const preferencesPath = `/t/${teamUrl}/preferences`;
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
@ -52,6 +53,21 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
<Link href={preferencesPath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(preferencesPath) &&
pathname.split('/').length === 4 &&
'bg-secondary',
)}
>
<Settings2 className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
</Button>
</Link>
{isPublicProfileEnabled && (
<Link href={publicProfilePath}>
<Button

View File

@ -169,6 +169,7 @@ export const DocumentHistorySheet = ({
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM },
() => null,

View File

@ -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 />

View File

@ -0,0 +1,59 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import sharp from 'sharp';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { prisma } from '@documenso/prisma';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const teamId = Number(req.query['teamId']);
if (teamId === 0 || Number.isNaN(teamId)) {
return res.status(400).json({
status: 'error',
message: 'Invalid team ID',
});
}
const settings = await prisma.teamGlobalSettings.findFirst({
where: {
teamId,
},
});
if (!settings || !settings.brandingEnabled) {
return res.status(404).json({
status: 'error',
message: 'Not found',
});
}
if (!settings.brandingLogo) {
return res.status(404).json({
status: 'error',
message: 'Not found',
});
}
const file = await getFile(JSON.parse(settings.brandingLogo)).catch(() => null);
if (!file) {
return res.status(404).json({
status: 'error',
message: 'Not found',
});
}
const img = await sharp(file)
.toFormat('png', {
quality: 80,
})
.toBuffer();
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Length', img.length);
// Stale while revalidate for 1 hours to 24 hours
res.setHeader('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400');
res.status(200).send(img);
}

View File

@ -1,3 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildLogger } from '@documenso/lib/utils/logger';
import * as trpcNext from '@documenso/trpc/server/adapters/next';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
@ -11,7 +13,44 @@ export const config = {
},
};
const logger = buildLogger();
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
onError(opts) {
const { error, path } = opts;
// Currently trialing changes with template and team router only.
if (!path || (!path.startsWith('template') && !path.startsWith('team'))) {
return;
}
// Always log the error for now.
console.error(error);
const appError = AppError.parseError(error.cause || error);
const isAppError = error.cause instanceof AppError;
// Only log AppErrors that are explicitly set to 500 or the error code
// is in the errorCodesToAlertOn list.
const isLoggableAppError =
isAppError && (appError.statusCode === 500 || errorCodesToAlertOn.includes(appError.code));
// Only log TRPC errors that are in the `errorCodesToAlertOn` list and is
// not an AppError.
const isLoggableTrpcError = !isAppError && errorCodesToAlertOn.includes(error.code);
if (isLoggableAppError || isLoggableTrpcError) {
logger.error(error, {
method: path,
context: {
appError: AppError.toJSON(appError),
},
});
}
},
});
const errorCodesToAlertOn = [AppErrorCode.UNKNOWN_ERROR, 'INTERNAL_SERVER_ERROR'];

932
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "1.7.2-rc.4",
"version": "1.8.1-rc.4",
"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"
},

View File

@ -168,6 +168,9 @@ export const ApiContractV1 = c.router(
500: ZUnsuccessfulResponseSchema,
},
summary: 'Send a document for signing',
// I'm aware this should be in the variable itself, which it is, however it's difficult for users to find in our current UI.
description:
'Notes\n\n`sendEmail` - Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links. Defaults to true',
},
resendDocument: {

View File

@ -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),
});

View File

@ -3,7 +3,6 @@ import { z } from 'zod';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import '@documenso/lib/constants/time-zones';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ZUrlSchema } from '@documenso/lib/schemas/common';
import {
@ -11,9 +10,11 @@ import {
ZDocumentActionAuthTypesSchema,
ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import {
DocumentDataType,
DocumentDistributionMethod,
DocumentSigningOrder,
FieldType,
ReadStatus,
@ -67,7 +68,10 @@ export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocume
export const ZSendDocumentForSigningMutationSchema = z
.object({
sendEmail: z.boolean().optional().default(true),
sendEmail: z.boolean().optional().default(true).openapi({
description:
'Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links.',
}),
})
.or(z.literal('').transform(() => ({ sendEmail: true })));
@ -129,8 +133,13 @@ export const ZCreateDocumentMutationSchema = z.object({
redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
typedSignatureEnabled: z.boolean().optional().default(true),
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.partial(),
.partial()
.optional()
.default({}),
authOptions: z
.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
@ -223,14 +232,14 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
title: z.string().optional(),
externalId: z.string().nullish(),
externalId: z.string().optional(),
recipients: z
.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string().optional(),
email: z.string().email().min(1),
signingOrder: z.number().nullish(),
signingOrder: z.number().optional(),
}),
)
.refine(
@ -249,8 +258,11 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: ZUrlSchema,
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder),
language: z.enum(SUPPORTED_LANGUAGE_CODES),
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
typedSignatureEnabled: z.boolean(),
emailSettings: ZDocumentEmailSettingsSchema,
})
.partial()
.optional(),

View File

@ -36,7 +36,7 @@ test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
// Add signature.
const canvas = page.locator('canvas');
const canvas = page.locator('canvas').first();
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
@ -93,7 +93,7 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
// Add signature.
const canvas = page.locator('canvas');
const canvas = page.locator('canvas').first();
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
@ -262,7 +262,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
}
// Add signature.
const canvas = page.locator('canvas');
const canvas = page.locator('canvas').first();
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
@ -373,7 +373,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
}
// Add signature.
const canvas = page.locator('canvas');
const canvas = page.locator('canvas').first();
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);

View File

@ -106,7 +106,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
@ -190,7 +190,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
@ -287,7 +287,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
@ -566,7 +566,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');

View File

@ -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);
});
});

View File

@ -462,6 +462,45 @@ test('[TEAMS]: check document visibility based on team member role', async ({ pa
}
});
test('[TEAMS]: ensure document owner can see document regardless of visibility', async ({
page,
}) => {
const team = await seedTeam();
// Seed a member user
const memberUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
// Seed a document with ADMIN visibility but make the member user a recipient
await seedDocuments([
{
sender: memberUser,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Admin Document with Member Document Owner',
},
},
]);
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
// Check that the member user can see the document
await expect(
page.getByRole('link', { name: 'Admin Document with Member Document Owner', exact: true }),
).toBeVisible();
await apiSignout({ page });
});
test('[TEAMS]: ensure recipient can see document regardless of visibility', async ({ page }) => {
const team = await seedTeam();

View File

@ -0,0 +1,57 @@
import { expect, test } from '@playwright/test';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: update the default document visibility in the team global settings', async ({
page,
}) => {
const team = await seedTeam({
createTeamMembers: 1,
});
await apiSignin({
page,
email: team.owner.email,
password: 'password',
redirectPath: `/t/${team.url}/settings/preferences`,
});
// !: Brittle selector
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'Admin' }).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('Document preferences updated', { exact: true })).toBeVisible();
});
test('[TEAMS]: update the sender details in the team global settings', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
await apiSignin({
page,
email: team.owner.email,
password: 'password',
redirectPath: `/t/${team.url}/settings/preferences`,
});
const checkbox = page.getByLabel('Send on Behalf of Team');
await checkbox.check();
await expect(checkbox).toBeChecked();
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('Document preferences updated', { exact: true })).toBeVisible();
await expect(checkbox).toBeChecked();
});

View File

@ -29,7 +29,7 @@ test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
await page.getByLabel('Confirm by typing transfer').fill('transfer');
await page.getByRole('button', { name: 'Transfer' }).click();
await expect(page.locator('[id="\\:r2\\:-form-item-message"]')).toContainText(
await expect(page.locator('[id*="form-item-message"]').first()).toContainText(
`You must enter 'transfer ${team.name}' to proceed`,
);

View File

@ -18,7 +18,7 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
const canvas = page.locator('canvas');
const canvas = page.locator('canvas').first();
const box = await canvas.boundingBox();
if (box) {

View File

@ -12,7 +12,7 @@ test('[USER] update full name', async ({ page }) => {
await page.getByLabel('Full Name').fill('John Doe');
const canvas = page.locator('canvas');
const canvas = page.locator('canvas').first();
const box = await canvas.boundingBox();
if (box) {

View File

@ -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"

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