mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 16:23:06 +10:00
Merge branch 'main' into feat/bin-tab
This commit is contained in:
23
.github/actions/cache-build/action.yml
vendored
23
.github/actions/cache-build/action.yml
vendored
@ -1,23 +0,0 @@
|
|||||||
name: Cache production build binaries
|
|
||||||
description: 'Cache or restore if necessary'
|
|
||||||
inputs:
|
|
||||||
node_version:
|
|
||||||
required: false
|
|
||||||
default: v22.x
|
|
||||||
runs:
|
|
||||||
using: 'composite'
|
|
||||||
steps:
|
|
||||||
- name: Cache production build
|
|
||||||
uses: actions/cache@v3
|
|
||||||
id: production-build-cache
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
${{ github.workspace }}/apps/web/.next
|
|
||||||
**/.turbo/**
|
|
||||||
**/dist/**
|
|
||||||
|
|
||||||
key: prod-build-${{ github.run_id }}-${{ hashFiles('package-lock.json') }}
|
|
||||||
restore-keys: prod-build-
|
|
||||||
|
|
||||||
- run: npm run build
|
|
||||||
shell: bash
|
|
||||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@ -26,7 +26,8 @@ jobs:
|
|||||||
- name: Copy env
|
- name: Copy env
|
||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|
||||||
- uses: ./.github/actions/cache-build
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
build_docker:
|
build_docker:
|
||||||
name: Build Docker Image
|
name: Build Docker Image
|
||||||
|
|||||||
29
.github/workflows/clean-cache.yml
vendored
29
.github/workflows/clean-cache.yml
vendored
@ -1,29 +0,0 @@
|
|||||||
name: cleanup caches by a branch
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- closed
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cleanup:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Cleanup
|
|
||||||
run: |
|
|
||||||
gh extension install actions/gh-actions-cache
|
|
||||||
|
|
||||||
echo "Fetching list of cache key"
|
|
||||||
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 )
|
|
||||||
|
|
||||||
## Setting this to not fail the workflow while deleting cache keys.
|
|
||||||
set +e
|
|
||||||
echo "Deleting caches..."
|
|
||||||
for cacheKey in $cacheKeysForPR
|
|
||||||
do
|
|
||||||
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
|
|
||||||
done
|
|
||||||
echo "Done"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge
|
|
||||||
5
.github/workflows/codeql-analysis.yml
vendored
5
.github/workflows/codeql-analysis.yml
vendored
@ -10,7 +10,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze
|
name: Analyze
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
@ -30,7 +30,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: ./.github/actions/node-install
|
- uses: ./.github/actions/node-install
|
||||||
|
|
||||||
- uses: ./.github/actions/cache-build
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v3
|
||||||
|
|||||||
3
.github/workflows/e2e-tests.yml
vendored
3
.github/workflows/e2e-tests.yml
vendored
@ -28,7 +28,8 @@ jobs:
|
|||||||
- name: Seed the database
|
- name: Seed the database
|
||||||
run: npm run prisma:seed
|
run: npm run prisma:seed
|
||||||
|
|
||||||
- uses: ./.github/actions/cache-build
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: npm run ci
|
run: npm run ci
|
||||||
|
|||||||
@ -1,4 +1 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
npm run commitlint -- $1
|
npm run commitlint -- $1
|
||||||
|
|||||||
@ -1,6 +1,3 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
||||||
|
|
||||||
|
|||||||
@ -6,5 +6,6 @@
|
|||||||
"solid": "Solid Integration",
|
"solid": "Solid Integration",
|
||||||
"preact": "Preact Integration",
|
"preact": "Preact Integration",
|
||||||
"angular": "Angular Integration",
|
"angular": "Angular Integration",
|
||||||
"css-variables": "CSS Variables"
|
"css-variables": "CSS Variables",
|
||||||
}
|
"authoring": "Authoring"
|
||||||
|
}
|
||||||
167
apps/documentation/pages/developers/embedding/authoring.mdx
Normal file
167
apps/documentation/pages/developers/embedding/authoring.mdx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
title: Authoring
|
||||||
|
description: Learn how to use embedded authoring to create documents and templates in your application
|
||||||
|
---
|
||||||
|
|
||||||
|
# Embedded Authoring
|
||||||
|
|
||||||
|
In addition to embedding signing experiences, Documenso now supports embedded authoring, allowing you to integrate document and template creation directly within your application.
|
||||||
|
|
||||||
|
## How Embedded Authoring Works
|
||||||
|
|
||||||
|
The embedded authoring feature enables your users to create new documents without leaving your application. This process works through secure presign tokens that authenticate the embedding session and manage permissions.
|
||||||
|
|
||||||
|
## Creating Documents with Embedded Authoring
|
||||||
|
|
||||||
|
To implement document creation in your application, use the `EmbedCreateDocument` component from our SDK:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
|
||||||
|
|
||||||
|
const DocumentCreator = () => {
|
||||||
|
// You'll need to obtain a presign token using your API key
|
||||||
|
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '800px', width: '100%' }}>
|
||||||
|
<EmbedCreateDocument
|
||||||
|
presignToken={presignToken}
|
||||||
|
externalId="order-12345"
|
||||||
|
onDocumentCreated={(data) => {
|
||||||
|
console.log('Document created with ID:', data.documentId);
|
||||||
|
console.log('External reference ID:', data.externalId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Obtaining a Presign Token
|
||||||
|
|
||||||
|
Before using the `EmbedCreateDocument` component, you'll need to obtain a presign token from your backend. This token authorizes the embedding session.
|
||||||
|
|
||||||
|
You can create a presign token by making a request to:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v2-beta/embedding/create-presign-token
|
||||||
|
```
|
||||||
|
|
||||||
|
This API endpoint requires authentication with your Documenso API key. The token has a default expiration of 1 hour, but you can customize this duration based on your security requirements.
|
||||||
|
|
||||||
|
You can find more details on this request at our [API Documentation](https://openapi.documenso.com/reference#tag/embedding)
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
The `EmbedCreateDocument` component accepts several configuration options:
|
||||||
|
|
||||||
|
| Option | Type | Description |
|
||||||
|
| ------------------ | ------- | ------------------------------------------------------------------ |
|
||||||
|
| `presignToken` | string | **Required**. The authentication token for the embedding session. |
|
||||||
|
| `externalId` | string | Optional reference ID from your system to link with the document. |
|
||||||
|
| `host` | string | Optional custom host URL. Defaults to `https://app.documenso.com`. |
|
||||||
|
| `css` | string | Optional custom CSS to style the embedded component. |
|
||||||
|
| `cssVars` | object | Optional CSS variables for colors, spacing, and more. |
|
||||||
|
| `darkModeDisabled` | boolean | Optional flag to disable dark mode. |
|
||||||
|
| `className` | string | Optional CSS class name for the iframe. |
|
||||||
|
|
||||||
|
## Feature Toggles
|
||||||
|
|
||||||
|
You can customize the authoring experience by enabling or disabling specific features:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<EmbedCreateDocument
|
||||||
|
presignToken="YOUR_PRESIGN_TOKEN"
|
||||||
|
features={{
|
||||||
|
allowConfigureSignatureTypes: true,
|
||||||
|
allowConfigureLanguage: true,
|
||||||
|
allowConfigureDateFormat: true,
|
||||||
|
allowConfigureTimezone: true,
|
||||||
|
allowConfigureRedirectUrl: true,
|
||||||
|
allowConfigureCommunication: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Handling Document Creation Events
|
||||||
|
|
||||||
|
The `onDocumentCreated` callback is triggered when a document is successfully created, providing both the document ID and your external reference ID:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<EmbedCreateDocument
|
||||||
|
presignToken="YOUR_PRESIGN_TOKEN"
|
||||||
|
externalId="order-12345"
|
||||||
|
onDocumentCreated={(data) => {
|
||||||
|
// Navigate to a success page
|
||||||
|
navigate(`/documents/success?id=${data.documentId}`);
|
||||||
|
|
||||||
|
// Or update your database with the document ID
|
||||||
|
updateOrderDocument(data.externalId, data.documentId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling the Embedded Component
|
||||||
|
|
||||||
|
You can customize the appearance of the embedded component using standard CSS classes:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<EmbedCreateDocument
|
||||||
|
className="h-screen w-full rounded-lg border-none shadow-md"
|
||||||
|
presignToken="YOUR_PRESIGN_TOKEN"
|
||||||
|
css={`
|
||||||
|
.documenso-embed {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
cssVars={{
|
||||||
|
primary: '#0000FF',
|
||||||
|
background: '#F5F5F5',
|
||||||
|
radius: '8px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Integration Example
|
||||||
|
|
||||||
|
Here's a complete example of integrating document creation in a React application:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
|
||||||
|
|
||||||
|
function DocumentCreator() {
|
||||||
|
// In a real application, you would fetch this token from your backend
|
||||||
|
// using your API key at /api/v2-beta/embedding/create-presign-token
|
||||||
|
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||||
|
const [documentId, setDocumentId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
if (documentId) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Document Created Successfully!</h2>
|
||||||
|
<p>Document ID: {documentId}</p>
|
||||||
|
<button onClick={() => setDocumentId(null)}>Create Another Document</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '800px', width: '100%' }}>
|
||||||
|
<EmbedCreateDocument
|
||||||
|
presignToken={presignToken}
|
||||||
|
externalId="order-12345"
|
||||||
|
onDocumentCreated={(data) => {
|
||||||
|
setDocumentId(data.documentId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DocumentCreator;
|
||||||
|
```
|
||||||
|
|
||||||
|
With embedded authoring, your users can seamlessly create documents within your application, enhancing the overall user experience and streamlining document workflows.
|
||||||
@ -169,6 +169,19 @@ 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.
|
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.
|
||||||
|
|
||||||
|
## Embedded Authoring
|
||||||
|
|
||||||
|
In addition to embedding signing experiences, Documenso now supports **embedded authoring**, allowing your users to create documents and templates directly within your application.
|
||||||
|
|
||||||
|
With embedded authoring, you can:
|
||||||
|
|
||||||
|
- Create new documents with custom fields
|
||||||
|
- Configure document properties and settings
|
||||||
|
- Set up recipients and signing workflows
|
||||||
|
- Customize the authoring experience
|
||||||
|
|
||||||
|
For detailed implementation instructions and code examples, see our [Embedded Authoring](/developers/embedding/authoring) guide.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [React Integration](/developers/embedding/react)
|
- [React Integration](/developers/embedding/react)
|
||||||
@ -178,3 +191,4 @@ If you're using **web components**, the integration process is slightly differen
|
|||||||
- [Preact Integration](/developers/embedding/preact)
|
- [Preact Integration](/developers/embedding/preact)
|
||||||
- [Angular Integration](/developers/embedding/angular)
|
- [Angular Integration](/developers/embedding/angular)
|
||||||
- [CSS Variables](/developers/embedding/css-variables)
|
- [CSS Variables](/developers/embedding/css-variables)
|
||||||
|
- [Embedded Authoring](/developers/embedding/authoring)
|
||||||
|
|||||||
@ -532,3 +532,93 @@ Replace the `text` value with the corresponding field type:
|
|||||||
- For the `SELECT` field it should be `select`. (check this before merge)
|
- For the `SELECT` field it should be `select`. (check this before merge)
|
||||||
|
|
||||||
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.
|
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.
|
||||||
|
|
||||||
|
## Pre-fill Fields On Document Creation
|
||||||
|
|
||||||
|
The API allows you to pre-fill fields on document creation. This is useful when you want to create a document from an existing template and pre-fill the fields with specific values.
|
||||||
|
|
||||||
|
To pre-fill a field, you need to make a `POST` request to the `/api/v1/templates/{templateId}/generate-document` endpoint with the field information. Here's an example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "my-document.pdf",
|
||||||
|
"recipients": [
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "Example User",
|
||||||
|
"email": "example@documenso.com",
|
||||||
|
"signingOrder": 1,
|
||||||
|
"role": "SIGNER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prefillFields": [
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"type": "text",
|
||||||
|
"label": "my-label",
|
||||||
|
"placeholder": "my-placeholder",
|
||||||
|
"value": "my-value"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"type": "number",
|
||||||
|
"label": "my-label",
|
||||||
|
"placeholder": "my-placeholder",
|
||||||
|
"value": "123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"type": "checkbox",
|
||||||
|
"label": "my-label",
|
||||||
|
"placeholder": "my-placeholder",
|
||||||
|
"value": ["option-1", "option-2"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the endpoint in the [API V1 documentation](https://app.documenso.com/api/v1/openapi#:~:text=/%7BtemplateId%7D/-,generate,-%2Ddocument).
|
||||||
|
|
||||||
|
### API V2
|
||||||
|
|
||||||
|
For API V2, you need to make a `POST` request to the `/api/v2-beta/template/use` endpoint with the field(s) information. Here's an example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"templateId": 111,
|
||||||
|
"recipients": [
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "Example User",
|
||||||
|
"email": "example@documenso.com",
|
||||||
|
"signingOrder": 1,
|
||||||
|
"role": "SIGNER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prefillFields": [
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"type": "text",
|
||||||
|
"label": "my-label",
|
||||||
|
"placeholder": "my-placeholder",
|
||||||
|
"value": "my-value"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"type": "number",
|
||||||
|
"label": "my-label",
|
||||||
|
"placeholder": "my-placeholder",
|
||||||
|
"value": "123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"type": "checkbox",
|
||||||
|
"label": "my-label",
|
||||||
|
"placeholder": "my-placeholder",
|
||||||
|
"value": ["option-1", "option-2"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the endpoint in the [API V2 documentation](https://openapi.documenso.com/reference#tag/template/POST/template/use).
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import NextPlausibleProvider from 'next-plausible';
|
import NextPlausibleProvider from 'next-plausible';
|
||||||
|
|||||||
54
apps/openpage-api/lib/add-zero-month.ts
Normal file
54
apps/openpage-api/lib/add-zero-month.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
export interface TransformedData {
|
||||||
|
labels: string[];
|
||||||
|
datasets: Array<{
|
||||||
|
label: string;
|
||||||
|
data: number[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addZeroMonth(transformedData: TransformedData): TransformedData {
|
||||||
|
const result = {
|
||||||
|
labels: [...transformedData.labels],
|
||||||
|
datasets: transformedData.datasets.map((dataset) => ({
|
||||||
|
label: dataset.label,
|
||||||
|
data: [...dataset.data],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.labels.length === 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.datasets.every((dataset) => dataset.data[0] === 0)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let firstMonth = DateTime.fromFormat(result.labels[0], 'MMM yyyy');
|
||||||
|
if (!firstMonth.isValid) {
|
||||||
|
const formats = ['MMM yyyy', 'MMMM yyyy', 'MM/yyyy', 'yyyy-MM'];
|
||||||
|
|
||||||
|
for (const format of formats) {
|
||||||
|
firstMonth = DateTime.fromFormat(result.labels[0], format);
|
||||||
|
if (firstMonth.isValid) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstMonth.isValid) {
|
||||||
|
console.warn(`Could not parse date: "${result.labels[0]}"`);
|
||||||
|
return transformedData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeroMonth = firstMonth.minus({ months: 1 }).toFormat('MMM yyyy');
|
||||||
|
result.labels.unshift(zeroMonth);
|
||||||
|
result.datasets.forEach((dataset) => {
|
||||||
|
dataset.data.unshift(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return transformedData;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,8 @@ import { DateTime } from 'luxon';
|
|||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { addZeroMonth } from '../add-zero-month';
|
||||||
|
|
||||||
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
.selectFrom('Document')
|
.selectFrom('Document')
|
||||||
@ -35,7 +37,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return transformedData;
|
return addZeroMonth(transformedData);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { DateTime } from 'luxon';
|
|||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { addZeroMonth } from '../add-zero-month';
|
||||||
|
|
||||||
export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
.selectFrom('Recipient')
|
.selectFrom('Recipient')
|
||||||
@ -34,7 +36,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return transformedData;
|
return addZeroMonth(transformedData);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetSignerConversionMonthlyResult = Awaited<
|
export type GetSignerConversionMonthlyResult = Awaited<
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { DateTime } from 'luxon';
|
|||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { addZeroMonth } from '../add-zero-month';
|
||||||
|
|
||||||
export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count') => {
|
export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count') => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
.selectFrom('User')
|
.selectFrom('User')
|
||||||
@ -32,7 +34,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return transformedData;
|
return addZeroMonth(transformedData);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
|
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { addZeroMonth } from './add-zero-month';
|
||||||
|
|
||||||
type MetricKeys = {
|
type MetricKeys = {
|
||||||
stars: number;
|
stars: number;
|
||||||
forks: number;
|
forks: number;
|
||||||
@ -37,31 +39,77 @@ export function transformData({
|
|||||||
data: DataEntry;
|
data: DataEntry;
|
||||||
metric: MetricKey;
|
metric: MetricKey;
|
||||||
}): TransformData {
|
}): TransformData {
|
||||||
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
|
try {
|
||||||
const [yearA, monthA] = dateA.split('-').map(Number);
|
if (!data || Object.keys(data).length === 0) {
|
||||||
const [yearB, monthB] = dateB.split('-').map(Number);
|
return {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
|
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
|
||||||
});
|
try {
|
||||||
|
const [yearA, monthA] = dateA.split('-').map(Number);
|
||||||
|
const [yearB, monthB] = dateB.split('-').map(Number);
|
||||||
|
|
||||||
const labels = sortedEntries.map(([date]) => {
|
if (isNaN(yearA) || isNaN(monthA) || isNaN(yearB) || isNaN(monthB)) {
|
||||||
const [year, month] = date.split('-');
|
console.warn(`Invalid date format: ${dateA} or ${dateB}`);
|
||||||
const dateTime = DateTime.fromObject({
|
return 0;
|
||||||
year: Number(year),
|
}
|
||||||
month: Number(month),
|
|
||||||
|
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sorting entries:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return dateTime.toFormat('MMM yyyy');
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
const labels = sortedEntries.map(([date]) => {
|
||||||
labels,
|
try {
|
||||||
datasets: [
|
const [year, month] = date.split('-');
|
||||||
{
|
|
||||||
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
|
if (!year || !month || isNaN(Number(year)) || isNaN(Number(month))) {
|
||||||
data: sortedEntries.map(([_, stats]) => stats[metric]),
|
console.warn(`Invalid date format: ${date}`);
|
||||||
},
|
return date;
|
||||||
],
|
}
|
||||||
};
|
|
||||||
|
const dateTime = DateTime.fromObject({
|
||||||
|
year: Number(year),
|
||||||
|
month: Number(month),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dateTime.isValid) {
|
||||||
|
console.warn(`Invalid DateTime object for: ${date}`);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateTime.toFormat('MMM yyyy');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error, date);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformedData = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
|
||||||
|
data: sortedEntries.map(([_, stats]) => {
|
||||||
|
const value = stats[metric];
|
||||||
|
return typeof value === 'number' && !isNaN(value) ? value : 0;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return addZeroMonth(transformedData);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// To be on the safer side
|
// To be on the safer side
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
# Exit on error.
|
# Exit on error.
|
||||||
set -eo pipefail
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
WEB_APP_DIR="$SCRIPT_DIR/.."
|
WEB_APP_DIR="$SCRIPT_DIR/.."
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -9,64 +12,171 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
|
export type NextSigner = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ConfirmationDialogProps = {
|
type ConfirmationDialogProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: (nextSigner?: NextSigner) => void;
|
||||||
hasUninsertedFields: boolean;
|
hasUninsertedFields: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
|
allowDictateNextSigner?: boolean;
|
||||||
|
defaultNextSigner?: NextSigner;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ZNextSignerFormSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||||
|
|
||||||
export function AssistantConfirmationDialog({
|
export function AssistantConfirmationDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
hasUninsertedFields,
|
hasUninsertedFields,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
allowDictateNextSigner = false,
|
||||||
|
defaultNextSigner,
|
||||||
}: ConfirmationDialogProps) {
|
}: ConfirmationDialogProps) {
|
||||||
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
|
resolver: zodResolver(ZNextSignerFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const onOpenChange = () => {
|
const onOpenChange = () => {
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Validate the form and submit it if dictate signer is enabled.
|
||||||
|
if (allowDictateNextSigner) {
|
||||||
|
void form.handleSubmit(onConfirm)();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirm();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<Form {...form}>
|
||||||
<DialogTitle>
|
<form>
|
||||||
<Trans>Complete Document</Trans>
|
<fieldset disabled={isSubmitting} className="border-none p-0">
|
||||||
</DialogTitle>
|
<DialogHeader>
|
||||||
<DialogDescription>
|
<DialogTitle>
|
||||||
<Trans>
|
<Trans>Complete Document</Trans>
|
||||||
Are you sure you want to complete the document? This action cannot be undone. Please
|
</DialogTitle>
|
||||||
ensure that you have completed prefilling all relevant fields before proceeding.
|
<DialogDescription>
|
||||||
</Trans>
|
<Trans>
|
||||||
</DialogDescription>
|
Are you sure you want to complete the document? This action cannot be undone.
|
||||||
</DialogHeader>
|
Please ensure that you have completed prefilling all relevant fields before
|
||||||
|
proceeding.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
<DocumentSigningDisclosure />
|
{allowDictateNextSigner && (
|
||||||
</div>
|
<div className="my-2">
|
||||||
|
<p className="text-muted-foreground mb-1 text-sm font-semibold">
|
||||||
|
The next recipient to sign this document will be{' '}
|
||||||
|
</p>
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<div className="flex flex-col gap-4 rounded-xl border p-4 md:flex-row">
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
<FormField
|
||||||
Cancel
|
control={form.control}
|
||||||
</Button>
|
name="name"
|
||||||
<Button
|
render={({ field }) => (
|
||||||
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
<FormItem className="flex-1">
|
||||||
onClick={onConfirm}
|
<FormLabel>
|
||||||
disabled={isSubmitting}
|
<Trans>Name</Trans>
|
||||||
loading={isSubmitting}
|
</FormLabel>
|
||||||
>
|
<FormControl>
|
||||||
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
|
<Input
|
||||||
</Button>
|
{...field}
|
||||||
</DialogFooter>
|
className="mt-2"
|
||||||
|
placeholder="Enter the next signer's name"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="email"
|
||||||
|
className="mt-2"
|
||||||
|
placeholder="Enter the next signer's email"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DocumentSigningDisclosure className="mt-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
{hasUninsertedFields ? <Trans>Proceed</Trans> : <Trans>Continue</Trans>}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,355 @@
|
|||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { DocumentDistributionMethod } from '@prisma/client';
|
||||||
|
import { InfoIcon } from 'lucide-react';
|
||||||
|
import type { Control } from 'react-hook-form';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
||||||
|
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
|
||||||
|
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||||
|
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
|
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
||||||
|
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||||
|
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
|
import { useConfigureDocument } from './configure-document-context';
|
||||||
|
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||||
|
|
||||||
|
interface ConfigureDocumentAdvancedSettingsProps {
|
||||||
|
control: Control<TConfigureEmbedFormSchema>;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfigureDocumentAdvancedSettings = ({
|
||||||
|
control,
|
||||||
|
isSubmitting,
|
||||||
|
}: ConfigureDocumentAdvancedSettingsProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const form = useFormContext<TConfigureEmbedFormSchema>();
|
||||||
|
const { features } = useConfigureDocument();
|
||||||
|
|
||||||
|
const { watch, setValue } = form;
|
||||||
|
|
||||||
|
// Lift watch() calls to reduce re-renders
|
||||||
|
const distributionMethod = watch('meta.distributionMethod');
|
||||||
|
const emailSettings = watch('meta.emailSettings');
|
||||||
|
const isEmailDistribution = distributionMethod === DocumentDistributionMethod.EMAIL;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground mb-1 text-lg font-medium">
|
||||||
|
<Trans>Advanced Settings</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-6 text-sm">
|
||||||
|
<Trans>Configure additional options and preferences</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Tabs defaultValue="general">
|
||||||
|
<TabsList className="mb-6 inline-flex">
|
||||||
|
<TabsTrigger value="general" className="px-4">
|
||||||
|
<Trans>General</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
{features.allowConfigureCommunication && (
|
||||||
|
<TabsTrigger value="communication" className="px-4">
|
||||||
|
<Trans>Communication</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="general" className="mt-0">
|
||||||
|
<div className="flex flex-col space-y-6">
|
||||||
|
{/* <FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.externalId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex flex-row items-center">
|
||||||
|
<Trans>External ID</Trans>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||||
|
<Trans>
|
||||||
|
Add an external ID to the document. This can be used to identify the
|
||||||
|
document in external systems.
|
||||||
|
</Trans>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} disabled={isSubmitting} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
{features.allowConfigureSignatureTypes && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.signatureTypes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Allowed Signature Types</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
||||||
|
label: _(option.label),
|
||||||
|
value: option.value,
|
||||||
|
}))}
|
||||||
|
selectedValues={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
className="bg-background w-full"
|
||||||
|
emptySelectionPlaceholder="Select signature types"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.allowConfigureLanguage && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.language"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Language</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
|
||||||
|
<SelectTrigger className="bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
|
||||||
|
<SelectItem key={code} value={code}>
|
||||||
|
{language.full}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.allowConfigureDateFormat && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.dateFormat"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Date Format</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
|
||||||
|
<SelectTrigger className="bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DATE_FORMATS.map((format) => (
|
||||||
|
<SelectItem key={format.key} value={format.value}>
|
||||||
|
{format.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.allowConfigureTimezone && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.timezone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Time Zone</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Combobox
|
||||||
|
className="bg-background"
|
||||||
|
options={TIME_ZONES}
|
||||||
|
{...field}
|
||||||
|
onChange={(value) => value && field.onChange(value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.allowConfigureRedirectUrl && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.redirectUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex flex-row items-center">
|
||||||
|
<Trans>Redirect URL</Trans>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||||
|
<Trans>
|
||||||
|
Add a URL to redirect the user to once the document is signed
|
||||||
|
</Trans>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} disabled={isSubmitting} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{features.allowConfigureCommunication && (
|
||||||
|
<TabsContent value="communication" className="mt-0">
|
||||||
|
<div className="flex flex-col space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.distributionMethod"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Distribution Method</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
|
||||||
|
<SelectTrigger className="bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={DocumentDistributionMethod.EMAIL}>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={DocumentDistributionMethod.NONE}>
|
||||||
|
<Trans>None</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>
|
||||||
|
Choose how to distribute your document to recipients. Email will send
|
||||||
|
notifications, None will generate signing links for manual distribution.
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
className="flex flex-col space-y-6 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={!isEmailDistribution}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.subject"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="subject">
|
||||||
|
<Trans>
|
||||||
|
Subject <span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
disabled={isSubmitting || !isEmailDistribution}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.message"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="message">
|
||||||
|
<Trans>
|
||||||
|
Message <span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
id="message"
|
||||||
|
className="bg-background mt-2 h-32 resize-none"
|
||||||
|
disabled={isSubmitting || !isEmailDistribution}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DocumentSendEmailMessageHelper />
|
||||||
|
|
||||||
|
<DocumentEmailCheckboxes
|
||||||
|
className={`mt-2 ${!isEmailDistribution ? 'pointer-events-none' : ''}`}
|
||||||
|
value={emailSettings}
|
||||||
|
onChange={(value) => setValue('meta.emailSettings', value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export type ConfigureDocumentContext = {
|
||||||
|
// General
|
||||||
|
isTemplate: boolean;
|
||||||
|
isPersisted: boolean;
|
||||||
|
// Features
|
||||||
|
features: {
|
||||||
|
allowConfigureSignatureTypes?: boolean;
|
||||||
|
allowConfigureLanguage?: boolean;
|
||||||
|
allowConfigureDateFormat?: boolean;
|
||||||
|
allowConfigureTimezone?: boolean;
|
||||||
|
allowConfigureRedirectUrl?: boolean;
|
||||||
|
allowConfigureCommunication?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConfigureDocumentContext = createContext<ConfigureDocumentContext | null>(null);
|
||||||
|
|
||||||
|
export type ConfigureDocumentProviderProps = {
|
||||||
|
isTemplate?: boolean;
|
||||||
|
isPersisted?: boolean;
|
||||||
|
features: {
|
||||||
|
allowConfigureSignatureTypes?: boolean;
|
||||||
|
allowConfigureLanguage?: boolean;
|
||||||
|
allowConfigureDateFormat?: boolean;
|
||||||
|
allowConfigureTimezone?: boolean;
|
||||||
|
allowConfigureRedirectUrl?: boolean;
|
||||||
|
allowConfigureCommunication?: boolean;
|
||||||
|
};
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConfigureDocumentProvider = ({
|
||||||
|
isTemplate,
|
||||||
|
isPersisted,
|
||||||
|
features,
|
||||||
|
children,
|
||||||
|
}: ConfigureDocumentProviderProps) => {
|
||||||
|
return (
|
||||||
|
<ConfigureDocumentContext.Provider
|
||||||
|
value={{
|
||||||
|
isTemplate: isTemplate ?? false,
|
||||||
|
isPersisted: isPersisted ?? false,
|
||||||
|
features: {
|
||||||
|
allowConfigureSignatureTypes: features.allowConfigureSignatureTypes ?? true,
|
||||||
|
allowConfigureLanguage: features.allowConfigureLanguage ?? true,
|
||||||
|
allowConfigureDateFormat: features.allowConfigureDateFormat ?? true,
|
||||||
|
allowConfigureTimezone: features.allowConfigureTimezone ?? true,
|
||||||
|
allowConfigureRedirectUrl: features.allowConfigureRedirectUrl ?? true,
|
||||||
|
allowConfigureCommunication: features.allowConfigureCommunication ?? true,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ConfigureDocumentContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConfigureDocument = () => {
|
||||||
|
const context = useContext(ConfigureDocumentContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useConfigureDocument must be used within a ConfigureDocumentProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@ -0,0 +1,393 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
|
||||||
|
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { GripVertical, HelpCircle, Plus, Trash } from 'lucide-react';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { Control } from 'react-hook-form';
|
||||||
|
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
|
import { useConfigureDocument } from './configure-document-context';
|
||||||
|
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||||
|
|
||||||
|
// Define a type for signer items
|
||||||
|
type SignerItem = TConfigureEmbedFormSchema['signers'][number];
|
||||||
|
|
||||||
|
export interface ConfigureDocumentRecipientsProps {
|
||||||
|
control: Control<TConfigureEmbedFormSchema>;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfigureDocumentRecipients = ({
|
||||||
|
control,
|
||||||
|
isSubmitting,
|
||||||
|
}: ConfigureDocumentRecipientsProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { isTemplate } = useConfigureDocument();
|
||||||
|
|
||||||
|
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
fields: signers,
|
||||||
|
append: appendSigner,
|
||||||
|
remove: removeSigner,
|
||||||
|
replace,
|
||||||
|
move,
|
||||||
|
} = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: 'signers',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getValues, watch } = useFormContext<TConfigureEmbedFormSchema>();
|
||||||
|
|
||||||
|
const signingOrder = watch('meta.signingOrder');
|
||||||
|
|
||||||
|
const { errors } = useFormState({
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onAddSigner = useCallback(() => {
|
||||||
|
const signerNumber = signers.length + 1;
|
||||||
|
|
||||||
|
appendSigner({
|
||||||
|
formId: nanoid(8),
|
||||||
|
name: isTemplate ? `Recipient ${signerNumber}` : '',
|
||||||
|
email: isTemplate ? `recipient.${signerNumber}@document.com` : '',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1,
|
||||||
|
});
|
||||||
|
}, [appendSigner, signers]);
|
||||||
|
|
||||||
|
const isSigningOrderEnabled = signingOrder === DocumentSigningOrder.SEQUENTIAL;
|
||||||
|
|
||||||
|
const handleSigningOrderChange = useCallback(
|
||||||
|
(index: number, newOrderString: string) => {
|
||||||
|
const trimmedOrderString = newOrderString.trim();
|
||||||
|
if (!trimmedOrderString) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOrder = Number(trimmedOrderString);
|
||||||
|
if (!Number.isInteger(newOrder) || newOrder < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current form values to preserve unsaved input data
|
||||||
|
const currentSigners = getValues('signers') || [...signers];
|
||||||
|
const signer = currentSigners[index];
|
||||||
|
|
||||||
|
// Remove signer from current position and insert at new position
|
||||||
|
const remainingSigners = currentSigners.filter((_: unknown, idx: number) => idx !== index);
|
||||||
|
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
|
||||||
|
remainingSigners.splice(newPosition, 0, signer);
|
||||||
|
|
||||||
|
// Update signing order for each item
|
||||||
|
const updatedSigners = remainingSigners.map((s: SignerItem, idx: number) => ({
|
||||||
|
...s,
|
||||||
|
signingOrder: idx + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update the form
|
||||||
|
replace(updatedSigners);
|
||||||
|
},
|
||||||
|
[signers, replace, getValues],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragEnd = useCallback(
|
||||||
|
(result: DropResult) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
|
||||||
|
// Use the move function from useFieldArray which preserves input values
|
||||||
|
move(result.source.index, result.destination.index);
|
||||||
|
|
||||||
|
// Update signing orders after move
|
||||||
|
const currentSigners = getValues('signers');
|
||||||
|
const updatedSigners = currentSigners.map((signer: SignerItem, index: number) => ({
|
||||||
|
...signer,
|
||||||
|
signingOrder: index + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update the form with new ordering
|
||||||
|
replace(updatedSigners);
|
||||||
|
},
|
||||||
|
[move, replace, getValues],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground mb-1 text-lg font-medium">
|
||||||
|
<Trans>Recipients</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-6 text-sm">
|
||||||
|
<Trans>Add signers and configure signing preferences</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.signingOrder"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
{...field}
|
||||||
|
id="signingOrder"
|
||||||
|
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
field.onChange(
|
||||||
|
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel
|
||||||
|
htmlFor="signingOrder"
|
||||||
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
<Trans>Enable signing order</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="meta.allowDictateNextSigner"
|
||||||
|
render={({ field: { value, ...field } }) => (
|
||||||
|
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
{...field}
|
||||||
|
id="allowDictateNextSigner"
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
disabled={isSubmitting || !isSigningOrderEnabled}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FormLabel
|
||||||
|
htmlFor="allowDictateNextSigner"
|
||||||
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
<Trans>Allow signers to dictate next signer</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="text-muted-foreground ml-1 cursor-help">
|
||||||
|
<HelpCircle className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-80 p-4">
|
||||||
|
<p>
|
||||||
|
<Trans>
|
||||||
|
When enabled, signers can choose who should sign next in the sequence instead
|
||||||
|
of following the predefined order.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DragDropContext
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
sensors={[
|
||||||
|
(api: SensorAPI) => {
|
||||||
|
$sensorApi.current = api;
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Droppable droppableId="signers">
|
||||||
|
{(provided) => (
|
||||||
|
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
|
||||||
|
{signers.map((signer, index) => (
|
||||||
|
<Draggable
|
||||||
|
key={signer.id}
|
||||||
|
draggableId={signer.id}
|
||||||
|
index={index}
|
||||||
|
isDragDisabled={!isSigningOrderEnabled || isSubmitting}
|
||||||
|
>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
className={cn('py-1', {
|
||||||
|
'bg-widget-foreground pointer-events-none rounded-md pt-2':
|
||||||
|
snapshot.isDragging,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className={cn('flex items-end gap-2 pb-2', {
|
||||||
|
'border-destructive/50': errors?.signers?.[index],
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isSigningOrderEnabled && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`signers.${index}.signingOrder`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn('flex w-16 flex-none items-center gap-x-1', {
|
||||||
|
'mb-6':
|
||||||
|
errors?.signers?.[index] &&
|
||||||
|
!errors?.signers?.[index]?.signingOrder,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-5 w-5 flex-shrink-0 opacity-40" />
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
max={signers.length}
|
||||||
|
min={1}
|
||||||
|
className="w-full text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
|
{...field}
|
||||||
|
disabled={isSubmitting || snapshot.isDragging}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
field.onBlur();
|
||||||
|
handleSigningOrderChange(index, e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`signers.${index}.name`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn('flex-1', {
|
||||||
|
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.name,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormLabel className="sr-only">
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={_(msg`Name`)}
|
||||||
|
className="w-full"
|
||||||
|
{...field}
|
||||||
|
disabled={isSubmitting || snapshot.isDragging}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`signers.${index}.email`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn('flex-1', {
|
||||||
|
'mb-6':
|
||||||
|
errors?.signers?.[index] && !errors?.signers?.[index]?.email,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormLabel className="sr-only">
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder={_(msg`Email`)}
|
||||||
|
className="w-full"
|
||||||
|
{...field}
|
||||||
|
disabled={isSubmitting || snapshot.isDragging}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`signers.${index}.role`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn('flex-none', {
|
||||||
|
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.role,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormLabel className="sr-only">
|
||||||
|
<Trans>Role</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RecipientRoleSelect
|
||||||
|
{...field}
|
||||||
|
isAssistantEnabled={isSigningOrderEnabled}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={isSubmitting || snapshot.isDragging}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={isSubmitting || signers.length === 1 || snapshot.isDragging}
|
||||||
|
onClick={() => removeSigner(index)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-auto"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={onAddSigner}
|
||||||
|
>
|
||||||
|
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||||
|
<Trans>Add Signer</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,238 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Cloud, FileText, Loader, X } from 'lucide-react';
|
||||||
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useConfigureDocument } from './configure-document-context';
|
||||||
|
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||||
|
|
||||||
|
export interface ConfigureDocumentUploadProps {
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocumentUploadProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { isPersisted } = useConfigureDocument();
|
||||||
|
|
||||||
|
const form = useFormContext<TConfigureEmbedFormSchema>();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Watch the documentData field from the form
|
||||||
|
const documentData = form.watch('documentData');
|
||||||
|
|
||||||
|
const onFileDrop = async (acceptedFiles: File[]) => {
|
||||||
|
try {
|
||||||
|
const file = acceptedFiles[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Convert file to UInt8Array
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
|
|
||||||
|
// Store file metadata and UInt8Array in form data
|
||||||
|
form.setValue('documentData', {
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
data: uint8Array, // Store as UInt8Array
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-populate title if it's empty
|
||||||
|
const currentTitle = form.getValues('title');
|
||||||
|
|
||||||
|
if (!currentTitle) {
|
||||||
|
// Get filename without extension
|
||||||
|
const fileNameWithoutExtension = file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
form.setValue('title', fileNameWithoutExtension);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading file', error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error uploading file`),
|
||||||
|
description: _(msg`There was an error uploading your file. Please try again.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDropRejected = () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Your document failed to upload.`),
|
||||||
|
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||||
|
duration: 5000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemoveFile = () => {
|
||||||
|
if (isPersisted) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Cannot remove document`),
|
||||||
|
description: _(msg`The document is already saved and cannot be changed.`),
|
||||||
|
duration: 5000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.unregister('documentData');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
accept: {
|
||||||
|
'application/pdf': ['.pdf'],
|
||||||
|
},
|
||||||
|
maxSize: APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024,
|
||||||
|
multiple: false,
|
||||||
|
disabled: isSubmitting || isLoading || isPersisted,
|
||||||
|
onDrop: (files) => {
|
||||||
|
void onFileDrop(files);
|
||||||
|
},
|
||||||
|
onDropRejected,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="documentData"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Upload Document</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{!documentData ? (
|
||||||
|
<div className="relative">
|
||||||
|
<FormControl>
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={cn(
|
||||||
|
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition',
|
||||||
|
{
|
||||||
|
'border-primary/50 bg-primary/5': isDragActive,
|
||||||
|
'hover:bg-muted/30':
|
||||||
|
!isDragActive && !isSubmitting && !isLoading && !isPersisted,
|
||||||
|
'cursor-not-allowed opacity-60': isSubmitting || isLoading || isPersisted,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center gap-y-2 px-4 py-4 text-center">
|
||||||
|
<Cloud
|
||||||
|
className={cn('h-10 w-10', {
|
||||||
|
'text-primary': isDragActive,
|
||||||
|
'text-muted-foreground': !isDragActive,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col space-y-1', {
|
||||||
|
'text-primary': isDragActive,
|
||||||
|
'text-muted-foreground': !isDragActive,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{isDragActive ? (
|
||||||
|
<Trans>Drop your document here</Trans>
|
||||||
|
) : isPersisted ? (
|
||||||
|
<Trans>Document is already uploaded</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Drag and drop or click to upload</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
{isPersisted ? (
|
||||||
|
<Trans>This document cannot be changed</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
.PDF documents accepted (max {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB)
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||||
|
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 rounded-lg border p-4">
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md">
|
||||||
|
<FileText className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium">{documentData.name}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{formatFileSize(documentData.size)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isPersisted && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRemoveFile}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { DocumentDistributionMethod, DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
|
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
import { ConfigureDocumentAdvancedSettings } from './configure-document-advanced-settings';
|
||||||
|
import { useConfigureDocument } from './configure-document-context';
|
||||||
|
import { ConfigureDocumentRecipients } from './configure-document-recipients';
|
||||||
|
import { ConfigureDocumentUpload } from './configure-document-upload';
|
||||||
|
import {
|
||||||
|
type TConfigureEmbedFormSchema,
|
||||||
|
ZConfigureEmbedFormSchema,
|
||||||
|
} from './configure-document-view.types';
|
||||||
|
|
||||||
|
export interface ConfigureDocumentViewProps {
|
||||||
|
onSubmit: (data: TConfigureEmbedFormSchema) => void | Promise<void>;
|
||||||
|
defaultValues?: Partial<TConfigureEmbedFormSchema>;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfigureDocumentView = ({ onSubmit, defaultValues }: ConfigureDocumentViewProps) => {
|
||||||
|
const { isTemplate } = useConfigureDocument();
|
||||||
|
|
||||||
|
const form = useForm<TConfigureEmbedFormSchema>({
|
||||||
|
resolver: zodResolver(ZConfigureEmbedFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: defaultValues?.title || '',
|
||||||
|
signers: defaultValues?.signers || [
|
||||||
|
{
|
||||||
|
formId: nanoid(8),
|
||||||
|
name: isTemplate ? `Recipient ${1}` : '',
|
||||||
|
email: isTemplate ? `recipient.${1}@document.com` : '',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
signingOrder: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
subject: defaultValues?.meta?.subject || '',
|
||||||
|
message: defaultValues?.meta?.message || '',
|
||||||
|
distributionMethod:
|
||||||
|
defaultValues?.meta?.distributionMethod || DocumentDistributionMethod.EMAIL,
|
||||||
|
emailSettings: defaultValues?.meta?.emailSettings || ZDocumentEmailSettingsSchema.parse({}),
|
||||||
|
dateFormat: defaultValues?.meta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
timezone: defaultValues?.meta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE,
|
||||||
|
redirectUrl: defaultValues?.meta?.redirectUrl || '',
|
||||||
|
language: defaultValues?.meta?.language || 'en',
|
||||||
|
signatureTypes: defaultValues?.meta?.signatureTypes || [],
|
||||||
|
signingOrder: defaultValues?.meta?.signingOrder || DocumentSigningOrder.PARALLEL,
|
||||||
|
allowDictateNextSigner: defaultValues?.meta?.allowDictateNextSigner || false,
|
||||||
|
externalId: defaultValues?.meta?.externalId || '',
|
||||||
|
},
|
||||||
|
documentData: defaultValues?.documentData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control, handleSubmit } = form;
|
||||||
|
|
||||||
|
const isSubmitting = form.formState.isSubmitting;
|
||||||
|
|
||||||
|
const onFormSubmit = handleSubmit(onSubmit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-foreground mb-1 text-xl font-semibold">
|
||||||
|
{isTemplate ? <Trans>Configure Template</Trans> : <Trans>Configure Document</Trans>}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{isTemplate ? (
|
||||||
|
<Trans>Set up your template properties and recipient information</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Set up your document properties and recipient information</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<div className="flex flex-col space-y-8">
|
||||||
|
<div>
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Title</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} disabled={isSubmitting} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfigureDocumentUpload isSubmitting={isSubmitting} />
|
||||||
|
<ConfigureDocumentRecipients control={control} isSubmitting={isSubmitting} />
|
||||||
|
<ConfigureDocumentAdvancedSettings control={control} isSubmitting={isSubmitting} />
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onFormSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
|
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
||||||
|
import {
|
||||||
|
ZDocumentMetaDateFormatSchema,
|
||||||
|
ZDocumentMetaLanguageSchema,
|
||||||
|
} from '@documenso/trpc/server/document-router/schema';
|
||||||
|
|
||||||
|
// Define the schema for configuration
|
||||||
|
export type TConfigureEmbedFormSchema = z.infer<typeof ZConfigureEmbedFormSchema>;
|
||||||
|
|
||||||
|
export const ZConfigureEmbedFormSchema = z.object({
|
||||||
|
title: z.string().min(1, { message: 'Title is required' }),
|
||||||
|
signers: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
formId: z.string(),
|
||||||
|
name: z.string().min(1, { message: 'Name is required' }),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
|
||||||
|
signingOrder: z.number().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, { message: 'At least one signer is required' }),
|
||||||
|
meta: z.object({
|
||||||
|
subject: z.string().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
||||||
|
emailSettings: ZDocumentEmailSettingsSchema,
|
||||||
|
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||||
|
timezone: z.string().min(1, 'Timezone is required'),
|
||||||
|
redirectUrl: z.string().optional(),
|
||||||
|
language: ZDocumentMetaLanguageSchema.optional(),
|
||||||
|
signatureTypes: z.array(z.string()).default([]),
|
||||||
|
signingOrder: z.enum(['SEQUENTIAL', 'PARALLEL']),
|
||||||
|
allowDictateNextSigner: z.boolean().default(false),
|
||||||
|
externalId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
documentData: z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
size: z.number(),
|
||||||
|
data: z.instanceof(Uint8Array), // UInt8Array can't be directly validated by zod
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
@ -0,0 +1,661 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { DocumentData } from '@prisma/client';
|
||||||
|
import { FieldType, ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||||
|
import { ChevronsUpDown } from 'lucide-react';
|
||||||
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||||
|
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
|
import { useSignerColors } from '@documenso/ui/lib/signer-colors';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
|
||||||
|
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
|
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
|
||||||
|
import { Form } from '@documenso/ui/primitives/form/form';
|
||||||
|
import PDFViewer from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||||
|
import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer';
|
||||||
|
|
||||||
|
const MIN_HEIGHT_PX = 12;
|
||||||
|
const MIN_WIDTH_PX = 36;
|
||||||
|
|
||||||
|
const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5;
|
||||||
|
const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
|
||||||
|
|
||||||
|
export const ZConfigureFieldsFormSchema = z.object({
|
||||||
|
fields: z.array(
|
||||||
|
z.object({
|
||||||
|
formId: z.string().min(1),
|
||||||
|
id: z.string().min(1),
|
||||||
|
type: z.nativeEnum(FieldType),
|
||||||
|
signerEmail: z.string().min(1),
|
||||||
|
recipientId: z.number().min(0),
|
||||||
|
pageNumber: z.number().min(1),
|
||||||
|
pageX: z.number().min(0),
|
||||||
|
pageY: z.number().min(0),
|
||||||
|
pageWidth: z.number().min(0),
|
||||||
|
pageHeight: z.number().min(0),
|
||||||
|
fieldMeta: ZFieldMetaSchema.optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TConfigureFieldsFormSchema = z.infer<typeof ZConfigureFieldsFormSchema>;
|
||||||
|
|
||||||
|
export type ConfigureFieldsViewProps = {
|
||||||
|
configData: TConfigureEmbedFormSchema;
|
||||||
|
defaultValues?: Partial<TConfigureFieldsFormSchema>;
|
||||||
|
onBack: (data: TConfigureFieldsFormSchema) => void;
|
||||||
|
onSubmit: (data: TConfigureFieldsFormSchema) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConfigureFieldsView = ({
|
||||||
|
configData,
|
||||||
|
defaultValues,
|
||||||
|
onBack,
|
||||||
|
onSubmit,
|
||||||
|
}: ConfigureFieldsViewProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
// Track if we're on a mobile device
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
// State for managing the mobile drawer
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
// Check for mobile viewport on component mount and resize
|
||||||
|
useEffect(() => {
|
||||||
|
const checkIfMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth < 768);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
checkIfMobile();
|
||||||
|
|
||||||
|
// Add resize listener
|
||||||
|
window.addEventListener('resize', checkIfMobile);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkIfMobile);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const documentData = useMemo(() => {
|
||||||
|
if (!configData.documentData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = base64.encode(configData.documentData?.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'preview',
|
||||||
|
type: 'BYTES_64',
|
||||||
|
data,
|
||||||
|
initialData: data,
|
||||||
|
} satisfies DocumentData;
|
||||||
|
}, [configData.documentData]);
|
||||||
|
|
||||||
|
const recipients = useMemo(() => {
|
||||||
|
return configData.signers.map<Recipient>((signer, index) => ({
|
||||||
|
id: index,
|
||||||
|
name: signer.name || '',
|
||||||
|
email: signer.email || '',
|
||||||
|
role: signer.role,
|
||||||
|
signingOrder: signer.signingOrder || null,
|
||||||
|
documentId: null,
|
||||||
|
templateId: null,
|
||||||
|
token: '',
|
||||||
|
documentDeletedAt: null,
|
||||||
|
expired: null,
|
||||||
|
signedAt: null,
|
||||||
|
authOptions: null,
|
||||||
|
rejectionReason: null,
|
||||||
|
sendStatus: SendStatus.NOT_SENT,
|
||||||
|
readStatus: ReadStatus.NOT_OPENED,
|
||||||
|
signingStatus: SigningStatus.NOT_SIGNED,
|
||||||
|
}));
|
||||||
|
}, [configData.signers]);
|
||||||
|
|
||||||
|
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>(
|
||||||
|
() => recipients[0] || null,
|
||||||
|
);
|
||||||
|
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||||
|
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
|
||||||
|
const [coords, setCoords] = useState({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const [activeFieldId, setActiveFieldId] = useState<string | null>(null);
|
||||||
|
const [lastActiveField, setLastActiveField] = useState<
|
||||||
|
TConfigureFieldsFormSchema['fields'][0] | null
|
||||||
|
>(null);
|
||||||
|
const [fieldClipboard, setFieldClipboard] = useState<
|
||||||
|
TConfigureFieldsFormSchema['fields'][0] | null
|
||||||
|
>(null);
|
||||||
|
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||||
|
const [currentField, setCurrentField] = useState<TConfigureFieldsFormSchema['fields'][0] | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldBounds = useRef({
|
||||||
|
height: DEFAULT_HEIGHT_PX,
|
||||||
|
width: DEFAULT_WIDTH_PX,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id);
|
||||||
|
const selectedSignerStyles = useSignerColors(
|
||||||
|
selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm<TConfigureFieldsFormSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
fields: defaultValues?.fields ?? [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control, handleSubmit } = form;
|
||||||
|
|
||||||
|
const onFormSubmit = handleSubmit(onSubmit);
|
||||||
|
|
||||||
|
const {
|
||||||
|
append,
|
||||||
|
remove,
|
||||||
|
update,
|
||||||
|
fields: localFields,
|
||||||
|
} = useFieldArray({
|
||||||
|
control: control,
|
||||||
|
name: 'fields',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFieldCopy = useCallback(
|
||||||
|
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
|
||||||
|
const { duplicate = false } = options ?? {};
|
||||||
|
|
||||||
|
if (lastActiveField) {
|
||||||
|
event?.preventDefault();
|
||||||
|
|
||||||
|
if (!duplicate) {
|
||||||
|
setFieldClipboard(lastActiveField);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Copied field',
|
||||||
|
description: 'Copied field to clipboard',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||||
|
...structuredClone(lastActiveField),
|
||||||
|
formId: nanoid(12),
|
||||||
|
id: nanoid(12),
|
||||||
|
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
|
||||||
|
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
|
||||||
|
pageX: lastActiveField.pageX + 3,
|
||||||
|
pageY: lastActiveField.pageY + 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
append(newField);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFieldPaste = useCallback(
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
if (fieldClipboard) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const copiedField = structuredClone(fieldClipboard);
|
||||||
|
|
||||||
|
append({
|
||||||
|
...copiedField,
|
||||||
|
formId: nanoid(12),
|
||||||
|
id: nanoid(12),
|
||||||
|
signerEmail: selectedRecipient?.email ?? copiedField.signerEmail,
|
||||||
|
recipientId: selectedRecipient?.id ?? copiedField.recipientId,
|
||||||
|
pageX: copiedField.pageX + 3,
|
||||||
|
pageY: copiedField.pageY + 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[append, fieldClipboard, selectedRecipient?.email, selectedRecipient?.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
|
||||||
|
useHotkeys(['ctrl+v', 'meta+v'], (evt) => onFieldPaste(evt));
|
||||||
|
useHotkeys(['ctrl+d', 'meta+d'], (evt) => onFieldCopy(evt, { duplicate: true }));
|
||||||
|
|
||||||
|
const onMouseMove = useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
if (!selectedField) return;
|
||||||
|
|
||||||
|
setIsFieldWithinBounds(
|
||||||
|
isWithinPageBounds(
|
||||||
|
event,
|
||||||
|
PDF_VIEWER_PAGE_SELECTOR,
|
||||||
|
fieldBounds.current.width,
|
||||||
|
fieldBounds.current.height,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
setCoords({
|
||||||
|
x: event.clientX - fieldBounds.current.width / 2,
|
||||||
|
y: event.clientY - fieldBounds.current.height / 2,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isWithinPageBounds, selectedField],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onMouseClick = useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
if (!selectedField || !selectedRecipient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!$page ||
|
||||||
|
!isWithinPageBounds(
|
||||||
|
event,
|
||||||
|
PDF_VIEWER_PAGE_SELECTOR,
|
||||||
|
fieldBounds.current.width,
|
||||||
|
fieldBounds.current.height,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { top, left, height, width } = getBoundingClientRect($page);
|
||||||
|
|
||||||
|
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||||
|
|
||||||
|
// Calculate x and y as a percentage of the page width and height
|
||||||
|
let pageX = ((event.pageX - left) / width) * 100;
|
||||||
|
let pageY = ((event.pageY - top) / height) * 100;
|
||||||
|
|
||||||
|
// Get the bounds as a percentage of the page width and height
|
||||||
|
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
|
||||||
|
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
|
||||||
|
|
||||||
|
// And center it based on the bounds
|
||||||
|
pageX -= fieldPageWidth / 2;
|
||||||
|
pageY -= fieldPageHeight / 2;
|
||||||
|
|
||||||
|
const field = {
|
||||||
|
id: nanoid(12),
|
||||||
|
formId: nanoid(12),
|
||||||
|
type: selectedField,
|
||||||
|
pageNumber,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
pageWidth: fieldPageWidth,
|
||||||
|
pageHeight: fieldPageHeight,
|
||||||
|
recipientId: selectedRecipient.id,
|
||||||
|
signerEmail: selectedRecipient.email,
|
||||||
|
fieldMeta: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
append(field);
|
||||||
|
|
||||||
|
// Automatically open advanced settings for field types that need configuration
|
||||||
|
if (ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING.includes(selectedField)) {
|
||||||
|
setCurrentField(field);
|
||||||
|
setShowAdvancedSettings(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedField(null);
|
||||||
|
},
|
||||||
|
[append, getPage, isWithinPageBounds, selectedField, selectedRecipient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFieldResize = useCallback(
|
||||||
|
(node: HTMLElement, index: number) => {
|
||||||
|
const field = localFields[index];
|
||||||
|
|
||||||
|
const $page = window.document.querySelector<HTMLElement>(
|
||||||
|
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
x: pageX,
|
||||||
|
y: pageY,
|
||||||
|
width: pageWidth,
|
||||||
|
height: pageHeight,
|
||||||
|
} = getFieldPosition($page, node);
|
||||||
|
|
||||||
|
update(index, {
|
||||||
|
...field,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getFieldPosition, localFields, update],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFieldMove = useCallback(
|
||||||
|
(node: HTMLElement, index: number) => {
|
||||||
|
const field = localFields[index];
|
||||||
|
|
||||||
|
const $page = window.document.querySelector<HTMLElement>(
|
||||||
|
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { x: pageX, y: pageY } = getFieldPosition($page, node);
|
||||||
|
|
||||||
|
update(index, {
|
||||||
|
...field,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getFieldPosition, localFields, update],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpdateFieldMeta = useCallback(
|
||||||
|
(formId: string, fieldMeta: TFieldMetaSchema) => {
|
||||||
|
const fieldIndex = localFields.findIndex((field) => field.formId === formId);
|
||||||
|
|
||||||
|
if (fieldIndex !== -1) {
|
||||||
|
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldMeta);
|
||||||
|
|
||||||
|
update(fieldIndex, {
|
||||||
|
...localFields[fieldIndex],
|
||||||
|
fieldMeta: parsedFieldMeta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[localFields, update],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedField) {
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
window.addEventListener('mouseup', onMouseClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
window.removeEventListener('mouseup', onMouseClick);
|
||||||
|
};
|
||||||
|
}, [onMouseClick, onMouseMove, selectedField]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver((_mutations) => {
|
||||||
|
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldBounds.current = {
|
||||||
|
height: Math.max(DEFAULT_HEIGHT_PX),
|
||||||
|
width: Math.max(DEFAULT_WIDTH_PX),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close drawer when a field is selected on mobile
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile && selectedField) {
|
||||||
|
setIsDrawerOpen(false);
|
||||||
|
}
|
||||||
|
}, [isMobile, selectedField]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid w-full grid-cols-12 gap-4">
|
||||||
|
{/* Desktop sidebar */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="order-2 col-span-12 md:order-1 md:col-span-4">
|
||||||
|
<div className="bg-widget border-border sticky top-4 max-h-[calc(100vh-2rem)] rounded-lg border p-4 pb-6">
|
||||||
|
<h2 className="mb-1 text-lg font-medium">
|
||||||
|
<Trans>Configure Fields</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-6 text-sm">
|
||||||
|
<Trans>Configure the fields you want to place on the document.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<RecipientSelector
|
||||||
|
selectedRecipient={selectedRecipient}
|
||||||
|
onSelectedRecipientChange={setSelectedRecipient}
|
||||||
|
recipients={recipients}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr className="my-6" />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FieldSelector
|
||||||
|
selectedField={selectedField}
|
||||||
|
onSelectedFieldChange={setSelectedField}
|
||||||
|
className="w-full"
|
||||||
|
disabled={!selectedRecipient}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1"
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
onClick={() => onBack(form.getValues())}
|
||||||
|
>
|
||||||
|
<Trans>Back</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
type="button"
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
disabled={!form.formState.isValid}
|
||||||
|
onClick={async () => onFormSubmit()}
|
||||||
|
>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn('order-1 col-span-12 md:order-2', !isMobile && 'md:col-span-8')}>
|
||||||
|
<div className="relative">
|
||||||
|
{selectedField && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200 [container-type:size]',
|
||||||
|
selectedSignerStyles.default.base,
|
||||||
|
{
|
||||||
|
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||||
|
'dark:text-black/60': isFieldWithinBounds,
|
||||||
|
},
|
||||||
|
selectedField === 'SIGNATURE' && 'font-signature',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: coords.y,
|
||||||
|
left: coords.x,
|
||||||
|
height: fieldBounds.current.height,
|
||||||
|
width: fieldBounds.current.width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
|
||||||
|
{_(FRIENDLY_FIELD_TYPE[selectedField])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
{documentData && (
|
||||||
|
<div>
|
||||||
|
<PDFViewer documentData={documentData} />
|
||||||
|
|
||||||
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
|
{localFields.map((field, index) => {
|
||||||
|
const recipientIndex = recipients.findIndex(
|
||||||
|
(r) => r.id === field.recipientId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldItem
|
||||||
|
key={field.formId}
|
||||||
|
field={field}
|
||||||
|
minHeight={MIN_HEIGHT_PX}
|
||||||
|
minWidth={MIN_WIDTH_PX}
|
||||||
|
defaultHeight={DEFAULT_HEIGHT_PX}
|
||||||
|
defaultWidth={DEFAULT_WIDTH_PX}
|
||||||
|
onResize={(node) => onFieldResize(node, index)}
|
||||||
|
onMove={(node) => onFieldMove(node, index)}
|
||||||
|
onRemove={() => remove(index)}
|
||||||
|
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||||
|
onFocus={() => setLastActiveField(field)}
|
||||||
|
onBlur={() => setLastActiveField(null)}
|
||||||
|
onAdvancedSettings={() => {
|
||||||
|
setCurrentField(field);
|
||||||
|
setShowAdvancedSettings(true);
|
||||||
|
}}
|
||||||
|
recipientIndex={recipientIndex}
|
||||||
|
active={activeFieldId === field.formId}
|
||||||
|
onFieldActivate={() => setActiveFieldId(field.formId)}
|
||||||
|
onFieldDeactivate={() => setActiveFieldId(null)}
|
||||||
|
disabled={selectedRecipient?.id !== field.recipientId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ElementVisible>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Floating Action Bar and Drawer */}
|
||||||
|
{isMobile && (
|
||||||
|
<Sheet open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<div className="bg-widget border-border fixed bottom-6 left-6 right-6 z-50 flex items-center justify-between gap-2 rounded-lg border p-4">
|
||||||
|
<span className="text-lg font-medium">
|
||||||
|
<Trans>Configure Fields</Trans>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="border-border text-muted-foreground inline-flex h-10 w-10 items-center justify-center rounded-lg border"
|
||||||
|
>
|
||||||
|
<ChevronsUpDown className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SheetTrigger>
|
||||||
|
|
||||||
|
<SheetContent
|
||||||
|
position="bottom"
|
||||||
|
size="xl"
|
||||||
|
className="bg-widget h-fit max-h-[80vh] overflow-y-auto rounded-t-xl p-4"
|
||||||
|
>
|
||||||
|
<h2 className="mb-1 text-lg font-medium">
|
||||||
|
<Trans>Configure Fields</Trans>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-6 text-sm">
|
||||||
|
<Trans>Configure the fields you want to place on the document.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<RecipientSelector
|
||||||
|
selectedRecipient={selectedRecipient}
|
||||||
|
onSelectedRecipientChange={setSelectedRecipient}
|
||||||
|
recipients={recipients}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr className="my-6" />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FieldSelector
|
||||||
|
selectedField={selectedField}
|
||||||
|
onSelectedFieldChange={(field) => {
|
||||||
|
setSelectedField(field);
|
||||||
|
if (field) {
|
||||||
|
setIsDrawerOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
disabled={!selectedRecipient}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1"
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
onClick={() => onBack(form.getValues())}
|
||||||
|
>
|
||||||
|
<Trans>Back</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
type="button"
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
disabled={!form.formState.isValid}
|
||||||
|
onClick={async () => onFormSubmit()}
|
||||||
|
>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldAdvancedSettingsDrawer
|
||||||
|
isOpen={showAdvancedSettings}
|
||||||
|
onOpenChange={setShowAdvancedSettings}
|
||||||
|
currentField={currentField}
|
||||||
|
fields={localFields}
|
||||||
|
onFieldUpdate={handleUpdateFieldMeta}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import type { FieldType } from '@prisma/client';
|
||||||
|
|
||||||
|
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
|
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
||||||
|
import { FieldAdvancedSettings } from '@documenso/ui/primitives/document-flow/field-item-advanced-settings';
|
||||||
|
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
|
||||||
|
|
||||||
|
export type FieldAdvancedSettingsDrawerProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
currentField: {
|
||||||
|
id: string;
|
||||||
|
formId: string;
|
||||||
|
type: FieldType;
|
||||||
|
pageNumber: number;
|
||||||
|
pageX: number;
|
||||||
|
pageY: number;
|
||||||
|
pageWidth: number;
|
||||||
|
pageHeight: number;
|
||||||
|
recipientId: number;
|
||||||
|
signerEmail: string;
|
||||||
|
fieldMeta?: FieldMeta;
|
||||||
|
} | null;
|
||||||
|
fields: Array<{
|
||||||
|
id: string;
|
||||||
|
formId: string;
|
||||||
|
type: FieldType;
|
||||||
|
pageNumber: number;
|
||||||
|
pageX: number;
|
||||||
|
pageY: number;
|
||||||
|
pageWidth: number;
|
||||||
|
pageHeight: number;
|
||||||
|
recipientId: number;
|
||||||
|
signerEmail: string;
|
||||||
|
fieldMeta?: FieldMeta;
|
||||||
|
}>;
|
||||||
|
onFieldUpdate: (formId: string, fieldMeta: FieldMeta) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FieldAdvancedSettingsDrawer = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
currentField,
|
||||||
|
fields,
|
||||||
|
onFieldUpdate,
|
||||||
|
}: FieldAdvancedSettingsDrawerProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
if (!currentField) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent position="right" size="lg" className="w-9/12 max-w-sm overflow-y-auto">
|
||||||
|
<SheetTitle className="sr-only">
|
||||||
|
{parseMessageDescriptor(
|
||||||
|
_,
|
||||||
|
msg`Configure ${parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[currentField.type])} Field`,
|
||||||
|
)}
|
||||||
|
</SheetTitle>
|
||||||
|
|
||||||
|
<FieldAdvancedSettings
|
||||||
|
title={msg`Advanced settings`}
|
||||||
|
description={msg`Configure the ${parseMessageDescriptor(
|
||||||
|
_,
|
||||||
|
FRIENDLY_FIELD_TYPE[currentField.type],
|
||||||
|
)} field`}
|
||||||
|
field={currentField}
|
||||||
|
fields={fields}
|
||||||
|
onAdvancedSettings={() => onOpenChange(false)}
|
||||||
|
onSave={(fieldMeta) => {
|
||||||
|
onFieldUpdate(currentField.formId, fieldMeta);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,7 +1,11 @@
|
|||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
export const EmbedClientLoading = () => {
|
export const EmbedClientLoading = () => {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
|
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
|
||||||
Loading...
|
<Loader className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
|
||||||
|
<span>Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
|
||||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
||||||
|
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
@ -25,12 +25,11 @@ import type {
|
|||||||
} from '@documenso/trpc/server/field-router/schema';
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||||
@ -69,16 +68,8 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const {
|
const { fullName, email, signature, setFullName, setEmail, setSignature } =
|
||||||
fullName,
|
useRequiredDocumentSigningContext();
|
||||||
email,
|
|
||||||
signature,
|
|
||||||
signatureValid,
|
|
||||||
setFullName,
|
|
||||||
setEmail,
|
|
||||||
setSignature,
|
|
||||||
setSignatureValid,
|
|
||||||
} = useRequiredDocumentSigningContext();
|
|
||||||
|
|
||||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||||
@ -194,10 +185,6 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
|
|
||||||
const onCompleteClick = async () => {
|
const onCompleteClick = async () => {
|
||||||
try {
|
try {
|
||||||
if (hasSignatureField && !signatureValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = validateFieldsInserted(pendingFields);
|
const valid = validateFieldsInserted(pendingFields);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
@ -419,34 +406,16 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
<SignaturePadDialog
|
||||||
<CardContent className="p-0">
|
className="mt-2"
|
||||||
<SignaturePad
|
disabled={isThrottled || isSubmitting}
|
||||||
className="h-44 w-full"
|
disableAnimation
|
||||||
disabled={isThrottled || isSubmitting}
|
value={signature ?? ''}
|
||||||
defaultValue={signature ?? undefined}
|
onChange={(v) => setSignature(v ?? '')}
|
||||||
onChange={(value) => {
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||||
setSignature(value);
|
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||||
}}
|
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||||
onValidityChange={(isValid) => {
|
/>
|
||||||
setSignatureValid(isValid);
|
|
||||||
}}
|
|
||||||
allowTypedSignature={Boolean(
|
|
||||||
metadata &&
|
|
||||||
'typedSignatureEnabled' in metadata &&
|
|
||||||
metadata.typedSignatureEnabled,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{hasSignatureField && !signatureValid && (
|
|
||||||
<div className="text-destructive mt-2 text-sm">
|
|
||||||
<Trans>
|
|
||||||
Signature is too small. Please provide a more complete signature.
|
|
||||||
</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,7 +10,6 @@ export type EmbedDocumentCompletedPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
||||||
console.log({ signature });
|
|
||||||
return (
|
return (
|
||||||
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--DocumentCompleted 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">
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
|
|||||||
@ -54,6 +54,8 @@ export const EmbedDocumentFields = ({
|
|||||||
onSignField={onSignField}
|
onSignField={onSignField}
|
||||||
onUnsignField={onUnsignField}
|
onUnsignField={onUnsignField}
|
||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||||
|
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||||
|
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.INITIALS, () => (
|
.with(FieldType.INITIALS, () => (
|
||||||
|
|||||||
@ -15,19 +15,19 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
|||||||
|
|
||||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||||
@ -37,6 +37,7 @@ import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-sc
|
|||||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||||
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
||||||
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
|
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
|
||||||
|
import { DocumentReadOnlyFields } from '../general/document/document-read-only-fields';
|
||||||
import { EmbedClientLoading } from './embed-client-loading';
|
import { EmbedClientLoading } from './embed-client-loading';
|
||||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||||
import { EmbedDocumentFields } from './embed-document-fields';
|
import { EmbedDocumentFields } from './embed-document-fields';
|
||||||
@ -48,6 +49,7 @@ export type EmbedSignDocumentClientPageProps = {
|
|||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
completedFields: DocumentField[];
|
||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
metadata?: DocumentMeta | TemplateMeta | null;
|
||||||
isCompleted?: boolean;
|
isCompleted?: boolean;
|
||||||
hidePoweredBy?: boolean;
|
hidePoweredBy?: boolean;
|
||||||
@ -61,6 +63,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
documentData,
|
documentData,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
|
completedFields,
|
||||||
metadata,
|
metadata,
|
||||||
isCompleted,
|
isCompleted,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
@ -70,15 +73,8 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const {
|
const { fullName, email, signature, setFullName, setSignature } =
|
||||||
fullName,
|
useRequiredDocumentSigningContext();
|
||||||
email,
|
|
||||||
signature,
|
|
||||||
signatureValid,
|
|
||||||
setFullName,
|
|
||||||
setSignature,
|
|
||||||
setSignatureValid,
|
|
||||||
} = useRequiredDocumentSigningContext();
|
|
||||||
|
|
||||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||||
@ -93,6 +89,8 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||||
|
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||||
|
|
||||||
@ -129,10 +127,6 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
|
|
||||||
const onCompleteClick = async () => {
|
const onCompleteClick = async () => {
|
||||||
try {
|
try {
|
||||||
if (hasSignatureField && !signatureValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = validateFieldsInserted(fieldsRequiringValidation);
|
const valid = validateFieldsInserted(fieldsRequiringValidation);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
@ -214,6 +208,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
// a to be provided by the parent application, unlike direct templates.
|
// a to be provided by the parent application, unlike direct templates.
|
||||||
setIsNameLocked(!!data.lockName);
|
setIsNameLocked(!!data.lockName);
|
||||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||||
|
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
|
||||||
|
|
||||||
if (data.darkModeDisabled) {
|
if (data.darkModeDisabled) {
|
||||||
document.documentElement.classList.add('dark-mode-disabled');
|
document.documentElement.classList.add('dark-mode-disabled');
|
||||||
@ -432,34 +427,16 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
<SignaturePadDialog
|
||||||
<CardContent className="p-0">
|
className="mt-2"
|
||||||
<SignaturePad
|
disabled={isThrottled || isSubmitting}
|
||||||
className="h-44 w-full"
|
disableAnimation
|
||||||
disabled={isThrottled || isSubmitting}
|
value={signature ?? ''}
|
||||||
defaultValue={signature ?? undefined}
|
onChange={(v) => setSignature(v ?? '')}
|
||||||
onChange={(value) => {
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||||
setSignature(value);
|
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||||
}}
|
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||||
onValidityChange={(isValid) => {
|
/>
|
||||||
setSignatureValid(isValid);
|
|
||||||
}}
|
|
||||||
allowTypedSignature={Boolean(
|
|
||||||
metadata &&
|
|
||||||
'typedSignatureEnabled' in metadata &&
|
|
||||||
metadata.typedSignatureEnabled,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{hasSignatureField && !signatureValid && (
|
|
||||||
<div className="text-destructive mt-2 text-sm">
|
|
||||||
<Trans>
|
|
||||||
Signature is too small. Please provide a more complete signature.
|
|
||||||
</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -477,9 +454,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
|
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
|
||||||
disabled={
|
disabled={isThrottled}
|
||||||
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
|
||||||
}
|
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={() => throttledOnCompleteClick()}
|
onClick={() => throttledOnCompleteClick()}
|
||||||
>
|
>
|
||||||
@ -500,6 +475,9 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
|
|
||||||
{/* Fields */}
|
{/* Fields */}
|
||||||
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
||||||
|
|
||||||
|
{/* Completed fields */}
|
||||||
|
<DocumentReadOnlyFields documentMeta={metadata || undefined} fields={completedFields} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hidePoweredBy && (
|
{!hidePoweredBy && (
|
||||||
|
|||||||
@ -19,12 +19,15 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export const ZProfileFormSchema = z.object({
|
export const ZProfileFormSchema = z.object({
|
||||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
name: z
|
||||||
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, { message: msg`Please enter a valid name.`.id }),
|
||||||
|
signature: z.string().min(1, { message: msg`Signature Pad cannot be empty.`.id }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZTwoFactorAuthTokenSchema = z.object({
|
export const ZTwoFactorAuthTokenSchema = z.object({
|
||||||
@ -109,22 +112,20 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
|
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="signature"
|
name="signature"
|
||||||
render={({ field: { onChange } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SignaturePad
|
<SignaturePadDialog
|
||||||
className="h-44 w-full"
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
containerClassName={cn('rounded-lg border bg-background')}
|
value={value}
|
||||||
defaultValue={user.signature ?? undefined}
|
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
allowTypedSignature={true}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -134,7 +135,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<Button type="submit" loading={isSubmitting} className="self-end">
|
<Button type="submit" loading={isSubmitting} className="self-end">
|
||||||
{isSubmitting ? <Trans>Updating profile...</Trans> : <Trans>Update profile</Trans>}
|
<Trans>Update profile</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton';
|
import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton';
|
||||||
@ -353,16 +353,15 @@ export const SignUpForm = ({
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="signature"
|
name="signature"
|
||||||
render={({ field: { onChange } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Sign Here</Trans>
|
<Trans>Sign Here</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SignaturePad
|
<SignaturePadDialog
|
||||||
className="h-36 w-full"
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
containerClassName="mt-2 rounded-lg border bg-background"
|
value={value}
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -531,6 +530,27 @@ export const SignUpForm = ({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
<p className="text-muted-foreground mt-6 text-xs">
|
||||||
|
<Trans>
|
||||||
|
By proceeding, you agree to our{' '}
|
||||||
|
<Link
|
||||||
|
to="https://documen.so/terms"
|
||||||
|
target="_blank"
|
||||||
|
className="text-documenso-700 duration-200 hover:opacity-70"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</Link>{' '}
|
||||||
|
and{' '}
|
||||||
|
<Link
|
||||||
|
to="https://documen.so/privacy"
|
||||||
|
target="_blank"
|
||||||
|
className="text-documenso-700 duration-200 hover:opacity-70"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -308,7 +308,7 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
|
|||||||
|
|
||||||
<div className="flex flex-row justify-end space-x-4">
|
<div className="flex flex-row justify-end space-x-4">
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<Trans>Save</Trans>
|
<Trans>Update</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@ -8,12 +8,15 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
import {
|
import {
|
||||||
SUPPORTED_LANGUAGES,
|
SUPPORTED_LANGUAGES,
|
||||||
SUPPORTED_LANGUAGE_CODES,
|
SUPPORTED_LANGUAGE_CODES,
|
||||||
isValidLanguageCode,
|
isValidLanguageCode,
|
||||||
} from '@documenso/lib/constants/i18n';
|
} from '@documenso/lib/constants/i18n';
|
||||||
|
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -23,7 +26,9 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -38,8 +43,10 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
|
|||||||
documentVisibility: z.nativeEnum(DocumentVisibility),
|
documentVisibility: z.nativeEnum(DocumentVisibility),
|
||||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||||
includeSenderDetails: z.boolean(),
|
includeSenderDetails: z.boolean(),
|
||||||
typedSignatureEnabled: z.boolean(),
|
|
||||||
includeSigningCertificate: z.boolean(),
|
includeSigningCertificate: z.boolean(),
|
||||||
|
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
||||||
|
message: msg`At least one signature type must be enabled`.id,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
|
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
|
||||||
@ -69,8 +76,8 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
? settings?.documentLanguage
|
? settings?.documentLanguage
|
||||||
: 'en',
|
: 'en',
|
||||||
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
||||||
typedSignatureEnabled: settings?.typedSignatureEnabled ?? true,
|
|
||||||
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
|
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
|
||||||
|
signatureTypes: extractTeamSignatureSettings(settings),
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
||||||
});
|
});
|
||||||
@ -84,7 +91,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
typedSignatureEnabled,
|
signatureTypes,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
await updateTeamDocumentPreferences({
|
await updateTeamDocumentPreferences({
|
||||||
@ -93,8 +100,10 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
documentVisibility,
|
documentVisibility,
|
||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
typedSignatureEnabled,
|
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -190,6 +199,44 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="signatureTypes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel className="flex flex-row items-center">
|
||||||
|
<Trans>Default Signature Settings</Trans>
|
||||||
|
<DocumentSignatureSettingsTooltip />
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
||||||
|
label: _(option.label),
|
||||||
|
value: option.value,
|
||||||
|
}))}
|
||||||
|
selectedValues={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
className="bg-background w-full"
|
||||||
|
enableSearch={false}
|
||||||
|
emptySelectionPlaceholder="Select signature types"
|
||||||
|
testId="signature-types-combobox"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{form.formState.errors.signatureTypes ? (
|
||||||
|
<FormMessage />
|
||||||
|
) : (
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>
|
||||||
|
Controls which signatures are allowed to be used when signing a document.
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="includeSenderDetails"
|
name="includeSenderDetails"
|
||||||
@ -238,36 +285,6 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="typedSignatureEnabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Enable Typed Signature</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormControl className="block">
|
|
||||||
<Switch
|
|
||||||
ref={field.ref}
|
|
||||||
name={field.name}
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>
|
|
||||||
Controls whether the recipients can sign the documents using a typed signature.
|
|
||||||
Enable or disable the typed signature globally.
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="includeSigningCertificate"
|
name="includeSigningCertificate"
|
||||||
@ -301,7 +318,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
|
|
||||||
<div className="flex flex-row justify-end space-x-4">
|
<div className="flex flex-row justify-end space-x-4">
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<Trans>Save</Trans>
|
<Trans>Update</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@ -113,7 +113,11 @@ export const DirectTemplatePageView = ({
|
|||||||
|
|
||||||
const redirectUrl = template.templateMeta?.redirectUrl;
|
const redirectUrl = template.templateMeta?.redirectUrl;
|
||||||
|
|
||||||
await (redirectUrl ? navigate(redirectUrl) : navigate(`/sign/${token}/complete`));
|
if (redirectUrl) {
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
} else {
|
||||||
|
await navigate(`/sign/${token}/complete`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Field, Recipient, Signature } from '@prisma/client';
|
import type { Field, Recipient, Signature } from '@prisma/client';
|
||||||
@ -24,7 +24,6 @@ import type {
|
|||||||
} from '@documenso/trpc/server/field-router/schema';
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import {
|
import {
|
||||||
DocumentFlowFormContainerContent,
|
DocumentFlowFormContainerContent,
|
||||||
DocumentFlowFormContainerFooter,
|
DocumentFlowFormContainerFooter,
|
||||||
@ -35,7 +34,7 @@ import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/ty
|
|||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||||
import { useStep } from '@documenso/ui/primitives/stepper';
|
import { useStep } from '@documenso/ui/primitives/stepper';
|
||||||
|
|
||||||
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
||||||
@ -73,8 +72,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
template,
|
template,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: DirectTemplateSigningFormProps) => {
|
}: DirectTemplateSigningFormProps) => {
|
||||||
const { fullName, signature, signatureValid, setFullName, setSignature } =
|
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
||||||
useRequiredDocumentSigningContext();
|
|
||||||
|
|
||||||
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(directRecipientFields);
|
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(directRecipientFields);
|
||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
@ -135,8 +133,6 @@ export const DirectTemplateSigningForm = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
|
||||||
|
|
||||||
const uninsertedFields = useMemo(() => {
|
const uninsertedFields = useMemo(() => {
|
||||||
return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
|
return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
|
||||||
}, [localFields]);
|
}, [localFields]);
|
||||||
@ -149,10 +145,6 @@ export const DirectTemplateSigningForm = ({
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
|
|
||||||
if (hasSignatureField && !signatureValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFieldsValid = validateFieldsInserted(localFields);
|
const isFieldsValid = validateFieldsInserted(localFields);
|
||||||
|
|
||||||
if (!isFieldsValid) {
|
if (!isFieldsValid) {
|
||||||
@ -170,6 +162,55 @@ export const DirectTemplateSigningForm = ({
|
|||||||
// Do not reset to false since we do a redirect.
|
// Do not reset to false since we do a redirect.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updatedFields = [...localFields];
|
||||||
|
|
||||||
|
localFields.forEach((field) => {
|
||||||
|
const index = updatedFields.findIndex((f) => f.id === field.id);
|
||||||
|
let value = '';
|
||||||
|
|
||||||
|
match(field.type)
|
||||||
|
.with(FieldType.TEXT, () => {
|
||||||
|
const meta = field.fieldMeta ? ZTextFieldMeta.safeParse(field.fieldMeta) : null;
|
||||||
|
|
||||||
|
if (meta?.success) {
|
||||||
|
value = meta.data.text ?? '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.with(FieldType.NUMBER, () => {
|
||||||
|
const meta = field.fieldMeta ? ZNumberFieldMeta.safeParse(field.fieldMeta) : null;
|
||||||
|
|
||||||
|
if (meta?.success) {
|
||||||
|
value = meta.data.value ?? '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.with(FieldType.DROPDOWN, () => {
|
||||||
|
const meta = field.fieldMeta ? ZDropdownFieldMeta.safeParse(field.fieldMeta) : null;
|
||||||
|
|
||||||
|
if (meta?.success) {
|
||||||
|
value = meta.data.defaultValue ?? '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const signedValue = {
|
||||||
|
token: directRecipient.token,
|
||||||
|
fieldId: field.id,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
|
||||||
|
updatedFields[index] = {
|
||||||
|
...field,
|
||||||
|
customText: value,
|
||||||
|
inserted: true,
|
||||||
|
signedValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalFields(updatedFields);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningRecipientProvider recipient={directRecipient}>
|
<DocumentSigningRecipientProvider recipient={directRecipient}>
|
||||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||||
@ -191,6 +232,8 @@ export const DirectTemplateSigningForm = ({
|
|||||||
onSignField={onSignField}
|
onSignField={onSignField}
|
||||||
onUnsignField={onUnsignField}
|
onUnsignField={onUnsignField}
|
||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||||
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.INITIALS, () => (
|
.with(FieldType.INITIALS, () => (
|
||||||
@ -335,19 +378,15 @@ export const DirectTemplateSigningForm = ({
|
|||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
<SignaturePadDialog
|
||||||
<CardContent className="p-0">
|
className="mt-2"
|
||||||
<SignaturePad
|
disabled={isSubmitting}
|
||||||
className="h-44 w-full"
|
value={signature ?? ''}
|
||||||
disabled={isSubmitting}
|
onChange={(value) => setSignature(value)}
|
||||||
defaultValue={signature ?? undefined}
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
onChange={(value) => {
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||||
setSignature(value);
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||||
}}
|
/>
|
||||||
allowTypedSignature={template.templateMeta?.typedSignatureEnabled}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -97,6 +97,10 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
|
|
||||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
|
if (!isLengthConditionMet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload: TSignFieldWithTokenMutationSchema = {
|
const payload: TSignFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
@ -194,18 +198,30 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
|
|
||||||
setCheckedValues(updatedValues);
|
setCheckedValues(updatedValues);
|
||||||
|
|
||||||
await removeSignedFieldWithToken({
|
const removePayload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
});
|
};
|
||||||
|
|
||||||
if (updatedValues.length > 0) {
|
if (onUnsignField) {
|
||||||
await signFieldWithToken({
|
await onUnsignField(removePayload);
|
||||||
|
} else {
|
||||||
|
await removeSignedFieldWithToken(removePayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedValues.length > 0 && shouldAutoSignField) {
|
||||||
|
const signPayload: TSignFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: toCheckboxValue(updatedValues),
|
value: toCheckboxValue(updatedValues),
|
||||||
isBase64: true,
|
isBase64: true,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (onSignField) {
|
||||||
|
await onSignField(signPayload);
|
||||||
|
} else {
|
||||||
|
await signFieldWithToken(signPayload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Field } from '@prisma/client';
|
import type { Field } from '@prisma/client';
|
||||||
import { RecipientRole } from '@prisma/client';
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -14,6 +17,15 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
@ -22,11 +34,23 @@ export type DocumentSigningCompleteDialogProps = {
|
|||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: () => void | Promise<void>;
|
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
|
||||||
role: RecipientRole;
|
role: RecipientRole;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
allowDictateNextSigner?: boolean;
|
||||||
|
defaultNextSigner?: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ZNextSignerFormSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||||
|
|
||||||
export const DocumentSigningCompleteDialog = ({
|
export const DocumentSigningCompleteDialog = ({
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
documentTitle,
|
documentTitle,
|
||||||
@ -35,19 +59,54 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
role,
|
role,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
allowDictateNextSigner = false,
|
||||||
|
defaultNextSigner,
|
||||||
}: DocumentSigningCompleteDialogProps) => {
|
}: DocumentSigningCompleteDialogProps) => {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
|
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||||
|
defaultValues: {
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
if (isSubmitting || !isComplete) {
|
if (form.formState.isSubmitting || !isComplete) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
form.reset({
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEditingNextSigner(false);
|
||||||
setShowDialog(open);
|
setShowDialog(open);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||||
|
console.log('data', data);
|
||||||
|
console.log('form.formState.errors', form.formState.errors);
|
||||||
|
try {
|
||||||
|
if (allowDictateNextSigner && data.name && data.email) {
|
||||||
|
await onSignatureComplete({ name: data.name, email: data.email });
|
||||||
|
} else {
|
||||||
|
await onSignatureComplete();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error completing signature:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@ -71,110 +130,184 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogTitle>
|
<Form {...form}>
|
||||||
<div className="text-foreground text-xl font-semibold">
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
{match(role)
|
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
<DialogTitle>
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
<div className="text-foreground text-xl font-semibold">
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
{match(role)
|
||||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
||||||
.exhaustive()}
|
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
||||||
</div>
|
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
||||||
</DialogTitle>
|
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
||||||
|
.exhaustive()}
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
{match(role)
|
{match(role)
|
||||||
.with(RecipientRole.VIEWER, () => (
|
.with(RecipientRole.VIEWER, () => (
|
||||||
<span>
|
<span>
|
||||||
<Trans>
|
<Trans>
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
You are about to complete viewing "
|
You are about to complete viewing "
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
{documentTitle}
|
{documentTitle}
|
||||||
|
</span>
|
||||||
|
".
|
||||||
|
</span>
|
||||||
|
<br /> Are you sure?
|
||||||
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
".
|
))
|
||||||
</span>
|
.with(RecipientRole.SIGNER, () => (
|
||||||
<br /> Are you sure?
|
<span>
|
||||||
</Trans>
|
<Trans>
|
||||||
</span>
|
<span className="inline-flex flex-wrap">
|
||||||
))
|
You are about to complete signing "
|
||||||
.with(RecipientRole.SIGNER, () => (
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
<span>
|
{documentTitle}
|
||||||
<Trans>
|
</span>
|
||||||
<span className="inline-flex flex-wrap">
|
".
|
||||||
You are about to complete signing "
|
</span>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
<br /> Are you sure?
|
||||||
{documentTitle}
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
".
|
))
|
||||||
</span>
|
.with(RecipientRole.APPROVER, () => (
|
||||||
<br /> Are you sure?
|
<span>
|
||||||
</Trans>
|
<Trans>
|
||||||
</span>
|
<span className="inline-flex flex-wrap">
|
||||||
))
|
You are about to complete approving{' '}
|
||||||
.with(RecipientRole.APPROVER, () => (
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
<span>
|
"{documentTitle}"
|
||||||
<Trans>
|
</span>
|
||||||
<span className="inline-flex flex-wrap">
|
.
|
||||||
You are about to complete approving{' '}
|
</span>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
<br /> Are you sure?
|
||||||
"{documentTitle}"
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
.
|
))
|
||||||
</span>
|
.otherwise(() => (
|
||||||
<br /> Are you sure?
|
<span>
|
||||||
</Trans>
|
<Trans>
|
||||||
</span>
|
<span className="inline-flex flex-wrap">
|
||||||
))
|
You are about to complete viewing "
|
||||||
.otherwise(() => (
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
<span>
|
{documentTitle}
|
||||||
<Trans>
|
</span>
|
||||||
<span className="inline-flex flex-wrap">
|
".
|
||||||
You are about to complete viewing "
|
</span>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
<br /> Are you sure?
|
||||||
{documentTitle}
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
".
|
))}
|
||||||
</span>
|
</div>
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentSigningDisclosure className="mt-4" />
|
{allowDictateNextSigner && (
|
||||||
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
{!isEditingNextSigner && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
The next recipient to sign this document will be{' '}
|
||||||
|
<span className="font-semibold">{form.watch('name')}</span> (
|
||||||
|
<span className="font-semibold">{form.watch('email')}</span>).
|
||||||
|
</p>
|
||||||
|
|
||||||
<DialogFooter>
|
<Button
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
type="button"
|
||||||
<Button
|
className="mt-2"
|
||||||
type="button"
|
variant="outline"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
size="sm"
|
||||||
variant="secondary"
|
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
||||||
onClick={() => {
|
>
|
||||||
setShowDialog(false);
|
<Trans>Update Recipient</Trans>
|
||||||
}}
|
</Button>
|
||||||
>
|
</div>
|
||||||
<Trans>Cancel</Trans>
|
)}
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
{isEditingNextSigner && (
|
||||||
type="button"
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
className="flex-1"
|
<FormField
|
||||||
disabled={!isComplete}
|
control={form.control}
|
||||||
loading={isSubmitting}
|
name="name"
|
||||||
onClick={onSignatureComplete}
|
render={({ field }) => (
|
||||||
>
|
<FormItem className="flex-1">
|
||||||
{match(role)
|
<FormLabel>
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
<Trans>Name</Trans>
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
</FormLabel>
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
<FormControl>
|
||||||
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
<Input
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
{...field}
|
||||||
.exhaustive()}
|
className="mt-2"
|
||||||
</Button>
|
placeholder="Enter the next signer's name"
|
||||||
</div>
|
/>
|
||||||
</DialogFooter>
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="email"
|
||||||
|
className="mt-2"
|
||||||
|
placeholder="Enter the next signer's email"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DocumentSigningDisclosure className="mt-4" />
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowDialog(false)}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!isComplete || !isNextSignerValid}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{match(role)
|
||||||
|
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||||
|
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
||||||
|
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
||||||
|
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
||||||
|
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||||
|
.exhaustive()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -18,14 +18,16 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog';
|
import {
|
||||||
|
AssistantConfirmationDialog,
|
||||||
|
type NextSigner,
|
||||||
|
} from '../../dialogs/assistant-confirmation-dialog';
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
|
|
||||||
@ -59,15 +61,17 @@ export const DocumentSigningForm = ({
|
|||||||
|
|
||||||
const assistantSignersId = useId();
|
const assistantSignersId = useId();
|
||||||
|
|
||||||
const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } =
|
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
||||||
useRequiredDocumentSigningContext();
|
|
||||||
|
|
||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
||||||
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: completeDocumentWithToken } =
|
const {
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
mutateAsync: completeDocumentWithToken,
|
||||||
|
isPending,
|
||||||
|
isSuccess,
|
||||||
|
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -75,10 +79,8 @@ export const DocumentSigningForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, formState } = useForm();
|
|
||||||
|
|
||||||
// Keep the loading state going if successful since the redirect may take some time.
|
// Keep the loading state going if successful since the redirect may take some time.
|
||||||
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
const isSubmitting = isPending || isSuccess;
|
||||||
|
|
||||||
const fieldsRequiringValidation = useMemo(
|
const fieldsRequiringValidation = useMemo(
|
||||||
() => fields.filter(isFieldUnsignedAndRequired),
|
() => fields.filter(isFieldUnsignedAndRequired),
|
||||||
@ -100,22 +102,6 @@ export const DocumentSigningForm = ({
|
|||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
|
||||||
setValidateUninsertedFields(true);
|
|
||||||
|
|
||||||
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
|
||||||
|
|
||||||
if (hasSignatureField && !signatureValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isFieldsValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await completeDocument();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAssistantFormSubmit = () => {
|
const onAssistantFormSubmit = () => {
|
||||||
if (uninsertedRecipientFields.length > 0) {
|
if (uninsertedRecipientFields.length > 0) {
|
||||||
return;
|
return;
|
||||||
@ -124,11 +110,11 @@ export const DocumentSigningForm = ({
|
|||||||
setIsConfirmationDialogOpen(true);
|
setIsConfirmationDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssistantConfirmDialogSubmit = async () => {
|
const handleAssistantConfirmDialogSubmit = async (nextSigner?: NextSigner) => {
|
||||||
setIsAssistantSubmitting(true);
|
setIsAssistantSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await completeDocument();
|
await completeDocument(undefined, nextSigner);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@ -141,12 +127,18 @@ export const DocumentSigningForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
const completeDocument = async (
|
||||||
await completeDocumentWithToken({
|
authOptions?: TRecipientActionAuth,
|
||||||
|
nextSigner?: { email: string; name: string },
|
||||||
|
) => {
|
||||||
|
const payload = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
authOptions,
|
authOptions,
|
||||||
});
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await completeDocumentWithToken(payload);
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
signerId: recipient.id,
|
signerId: recipient.id,
|
||||||
@ -161,6 +153,29 @@ export const DocumentSigningForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const nextRecipient = useMemo(() => {
|
||||||
|
if (
|
||||||
|
!document.documentMeta?.signingOrder ||
|
||||||
|
document.documentMeta.signingOrder !== 'SEQUENTIAL'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedRecipients = allRecipients.sort((a, b) => {
|
||||||
|
// Sort by signingOrder first (nulls last), then by id
|
||||||
|
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
|
||||||
|
if (a.signingOrder === null) return 1;
|
||||||
|
if (b.signingOrder === null) return -1;
|
||||||
|
if (a.signingOrder === b.signingOrder) return a.id - b.id;
|
||||||
|
return a.signingOrder - b.signingOrder;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
|
||||||
|
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
||||||
|
? sortedRecipients[currentIndex + 1]
|
||||||
|
: undefined;
|
||||||
|
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -210,12 +225,19 @@ export const DocumentSigningForm = ({
|
|||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
await completeDocument(undefined, nextSigner);
|
||||||
|
}}
|
||||||
role={recipient.role}
|
role={recipient.role}
|
||||||
disabled={!isRecipientsTurn}
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient
|
||||||
|
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -294,9 +316,8 @@ export const DocumentSigningForm = ({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
size="lg"
|
size="lg"
|
||||||
loading={isAssistantSubmitting}
|
loading={isAssistantSubmitting}
|
||||||
disabled={isAssistantSubmitting || uninsertedRecipientFields.length > 0}
|
|
||||||
>
|
>
|
||||||
{isAssistantSubmitting ? <Trans>Submitting...</Trans> : <Trans>Continue</Trans>}
|
<Trans>Continue</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -306,12 +327,20 @@ export const DocumentSigningForm = ({
|
|||||||
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
||||||
onConfirm={handleAssistantConfirmDialogSubmit}
|
onConfirm={handleAssistantConfirmDialogSubmit}
|
||||||
isSubmitting={isAssistantSubmitting}
|
isSubmitting={isAssistantSubmitting}
|
||||||
|
allowDictateNextSigner={
|
||||||
|
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||||
|
}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient
|
||||||
|
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
<div>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
|
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
|
||||||
<Trans>Please review the document before approving.</Trans>
|
<Trans>Please review the document before approving.</Trans>
|
||||||
@ -347,60 +376,53 @@ export const DocumentSigningForm = ({
|
|||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
<SignaturePadDialog
|
||||||
<CardContent className="p-0">
|
className="mt-2"
|
||||||
<SignaturePad
|
disabled={isSubmitting}
|
||||||
className="h-44 w-full"
|
value={signature ?? ''}
|
||||||
disabled={isSubmitting}
|
onChange={(v) => setSignature(v ?? '')}
|
||||||
defaultValue={signature ?? undefined}
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
onValidityChange={(isValid) => {
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||||
setSignatureValid(isValid);
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||||
}}
|
/>
|
||||||
onChange={(value) => {
|
|
||||||
if (signatureValid) {
|
|
||||||
setSignature(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{!signatureValid && (
|
|
||||||
<div className="text-destructive mt-2 text-sm">
|
|
||||||
<Trans>
|
|
||||||
Signature is too small. Please provide a more complete signature.
|
|
||||||
</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
|
||||||
onClick={async () => navigate(-1)}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
|
||||||
documentTitle={document.title}
|
|
||||||
fields={fields}
|
|
||||||
fieldsValidated={fieldsValidated}
|
|
||||||
role={recipient.role}
|
|
||||||
disabled={!isRecipientsTurn}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
|
||||||
|
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||||
|
onClick={async () => navigate(-1)}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DocumentSigningCompleteDialog
|
||||||
|
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||||
|
documentTitle={document.title}
|
||||||
|
fields={fields}
|
||||||
|
fieldsValidated={fieldsValidated}
|
||||||
|
disabled={!isRecipientsTurn}
|
||||||
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
await completeDocument(undefined, nextSigner);
|
||||||
|
}}
|
||||||
|
role={recipient.role}
|
||||||
|
allowDictateNextSigner={
|
||||||
|
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||||
|
}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient
|
||||||
|
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -40,9 +40,9 @@ import { DocumentReadOnlyFields } from '~/components/general/document/document-r
|
|||||||
|
|
||||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
export type SigningPageViewProps = {
|
export type DocumentSigningPageViewProps = {
|
||||||
document: DocumentAndSender;
|
|
||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
|
document: DocumentAndSender;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
completedFields: CompletedField[];
|
completedFields: CompletedField[];
|
||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
@ -50,13 +50,13 @@ export type SigningPageViewProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningPageView = ({
|
export const DocumentSigningPageView = ({
|
||||||
document,
|
|
||||||
recipient,
|
recipient,
|
||||||
|
document,
|
||||||
fields,
|
fields,
|
||||||
completedFields,
|
completedFields,
|
||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
}: SigningPageViewProps) => {
|
}: DocumentSigningPageViewProps) => {
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||||
@ -157,7 +157,7 @@ export const DocumentSigningPageView = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentReadOnlyFields fields={completedFields} />
|
<DocumentReadOnlyFields documentMeta={documentMeta || undefined} fields={completedFields} />
|
||||||
|
|
||||||
{recipient.role !== RecipientRole.ASSISTANT && (
|
{recipient.role !== RecipientRole.ASSISTANT && (
|
||||||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
||||||
@ -177,6 +177,8 @@ export const DocumentSigningPageView = ({
|
|||||||
key={field.id}
|
key={field.id}
|
||||||
field={field}
|
field={field}
|
||||||
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
||||||
|
uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled}
|
||||||
|
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.INITIALS, () => (
|
.with(FieldType.INITIALS, () => (
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
import { createContext, useContext, useState } from 'react';
|
||||||
|
|
||||||
|
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||||
|
|
||||||
export type DocumentSigningContextValue = {
|
export type DocumentSigningContextValue = {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
@ -7,8 +9,6 @@ export type DocumentSigningContextValue = {
|
|||||||
setEmail: (_value: string) => void;
|
setEmail: (_value: string) => void;
|
||||||
signature: string | null;
|
signature: string | null;
|
||||||
setSignature: (_value: string | null) => void;
|
setSignature: (_value: string | null) => void;
|
||||||
signatureValid: boolean;
|
|
||||||
setSignatureValid: (_valid: boolean) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DocumentSigningContext = createContext<DocumentSigningContextValue | null>(null);
|
const DocumentSigningContext = createContext<DocumentSigningContextValue | null>(null);
|
||||||
@ -31,6 +31,9 @@ export interface DocumentSigningProviderProps {
|
|||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
signature?: string | null;
|
signature?: string | null;
|
||||||
|
typedSignatureEnabled?: boolean;
|
||||||
|
uploadSignatureEnabled?: boolean;
|
||||||
|
drawSignatureEnabled?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,18 +41,31 @@ export const DocumentSigningProvider = ({
|
|||||||
fullName: initialFullName,
|
fullName: initialFullName,
|
||||||
email: initialEmail,
|
email: initialEmail,
|
||||||
signature: initialSignature,
|
signature: initialSignature,
|
||||||
|
typedSignatureEnabled = true,
|
||||||
|
uploadSignatureEnabled = true,
|
||||||
|
drawSignatureEnabled = true,
|
||||||
children,
|
children,
|
||||||
}: DocumentSigningProviderProps) => {
|
}: DocumentSigningProviderProps) => {
|
||||||
const [fullName, setFullName] = useState(initialFullName || '');
|
const [fullName, setFullName] = useState(initialFullName || '');
|
||||||
const [email, setEmail] = useState(initialEmail || '');
|
const [email, setEmail] = useState(initialEmail || '');
|
||||||
const [signature, setSignature] = useState(initialSignature || null);
|
|
||||||
const [signatureValid, setSignatureValid] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Ensure the user signature doesn't show up if it's not allowed.
|
||||||
if (initialSignature) {
|
const [signature, setSignature] = useState(
|
||||||
setSignature(initialSignature);
|
(() => {
|
||||||
}
|
const sig = initialSignature || '';
|
||||||
}, [initialSignature]);
|
const isBase64 = isBase64Image(sig);
|
||||||
|
|
||||||
|
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled)) {
|
||||||
|
return sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBase64 && typedSignatureEnabled) {
|
||||||
|
return sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningContext.Provider
|
<DocumentSigningContext.Provider
|
||||||
@ -60,8 +76,6 @@ export const DocumentSigningProvider = ({
|
|||||||
setEmail,
|
setEmail,
|
||||||
signature,
|
signature,
|
||||||
setSignature,
|
setSignature,
|
||||||
signatureValid,
|
|
||||||
setSignatureValid,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -31,10 +31,7 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ZRejectDocumentFormSchema = z.object({
|
const ZRejectDocumentFormSchema = z.object({
|
||||||
reason: z
|
reason: z.string().max(500, msg`Reason must be less than 500 characters`),
|
||||||
.string()
|
|
||||||
.min(5, msg`Please provide a reason`)
|
|
||||||
.max(500, msg`Reason must be less than 500 characters`),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
||||||
|
|||||||
@ -17,7 +17,6 @@ import type {
|
|||||||
} from '@documenso/trpc/server/field-router/schema';
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@ -29,11 +28,14 @@ import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
|||||||
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
|
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
|
||||||
|
|
||||||
export type DocumentSigningSignatureFieldProps = {
|
export type DocumentSigningSignatureFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
|
uploadSignatureEnabled?: boolean;
|
||||||
|
drawSignatureEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningSignatureField = ({
|
export const DocumentSigningSignatureField = ({
|
||||||
@ -41,6 +43,8 @@ export const DocumentSigningSignatureField = ({
|
|||||||
onSignField,
|
onSignField,
|
||||||
onUnsignField,
|
onUnsignField,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
|
uploadSignatureEnabled,
|
||||||
|
drawSignatureEnabled,
|
||||||
}: DocumentSigningSignatureFieldProps) => {
|
}: DocumentSigningSignatureFieldProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -52,12 +56,8 @@ export const DocumentSigningSignatureField = ({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [fontSize, setFontSize] = useState(2);
|
const [fontSize, setFontSize] = useState(2);
|
||||||
|
|
||||||
const {
|
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
||||||
signature: providedSignature,
|
useRequiredDocumentSigningContext();
|
||||||
setSignature: setProvidedSignature,
|
|
||||||
signatureValid,
|
|
||||||
setSignatureValid,
|
|
||||||
} = useRequiredDocumentSigningContext();
|
|
||||||
|
|
||||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
@ -89,7 +89,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
}, [field.inserted, signature?.signatureImageAsBase64]);
|
}, [field.inserted, signature?.signatureImageAsBase64]);
|
||||||
|
|
||||||
const onPreSign = () => {
|
const onPreSign = () => {
|
||||||
if (!providedSignature || !signatureValid) {
|
if (!providedSignature) {
|
||||||
setShowSignatureModal(true);
|
setShowSignatureModal(true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -102,6 +102,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
const onDialogSignClick = () => {
|
const onDialogSignClick = () => {
|
||||||
setShowSignatureModal(false);
|
setShowSignatureModal(false);
|
||||||
setProvidedSignature(localSignature);
|
setProvidedSignature(localSignature);
|
||||||
|
|
||||||
if (!localSignature) {
|
if (!localSignature) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -116,14 +117,14 @@ export const DocumentSigningSignatureField = ({
|
|||||||
try {
|
try {
|
||||||
const value = signature || providedSignature;
|
const value = signature || providedSignature;
|
||||||
|
|
||||||
if (!value || (signature && !signatureValid)) {
|
if (!value) {
|
||||||
setShowSignatureModal(true);
|
setShowSignatureModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTypedSignature = !value.startsWith('data:image');
|
const isTypedSignature = !value.startsWith('data:image');
|
||||||
|
|
||||||
if (isTypedSignature && !typedSignatureEnabled) {
|
if (isTypedSignature && typedSignatureEnabled === false) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`Typed signatures are not allowed. Please draw your signature.`),
|
description: _(msg`Typed signatures are not allowed. Please draw your signature.`),
|
||||||
@ -275,29 +276,14 @@ export const DocumentSigningSignatureField = ({
|
|||||||
</Trans>
|
</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<div className="">
|
<SignaturePad
|
||||||
<Label htmlFor="signature">
|
className="mt-2"
|
||||||
<Trans>Signature</Trans>
|
value={localSignature ?? ''}
|
||||||
</Label>
|
onChange={({ value }) => setLocalSignature(value)}
|
||||||
|
typedSignatureEnabled={typedSignatureEnabled}
|
||||||
<div className="border-border mt-2 rounded-md border">
|
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||||
<SignaturePad
|
drawSignatureEnabled={drawSignatureEnabled}
|
||||||
id="signature"
|
/>
|
||||||
className="h-44 w-full"
|
|
||||||
onChange={(value) => setLocalSignature(value)}
|
|
||||||
allowTypedSignature={typedSignatureEnabled}
|
|
||||||
onValidityChange={(isValid) => {
|
|
||||||
setSignatureValid(isValid);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!signatureValid && (
|
|
||||||
<div className="text-destructive mt-2 text-sm">
|
|
||||||
<Trans>Signature is too small. Please provide a more complete signature.</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentSigningDisclosure />
|
<DocumentSigningDisclosure />
|
||||||
|
|
||||||
@ -317,7 +303,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!localSignature || !signatureValid}
|
disabled={!localSignature}
|
||||||
onClick={() => onDialogSignClick()}
|
onClick={() => onDialogSignClick()}
|
||||||
>
|
>
|
||||||
<Trans>Sign</Trans>
|
<Trans>Sign</Trans>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
|
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
@ -71,7 +72,7 @@ export const DocumentEditForm = ({
|
|||||||
|
|
||||||
const { recipients, fields } = document;
|
const { recipients, fields } = document;
|
||||||
|
|
||||||
const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({
|
const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: (newData) => {
|
onSuccess: (newData) => {
|
||||||
utils.document.getDocumentWithDetailsById.setData(
|
utils.document.getDocumentWithDetailsById.setData(
|
||||||
@ -174,7 +175,7 @@ export const DocumentEditForm = ({
|
|||||||
|
|
||||||
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const { timezone, dateFormat, redirectUrl, language } = data.meta;
|
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
|
||||||
|
|
||||||
await updateDocument({
|
await updateDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
@ -190,6 +191,9 @@ export const DocumentEditForm = ({
|
|||||||
dateFormat,
|
dateFormat,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
language: isValidLanguageCode(language) ? language : undefined,
|
language: isValidLanguageCode(language) ? language : undefined,
|
||||||
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -213,6 +217,13 @@ export const DocumentEditForm = ({
|
|||||||
signingOrder: data.signingOrder,
|
signingOrder: data.signingOrder,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
updateDocument({
|
||||||
|
documentId: document.id,
|
||||||
|
meta: {
|
||||||
|
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
setRecipients({
|
setRecipients({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
recipients: data.signers.map((signer) => ({
|
recipients: data.signers.map((signer) => ({
|
||||||
@ -242,14 +253,6 @@ export const DocumentEditForm = ({
|
|||||||
fields: data.fields,
|
fields: data.fields,
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateDocument({
|
|
||||||
documentId: document.id,
|
|
||||||
|
|
||||||
meta: {
|
|
||||||
typedSignatureEnabled: data.typedSignatureEnabled,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear all field data from localStorage
|
// Clear all field data from localStorage
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
const key = localStorage.key(i);
|
const key = localStorage.key(i);
|
||||||
@ -365,6 +368,7 @@ export const DocumentEditForm = ({
|
|||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
signingOrder={document.documentMeta?.signingOrder}
|
signingOrder={document.documentMeta?.signingOrder}
|
||||||
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
isDocumentEnterprise={isDocumentEnterprise}
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
@ -378,7 +382,6 @@ export const DocumentEditForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
|
||||||
teamId={team?.id}
|
teamId={team?.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentMeta } from '@prisma/client';
|
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
||||||
import { FieldType, SigningStatus } from '@prisma/client';
|
import { FieldType, SigningStatus } from '@prisma/client';
|
||||||
import { Clock, EyeOffIcon } from 'lucide-react';
|
import { Clock, EyeOffIcon } from 'lucide-react';
|
||||||
import { P, match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
@ -27,7 +27,7 @@ import { PopoverHover } from '@documenso/ui/primitives/popover';
|
|||||||
|
|
||||||
export type DocumentReadOnlyFieldsProps = {
|
export type DocumentReadOnlyFieldsProps = {
|
||||||
fields: DocumentField[];
|
fields: DocumentField[];
|
||||||
documentMeta?: DocumentMeta;
|
documentMeta?: DocumentMeta | TemplateMeta;
|
||||||
showFieldStatus?: boolean;
|
showFieldStatus?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
@ -124,6 +125,8 @@ export const TemplateEditForm = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||||
|
const { signatureTypes } = data.meta;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateTemplateSettings({
|
await updateTemplateSettings({
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
@ -136,6 +139,9 @@ export const TemplateEditForm = ({
|
|||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
...data.meta,
|
...data.meta,
|
||||||
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -161,6 +167,7 @@ export const TemplateEditForm = ({
|
|||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
meta: {
|
meta: {
|
||||||
signingOrder: data.signingOrder,
|
signingOrder: data.signingOrder,
|
||||||
|
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@ -187,13 +194,6 @@ export const TemplateEditForm = ({
|
|||||||
fields: data.fields,
|
fields: data.fields,
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateTemplateSettings({
|
|
||||||
templateId: template.id,
|
|
||||||
meta: {
|
|
||||||
typedSignatureEnabled: data.typedSignatureEnabled,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear all field data from localStorage
|
// Clear all field data from localStorage
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
const key = localStorage.key(i);
|
const key = localStorage.key(i);
|
||||||
@ -271,6 +271,7 @@ export const TemplateEditForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
signingOrder={template.templateMeta?.signingOrder}
|
signingOrder={template.templateMeta?.signingOrder}
|
||||||
|
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
|
||||||
templateDirectLink={template.directLink}
|
templateDirectLink={template.directLink}
|
||||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||||
isEnterprise={isEnterprise}
|
isEnterprise={isEnterprise}
|
||||||
@ -284,7 +285,6 @@ export const TemplateEditForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
teamId={team?.id}
|
teamId={team?.id}
|
||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
</DocumentFlowFormContainer>
|
</DocumentFlowFormContainer>
|
||||||
|
|||||||
@ -23,12 +23,12 @@ import {
|
|||||||
getUsersWithSubscriptionsCount,
|
getUsersWithSubscriptionsCount,
|
||||||
} from '@documenso/lib/server-only/admin/get-users-stats';
|
} from '@documenso/lib/server-only/admin/get-users-stats';
|
||||||
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
|
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
|
||||||
|
|
||||||
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
|
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
|
||||||
import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents';
|
import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents';
|
||||||
import { CardMetric } from '~/components/general/metric-card';
|
import { CardMetric } from '~/components/general/metric-card';
|
||||||
|
|
||||||
|
import { version } from '../../../../package.json';
|
||||||
import type { Route } from './+types/stats';
|
import type { Route } from './+types/stats';
|
||||||
|
|
||||||
export async function loader() {
|
export async function loader() {
|
||||||
@ -89,7 +89,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
|||||||
value={usersWithSubscriptionsCount}
|
value={usersWithSubscriptionsCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${env('APP_VERSION')}`} />
|
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${version}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 gap-8">
|
<div className="mt-16 gap-8">
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { redirect } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
|
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||||
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
RECIPIENT_ROLES_DESCRIPTION,
|
RECIPIENT_ROLES_DESCRIPTION,
|
||||||
@ -59,6 +60,8 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
throw redirect('/');
|
throw redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isPlatformDocument = await isDocumentPlatform(document);
|
||||||
|
|
||||||
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
|
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
|
||||||
|
|
||||||
const auditLogs = await getDocumentCertificateAuditLogs({
|
const auditLogs = await getDocumentCertificateAuditLogs({
|
||||||
@ -70,6 +73,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
return {
|
return {
|
||||||
document,
|
document,
|
||||||
documentLanguage,
|
documentLanguage,
|
||||||
|
isPlatformDocument,
|
||||||
auditLogs,
|
auditLogs,
|
||||||
messages,
|
messages,
|
||||||
};
|
};
|
||||||
@ -85,7 +89,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
* Update: Maybe <Trans> tags work now after RR7 migration.
|
* Update: Maybe <Trans> tags work now after RR7 migration.
|
||||||
*/
|
*/
|
||||||
export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
|
export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
|
||||||
const { document, documentLanguage, auditLogs, messages } = loaderData;
|
const { document, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
|
||||||
|
|
||||||
const { i18n, _ } = useLingui();
|
const { i18n, _ } = useLingui();
|
||||||
|
|
||||||
@ -245,24 +249,24 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
|||||||
{signature.secondaryId}
|
{signature.secondaryId}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
|
||||||
<span className="font-medium">{_(msg`IP Address`)}:</span>{' '}
|
|
||||||
<span className="inline-block">
|
|
||||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
|
|
||||||
<span className="font-medium">{_(msg`Device`)}:</span>{' '}
|
|
||||||
<span className="inline-block">
|
|
||||||
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground">N/A</p>
|
<p className="text-muted-foreground">N/A</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||||
|
<span className="font-medium">{_(msg`IP Address`)}:</span>{' '}
|
||||||
|
<span className="inline-block">
|
||||||
|
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
|
||||||
|
<span className="font-medium">{_(msg`Device`)}:</span>{' '}
|
||||||
|
<span className="inline-block">
|
||||||
|
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell truncate={false} className="w-[min-content] align-top">
|
<TableCell truncate={false} className="w-[min-content] align-top">
|
||||||
@ -337,15 +341,17 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="my-8 flex-row-reverse">
|
{isPlatformDocument && (
|
||||||
<div className="flex items-end justify-end gap-x-4">
|
<div className="my-8 flex-row-reverse">
|
||||||
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
<div className="flex items-end justify-end gap-x-4">
|
||||||
{_(msg`Signing certificate provided by`)}:
|
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
||||||
</p>
|
{_(msg`Signing certificate provided by`)}:
|
||||||
|
</p>
|
||||||
|
|
||||||
<BrandingLogo className="max-h-6 print:max-h-4" />
|
<BrandingLogo className="max-h-6 print:max-h-4" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,7 +79,14 @@ export default function DirectTemplatePage() {
|
|||||||
const { template, directTemplateRecipient } = data;
|
const { template, directTemplateRecipient } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
|
<DocumentSigningProvider
|
||||||
|
email={user?.email}
|
||||||
|
fullName={user?.name}
|
||||||
|
signature={user?.signature}
|
||||||
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||||
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||||
|
>
|
||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
recipient={directTemplateRecipient}
|
recipient={directTemplateRecipient}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentSigningOrder, DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { Clock8 } from 'lucide-react';
|
import { Clock8 } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
|
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
|
||||||
@ -13,6 +13,7 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum
|
|||||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||||
|
import { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||||
@ -72,7 +73,24 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
? await getRecipientsForAssistant({
|
? await getRecipientsForAssistant({
|
||||||
token,
|
token,
|
||||||
})
|
})
|
||||||
: [];
|
: [recipient];
|
||||||
|
|
||||||
|
if (
|
||||||
|
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
|
||||||
|
recipient.role !== RecipientRole.ASSISTANT
|
||||||
|
) {
|
||||||
|
const nextPendingRecipient = await getNextPendingRecipient({
|
||||||
|
documentId: document.id,
|
||||||
|
currentRecipientId: recipient.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextPendingRecipient) {
|
||||||
|
allRecipients.push({
|
||||||
|
...nextPendingRecipient,
|
||||||
|
fields: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
documentAuth: document.authOptions,
|
documentAuth: document.authOptions,
|
||||||
@ -215,6 +233,9 @@ export default function SigningPage() {
|
|||||||
email={recipient.email}
|
email={recipient.email}
|
||||||
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
||||||
signature={user?.email === recipient.email ? user?.signature : undefined}
|
signature={user?.email === recipient.email ? user?.signature : undefined}
|
||||||
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||||
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||||
>
|
>
|
||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={document.authOptions}
|
documentAuthOptions={document.authOptions}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import type { Route } from './+types/team.verify.transfer.token';
|
import type { Route } from './+types/team.verify.transfer.$token';
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
const { token } = params;
|
const { token } = params;
|
||||||
@ -131,7 +131,14 @@ export default function EmbedDirectTemplatePage() {
|
|||||||
} = useSuperLoaderData<typeof loader>();
|
} = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
|
<DocumentSigningProvider
|
||||||
|
email={user?.email}
|
||||||
|
fullName={user?.name}
|
||||||
|
signature={user?.signature}
|
||||||
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||||
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||||
|
>
|
||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
@ -8,6 +8,7 @@ import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-ent
|
|||||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
@ -33,7 +34,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
const { user } = await getOptionalSession(request);
|
const { user } = await getOptionalSession(request);
|
||||||
|
|
||||||
const [document, fields, recipient] = await Promise.all([
|
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||||
getDocumentAndSenderByToken({
|
getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
@ -41,6 +42,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
}).catch(() => null),
|
}).catch(() => null),
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getRecipientByToken({ token }).catch(() => null),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
|
getCompletedFieldsForToken({ token }).catch(() => []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// `document.directLink` is always available but we're doing this to
|
// `document.directLink` is always available but we're doing this to
|
||||||
@ -130,6 +132,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
allRecipients,
|
allRecipients,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
|
completedFields,
|
||||||
hidePoweredBy,
|
hidePoweredBy,
|
||||||
isPlatformDocument,
|
isPlatformDocument,
|
||||||
isEnterpriseDocument,
|
isEnterpriseDocument,
|
||||||
@ -145,6 +148,7 @@ export default function EmbedSignDocumentPage() {
|
|||||||
allRecipients,
|
allRecipients,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
|
completedFields,
|
||||||
hidePoweredBy,
|
hidePoweredBy,
|
||||||
isPlatformDocument,
|
isPlatformDocument,
|
||||||
isEnterpriseDocument,
|
isEnterpriseDocument,
|
||||||
@ -156,6 +160,9 @@ export default function EmbedSignDocumentPage() {
|
|||||||
email={recipient.email}
|
email={recipient.email}
|
||||||
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
||||||
signature={user?.email === recipient.email ? user?.signature : undefined}
|
signature={user?.email === recipient.email ? user?.signature : undefined}
|
||||||
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||||
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||||
>
|
>
|
||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={document.authOptions}
|
documentAuthOptions={document.authOptions}
|
||||||
@ -168,6 +175,7 @@ export default function EmbedSignDocumentPage() {
|
|||||||
documentData={document.documentData}
|
documentData={document.documentData}
|
||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
completedFields={completedFields}
|
||||||
metadata={document.documentMeta}
|
metadata={document.documentMeta}
|
||||||
isCompleted={isDocumentCompleted(document.status)}
|
isCompleted={isDocumentCompleted(document.status)}
|
||||||
hidePoweredBy={
|
hidePoweredBy={
|
||||||
66
apps/remix/app/routes/embed+/v1.authoring+/_layout.tsx
Normal file
66
apps/remix/app/routes/embed+/v1.authoring+/_layout.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { useLayoutEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Outlet } from 'react-router';
|
||||||
|
|
||||||
|
import { TrpcProvider, trpc } from '@documenso/trpc/react';
|
||||||
|
|
||||||
|
import { EmbedClientLoading } from '~/components/embed/embed-client-loading';
|
||||||
|
import { ZBaseEmbedAuthoringSchema } from '~/types/embed-authoring-base-schema';
|
||||||
|
import { injectCss } from '~/utils/css-vars';
|
||||||
|
|
||||||
|
export default function AuthoringLayout() {
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: verifyEmbeddingPresignToken,
|
||||||
|
isPending: isVerifyingEmbeddingPresignToken,
|
||||||
|
data: isVerified,
|
||||||
|
} = trpc.embeddingPresign.verifyEmbeddingPresignToken.useMutation();
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
try {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
const result = ZBaseEmbedAuthoringSchema.safeParse(
|
||||||
|
JSON.parse(decodeURIComponent(atob(hash))),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, css, cssVars, darkModeDisabled } = result.data;
|
||||||
|
|
||||||
|
if (darkModeDisabled) {
|
||||||
|
document.documentElement.classList.add('dark-mode-disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
injectCss({
|
||||||
|
css,
|
||||||
|
cssVars,
|
||||||
|
});
|
||||||
|
|
||||||
|
void verifyEmbeddingPresignToken({ token }).then((result) => {
|
||||||
|
if (result.success) {
|
||||||
|
setToken(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error verifying embedding presign token:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isVerifyingEmbeddingPresignToken) {
|
||||||
|
return <EmbedClientLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof isVerified !== 'undefined' && !isVerified.success) {
|
||||||
|
return <div>Invalid embedding presign token</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TrpcProvider headers={{ authorization: `Bearer ${token}` }}>
|
||||||
|
<Outlet />
|
||||||
|
</TrpcProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
export default function EmbeddingAuthoringCompletedPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
// Get templateId and externalId from URL search params
|
||||||
|
const templateId = searchParams.get('templateId');
|
||||||
|
const documentId = searchParams.get('documentId');
|
||||||
|
const externalId = searchParams.get('externalId');
|
||||||
|
|
||||||
|
const id = Number(templateId || documentId);
|
||||||
|
const type = templateId ? 'template' : 'document';
|
||||||
|
|
||||||
|
// Send postMessage to parent window with the details
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id || !window.parent || window.parent === window) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
type: `${type}-created`,
|
||||||
|
[`${type}Id`]: id,
|
||||||
|
externalId,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}, [id, type, externalId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center p-6 text-center">
|
||||||
|
<div className="mx-auto w-full max-w-md">
|
||||||
|
<CheckCircle2 className="text-primary mx-auto h-16 w-16" />
|
||||||
|
|
||||||
|
<h1 className="mt-6 text-2xl font-bold">
|
||||||
|
{type === 'template' ? <Trans>Template Created</Trans> : <Trans>Document Created</Trans>}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
{type === 'template' ? (
|
||||||
|
<Trans>Your template has been created successfully</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Your document has been created successfully</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
apps/remix/app/routes/embed+/v1.authoring+/document.create.tsx
Normal file
184
apps/remix/app/routes/embed+/v1.authoring+/document.create.tsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { useLayoutEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
|
||||||
|
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
|
||||||
|
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
|
||||||
|
import {
|
||||||
|
ConfigureFieldsView,
|
||||||
|
type TConfigureFieldsFormSchema,
|
||||||
|
} from '~/components/embed/authoring/configure-fields-view';
|
||||||
|
import {
|
||||||
|
type TBaseEmbedAuthoringSchema,
|
||||||
|
ZBaseEmbedAuthoringSchema,
|
||||||
|
} from '~/types/embed-authoring-base-schema';
|
||||||
|
|
||||||
|
export default function EmbeddingAuthoringDocumentCreatePage() {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(null);
|
||||||
|
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(null);
|
||||||
|
const [features, setFeatures] = useState<TBaseEmbedAuthoringSchema['features'] | null>(null);
|
||||||
|
const [externalId, setExternalId] = useState<string | null>(null);
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
|
||||||
|
const { mutateAsync: createEmbeddingDocument } =
|
||||||
|
trpc.embeddingPresign.createEmbeddingDocument.useMutation();
|
||||||
|
|
||||||
|
const handleConfigurePageViewSubmit = (data: TConfigureEmbedFormSchema) => {
|
||||||
|
// Store the configuration data and move to the field placement stage
|
||||||
|
setConfiguration(data);
|
||||||
|
setCurrentStep(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackToConfig = (data: TConfigureFieldsFormSchema) => {
|
||||||
|
// Return to the configuration view but keep the data
|
||||||
|
setFields(data);
|
||||||
|
setCurrentStep(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfigureFieldsSubmit = async (data: TConfigureFieldsFormSchema) => {
|
||||||
|
try {
|
||||||
|
if (!configuration || !configuration.documentData) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: _('Error'),
|
||||||
|
description: _('Please configure the document first'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = data.fields;
|
||||||
|
|
||||||
|
const documentData = await putPdfFile({
|
||||||
|
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
|
||||||
|
name: configuration.documentData.name,
|
||||||
|
type: configuration.documentData.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use the externalId from the URL fragment if available
|
||||||
|
const documentExternalId = externalId || configuration.meta.externalId;
|
||||||
|
|
||||||
|
const createResult = await createEmbeddingDocument({
|
||||||
|
title: configuration.title,
|
||||||
|
documentDataId: documentData.id,
|
||||||
|
externalId: documentExternalId,
|
||||||
|
meta: {
|
||||||
|
...configuration.meta,
|
||||||
|
drawSignatureEnabled:
|
||||||
|
configuration.meta.signatureTypes.length === 0 ||
|
||||||
|
configuration.meta.signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
typedSignatureEnabled:
|
||||||
|
configuration.meta.signatureTypes.length === 0 ||
|
||||||
|
configuration.meta.signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
|
uploadSignatureEnabled:
|
||||||
|
configuration.meta.signatureTypes.length === 0 ||
|
||||||
|
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
|
},
|
||||||
|
recipients: configuration.signers.map((signer) => ({
|
||||||
|
name: signer.name,
|
||||||
|
email: signer.email,
|
||||||
|
role: signer.role,
|
||||||
|
fields: fields
|
||||||
|
.filter((field) => field.signerEmail === signer.email)
|
||||||
|
// There's a gnarly discriminated union that makes this hard to satisfy, we're casting for the second
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.map<any>((f) => ({
|
||||||
|
...f,
|
||||||
|
pageX: f.pageX,
|
||||||
|
pageY: f.pageY,
|
||||||
|
width: f.pageWidth,
|
||||||
|
height: f.pageHeight,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _('Success'),
|
||||||
|
description: _('Document created successfully'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send a message to the parent window with the document details
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
type: 'document-created',
|
||||||
|
documentId: createResult.documentId,
|
||||||
|
externalId: documentExternalId,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
// Navigate to the completion page instead of the document details page
|
||||||
|
await navigate(
|
||||||
|
`/embed/v1/authoring/create-completed?documentId=${createResult.documentId}&externalId=${documentExternalId}#${hash}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating document:', err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: _('Error'),
|
||||||
|
description: _('Failed to create document'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
try {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
const result = ZBaseEmbedAuthoringSchema.safeParse(
|
||||||
|
JSON.parse(decodeURIComponent(atob(hash))),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFeatures(result.data.features);
|
||||||
|
|
||||||
|
// Extract externalId from the parsed data if available
|
||||||
|
if (result.data.externalId) {
|
||||||
|
setExternalId(result.data.externalId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing embedding params:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg p-6">
|
||||||
|
<ConfigureDocumentProvider isTemplate={false} features={features ?? {}}>
|
||||||
|
<Stepper currentStep={currentStep} setCurrentStep={setCurrentStep}>
|
||||||
|
<ConfigureDocumentView
|
||||||
|
defaultValues={configuration ?? undefined}
|
||||||
|
onSubmit={handleConfigurePageViewSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfigureFieldsView
|
||||||
|
configData={configuration!}
|
||||||
|
defaultValues={fields ?? undefined}
|
||||||
|
onBack={handleBackToConfig}
|
||||||
|
onSubmit={handleConfigureFieldsSubmit}
|
||||||
|
/>
|
||||||
|
</Stepper>
|
||||||
|
</ConfigureDocumentProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
apps/remix/app/routes/embed+/v1.authoring+/template.create.tsx
Normal file
175
apps/remix/app/routes/embed+/v1.authoring+/template.create.tsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import { useLayoutEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
|
||||||
|
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
|
||||||
|
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
|
||||||
|
import {
|
||||||
|
ConfigureFieldsView,
|
||||||
|
type TConfigureFieldsFormSchema,
|
||||||
|
} from '~/components/embed/authoring/configure-fields-view';
|
||||||
|
import {
|
||||||
|
type TBaseEmbedAuthoringSchema,
|
||||||
|
ZBaseEmbedAuthoringSchema,
|
||||||
|
} from '~/types/embed-authoring-base-schema';
|
||||||
|
|
||||||
|
export default function EmbeddingAuthoringTemplateCreatePage() {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(null);
|
||||||
|
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(null);
|
||||||
|
const [features, setFeatures] = useState<TBaseEmbedAuthoringSchema['features'] | null>(null);
|
||||||
|
const [externalId, setExternalId] = useState<string | null>(null);
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
|
||||||
|
const { mutateAsync: createEmbeddingTemplate } =
|
||||||
|
trpc.embeddingPresign.createEmbeddingTemplate.useMutation();
|
||||||
|
|
||||||
|
const handleConfigurePageViewSubmit = (data: TConfigureEmbedFormSchema) => {
|
||||||
|
// Store the configuration data and move to the field placement stage
|
||||||
|
setConfiguration(data);
|
||||||
|
setCurrentStep(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackToConfig = (data: TConfigureFieldsFormSchema) => {
|
||||||
|
// Return to the configuration view but keep the data
|
||||||
|
setFields(data);
|
||||||
|
setCurrentStep(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfigureFieldsSubmit = async (data: TConfigureFieldsFormSchema) => {
|
||||||
|
try {
|
||||||
|
console.log('configuration', configuration);
|
||||||
|
console.log('data', data);
|
||||||
|
if (!configuration || !configuration.documentData) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: _('Error'),
|
||||||
|
description: _('Please configure the template first'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = data.fields;
|
||||||
|
|
||||||
|
const documentData = await putPdfFile({
|
||||||
|
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
|
||||||
|
name: configuration.documentData.name,
|
||||||
|
type: configuration.documentData.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use the externalId from the URL fragment if available
|
||||||
|
const metaWithExternalId = {
|
||||||
|
...configuration.meta,
|
||||||
|
externalId: externalId || configuration.meta.externalId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const createResult = await createEmbeddingTemplate({
|
||||||
|
title: configuration.title,
|
||||||
|
documentDataId: documentData.id,
|
||||||
|
meta: metaWithExternalId,
|
||||||
|
recipients: configuration.signers.map((signer) => ({
|
||||||
|
name: signer.name,
|
||||||
|
email: signer.email,
|
||||||
|
role: signer.role,
|
||||||
|
fields: fields
|
||||||
|
.filter((field) => field.signerEmail === signer.email)
|
||||||
|
// There's a gnarly discriminated union that makes this hard to satisfy, we're casting for the second
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.map<any>((field) => ({
|
||||||
|
...field,
|
||||||
|
pageX: field.pageX,
|
||||||
|
pageY: field.pageY,
|
||||||
|
width: field.pageWidth,
|
||||||
|
height: field.pageHeight,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _('Success'),
|
||||||
|
description: _('Template created successfully'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send a message to the parent window with the template details
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
type: 'template-created',
|
||||||
|
templateId: createResult.templateId,
|
||||||
|
externalId: metaWithExternalId.externalId,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
// Navigate to the completion page instead of the template details page
|
||||||
|
await navigate(
|
||||||
|
`/embed/v1/authoring/create-completed?templateId=${createResult.templateId}&externalId=${metaWithExternalId.externalId}#${hash}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating template:', err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: _('Error'),
|
||||||
|
description: _('Failed to create template'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
try {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
const result = ZBaseEmbedAuthoringSchema.safeParse(
|
||||||
|
JSON.parse(decodeURIComponent(atob(hash))),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFeatures(result.data.features);
|
||||||
|
|
||||||
|
// Extract externalId from the parsed data if available
|
||||||
|
if (result.data.externalId) {
|
||||||
|
setExternalId(result.data.externalId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing embedding params:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg p-6">
|
||||||
|
<ConfigureDocumentProvider isTemplate={true} features={features ?? {}}>
|
||||||
|
<Stepper currentStep={currentStep} setCurrentStep={setCurrentStep}>
|
||||||
|
<ConfigureDocumentView
|
||||||
|
defaultValues={configuration ?? undefined}
|
||||||
|
onSubmit={handleConfigurePageViewSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfigureFieldsView
|
||||||
|
configData={configuration!}
|
||||||
|
defaultValues={fields ?? undefined}
|
||||||
|
onBack={handleBackToConfig}
|
||||||
|
onSubmit={handleConfigureFieldsSubmit}
|
||||||
|
/>
|
||||||
|
</Stepper>
|
||||||
|
</ConfigureDocumentProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/remix/app/types/embed-authoring-base-schema.ts
Normal file
23
apps/remix/app/types/embed-authoring-base-schema.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZBaseEmbedDataSchema } from './embed-base-schemas';
|
||||||
|
|
||||||
|
export const ZBaseEmbedAuthoringSchema = z
|
||||||
|
.object({
|
||||||
|
token: z.string(),
|
||||||
|
externalId: z.string().optional(),
|
||||||
|
features: z
|
||||||
|
.object({
|
||||||
|
allowConfigureSignatureTypes: z.boolean().optional(),
|
||||||
|
allowConfigureLanguage: z.boolean().optional(),
|
||||||
|
allowConfigureDateFormat: z.boolean().optional(),
|
||||||
|
allowConfigureTimezone: z.boolean().optional(),
|
||||||
|
allowConfigureRedirectUrl: z.boolean().optional(),
|
||||||
|
allowConfigureCommunication: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default({}),
|
||||||
|
})
|
||||||
|
.and(ZBaseEmbedDataSchema);
|
||||||
|
|
||||||
|
export type TBaseEmbedAuthoringSchema = z.infer<typeof ZBaseEmbedAuthoringSchema>;
|
||||||
@ -14,4 +14,5 @@ export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
|
|||||||
.transform((value) => value || undefined),
|
.transform((value) => value || undefined),
|
||||||
lockName: z.boolean().optional().default(false),
|
lockName: z.boolean().optional().default(false),
|
||||||
allowDocumentRejection: z.boolean().optional(),
|
allowDocumentRejection: z.boolean().optional(),
|
||||||
|
showOtherRecipientsCompletedFields: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -99,5 +99,6 @@
|
|||||||
"vite": "^6.1.0",
|
"vite": "^6.1.0",
|
||||||
"vite-plugin-babel-macros": "^1.0.6",
|
"vite-plugin-babel-macros": "^1.0.6",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
},
|
||||||
}
|
"version": "1.10.0-rc.5"
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { lingui } from '@lingui/vite-plugin';
|
|||||||
import { reactRouter } from '@react-router/dev/vite';
|
import { reactRouter } from '@react-router/dev/vite';
|
||||||
import autoprefixer from 'autoprefixer';
|
import autoprefixer from 'autoprefixer';
|
||||||
import serverAdapter from 'hono-react-router-adapter/vite';
|
import serverAdapter from 'hono-react-router-adapter/vite';
|
||||||
|
import path from 'node:path';
|
||||||
import tailwindcss from 'tailwindcss';
|
import tailwindcss from 'tailwindcss';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import macrosPlugin from 'vite-plugin-babel-macros';
|
import macrosPlugin from 'vite-plugin-babel-macros';
|
||||||
@ -44,9 +45,15 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
https: 'node:https',
|
https: 'node:https',
|
||||||
'.prisma/client/default': '../../node_modules/.prisma/client/default.js',
|
'.prisma/client/default': path.resolve(
|
||||||
'.prisma/client/index-browser': '../../node_modules/.prisma/client/index-browser.js',
|
__dirname,
|
||||||
canvas: './app/types/empty-module.ts',
|
'../../node_modules/.prisma/client/default.js',
|
||||||
|
),
|
||||||
|
'.prisma/client/index-browser': path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../../node_modules/.prisma/client/index-browser.js',
|
||||||
|
),
|
||||||
|
canvas: path.resolve(__dirname, './app/types/empty-module.ts'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -33,7 +33,7 @@ FROM base AS installer
|
|||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN apk add --no-cache jq
|
RUN apk add --no-cache jq
|
||||||
# Required for node_modules/aws-crt
|
# Required for node_modules/aws-crt
|
||||||
RUN apk add --no-cache make cmake g++ openssl
|
RUN apk add --no-cache make cmake g++ openssl bash
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ command -v docker >/dev/null 2>&1 || {
|
|||||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
||||||
|
|
||||||
|
# Get Git information
|
||||||
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||||
GIT_SHA="$(git rev-parse HEAD)"
|
GIT_SHA="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
@ -15,12 +16,39 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
|
|||||||
echo "App version: $APP_VERSION"
|
echo "App version: $APP_VERSION"
|
||||||
echo "Git SHA: $GIT_SHA"
|
echo "Git SHA: $GIT_SHA"
|
||||||
|
|
||||||
|
# Build with temporary base tag
|
||||||
docker build -f "$SCRIPT_DIR/Dockerfile" \
|
docker build -f "$SCRIPT_DIR/Dockerfile" \
|
||||||
--progress=plain \
|
--progress=plain \
|
||||||
-t "documenso/documenso:latest" \
|
-t "documenso-base" \
|
||||||
-t "documenso/documenso:$GIT_SHA" \
|
|
||||||
-t "documenso/documenso:$APP_VERSION" \
|
|
||||||
-t "ghcr.io/documenso/documenso:latest" \
|
|
||||||
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
|
|
||||||
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
|
|
||||||
"$MONOREPO_ROOT"
|
"$MONOREPO_ROOT"
|
||||||
|
|
||||||
|
# Handle repository tagging
|
||||||
|
if [ ! -z "$DOCKER_REPOSITORY" ]; then
|
||||||
|
echo "Using custom repository: $DOCKER_REPOSITORY"
|
||||||
|
|
||||||
|
# Add tags for custom repository
|
||||||
|
docker tag "documenso-base" "$DOCKER_REPOSITORY:latest"
|
||||||
|
docker tag "documenso-base" "$DOCKER_REPOSITORY:$GIT_SHA"
|
||||||
|
|
||||||
|
# Add version tag if available
|
||||||
|
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
|
||||||
|
docker tag "documenso-base" "$DOCKER_REPOSITORY:$APP_VERSION"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Using default repositories: dockerhub and ghcr.io"
|
||||||
|
|
||||||
|
# Add tags for both default repositories
|
||||||
|
docker tag "documenso-base" "documenso/documenso:latest"
|
||||||
|
docker tag "documenso-base" "documenso/documenso:$GIT_SHA"
|
||||||
|
docker tag "documenso-base" "ghcr.io/documenso/documenso:latest"
|
||||||
|
docker tag "documenso-base" "ghcr.io/documenso/documenso:$GIT_SHA"
|
||||||
|
|
||||||
|
# Add version tags if available
|
||||||
|
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
|
||||||
|
docker tag "documenso-base" "documenso/documenso:$APP_VERSION"
|
||||||
|
docker tag "documenso-base" "ghcr.io/documenso/documenso:$APP_VERSION"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove the temporary base tag
|
||||||
|
docker rmi "documenso-base"
|
||||||
|
|||||||
@ -9,11 +9,11 @@ SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
|||||||
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
||||||
|
|
||||||
# Get the platform from environment variable or set to linux/amd64 if not set
|
# Get the platform from environment variable or set to linux/amd64 if not set
|
||||||
# quote the string to prevent word splitting
|
|
||||||
if [ -z "$PLATFORM" ]; then
|
if [ -z "$PLATFORM" ]; then
|
||||||
PLATFORM="linux/amd64"
|
PLATFORM="linux/amd64"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Get Git information
|
||||||
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||||
GIT_SHA="$(git rev-parse HEAD)"
|
GIT_SHA="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
@ -21,14 +21,41 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
|
|||||||
echo "App version: $APP_VERSION"
|
echo "App version: $APP_VERSION"
|
||||||
echo "Git SHA: $GIT_SHA"
|
echo "Git SHA: $GIT_SHA"
|
||||||
|
|
||||||
|
# Build with temporary base tag
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
-f "$SCRIPT_DIR/Dockerfile" \
|
-f "$SCRIPT_DIR/Dockerfile" \
|
||||||
--platform=$PLATFORM \
|
--platform=$PLATFORM \
|
||||||
--progress=plain \
|
--progress=plain \
|
||||||
-t "documenso/documenso:latest" \
|
-t "documenso-base" \
|
||||||
-t "documenso/documenso:$GIT_SHA" \
|
|
||||||
-t "documenso/documenso:$APP_VERSION" \
|
|
||||||
-t "ghcr.io/documenso/documenso:latest" \
|
|
||||||
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
|
|
||||||
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
|
|
||||||
"$MONOREPO_ROOT"
|
"$MONOREPO_ROOT"
|
||||||
|
|
||||||
|
# Handle repository tagging
|
||||||
|
if [ ! -z "$DOCKER_REPOSITORY" ]; then
|
||||||
|
echo "Using custom repository: $DOCKER_REPOSITORY"
|
||||||
|
|
||||||
|
# Add tags for custom repository
|
||||||
|
docker tag "documenso-base" "$DOCKER_REPOSITORY:latest"
|
||||||
|
docker tag "documenso-base" "$DOCKER_REPOSITORY:$GIT_SHA"
|
||||||
|
|
||||||
|
# Add version tag if available
|
||||||
|
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
|
||||||
|
docker tag "documenso-base" "$DOCKER_REPOSITORY:$APP_VERSION"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Using default repositories: dockerhub and ghcr.io"
|
||||||
|
|
||||||
|
# Add tags for both default repositories
|
||||||
|
docker tag "documenso-base" "documenso/documenso:latest"
|
||||||
|
docker tag "documenso-base" "documenso/documenso:$GIT_SHA"
|
||||||
|
docker tag "documenso-base" "ghcr.io/documenso/documenso:latest"
|
||||||
|
docker tag "documenso-base" "ghcr.io/documenso/documenso:$GIT_SHA"
|
||||||
|
|
||||||
|
# Add version tags if available
|
||||||
|
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
|
||||||
|
docker tag "documenso-base" "documenso/documenso:$APP_VERSION"
|
||||||
|
docker tag "documenso-base" "ghcr.io/documenso/documenso:$APP_VERSION"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove the temporary base tag
|
||||||
|
docker rmi "documenso-base"
|
||||||
|
|||||||
15
package-lock.json
generated
15
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.9.0-rc.11",
|
"version": "1.10.0-rc.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.9.0-rc.11",
|
"version": "1.10.0-rc.5",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@ -95,6 +95,7 @@
|
|||||||
},
|
},
|
||||||
"apps/remix": {
|
"apps/remix": {
|
||||||
"name": "@documenso/remix",
|
"name": "@documenso/remix",
|
||||||
|
"version": "1.10.0-rc.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
"@documenso/assets": "*",
|
"@documenso/assets": "*",
|
||||||
@ -41559,6 +41560,7 @@
|
|||||||
"@vvo/tzdb": "^6.117.0",
|
"@vvo/tzdb": "^6.117.0",
|
||||||
"csv-parse": "^5.6.0",
|
"csv-parse": "^5.6.0",
|
||||||
"inngest": "^3.19.13",
|
"inngest": "^3.19.13",
|
||||||
|
"jose": "^6.0.0",
|
||||||
"kysely": "0.26.3",
|
"kysely": "0.26.3",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
@ -41581,6 +41583,15 @@
|
|||||||
"@types/pg": "^8.11.4"
|
"@types/pg": "^8.11.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/lib/node_modules/jose": {
|
||||||
|
"version": "6.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.10.tgz",
|
||||||
|
"integrity": "sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/prettier-config": {
|
"packages/prettier-config": {
|
||||||
"name": "@documenso/prettier-config",
|
"name": "@documenso/prettier-config",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.9.0-rc.11",
|
"version": "1.10.0-rc.5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev --filter=@documenso/remix",
|
"dev": "turbo run dev --filter=@documenso/remix",
|
||||||
@ -14,11 +14,11 @@
|
|||||||
"prepare": "husky && husky install || true",
|
"prepare": "husky && husky install || true",
|
||||||
"commitlint": "commitlint --edit",
|
"commitlint": "commitlint --edit",
|
||||||
"clean": "turbo run clean && rimraf node_modules",
|
"clean": "turbo run clean && rimraf node_modules",
|
||||||
"d": "npm run dx && npm run dev",
|
"d": "npm run dx && npm run translate:compile && npm run dev",
|
||||||
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed",
|
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed",
|
||||||
"dx:up": "docker compose -f docker/development/compose.yml up -d",
|
"dx:up": "docker compose -f docker/development/compose.yml up -d",
|
||||||
"dx:down": "docker compose -f docker/development/compose.yml down",
|
"dx:down": "docker compose -f docker/development/compose.yml down",
|
||||||
"ci": "turbo run test:e2e",
|
"ci": "turbo run build --filter=@documenso/remix && turbo run test:e2e",
|
||||||
"prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma",
|
"prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma",
|
||||||
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
|
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
|
||||||
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
|
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
|
||||||
@ -80,4 +80,4 @@
|
|||||||
"trigger.dev": {
|
"trigger.dev": {
|
||||||
"endpointId": "documenso-app"
|
"endpointId": "documenso-app"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -323,8 +323,11 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
dateFormat: dateFormat?.value,
|
dateFormat: dateFormat?.value,
|
||||||
redirectUrl: body.meta.redirectUrl,
|
redirectUrl: body.meta.redirectUrl,
|
||||||
signingOrder: body.meta.signingOrder,
|
signingOrder: body.meta.signingOrder,
|
||||||
|
allowDictateNextSigner: body.meta.allowDictateNextSigner,
|
||||||
language: body.meta.language,
|
language: body.meta.language,
|
||||||
typedSignatureEnabled: body.meta.typedSignatureEnabled,
|
typedSignatureEnabled: body.meta.typedSignatureEnabled,
|
||||||
|
uploadSignatureEnabled: body.meta.uploadSignatureEnabled,
|
||||||
|
drawSignatureEnabled: body.meta.drawSignatureEnabled,
|
||||||
distributionMethod: body.meta.distributionMethod,
|
distributionMethod: body.meta.distributionMethod,
|
||||||
emailSettings: body.meta.emailSettings,
|
emailSettings: body.meta.emailSettings,
|
||||||
requestMetadata: metadata,
|
requestMetadata: metadata,
|
||||||
|
|||||||
@ -155,8 +155,11 @@ export const ZCreateDocumentMutationSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
redirectUrl: z.string(),
|
redirectUrl: z.string(),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||||
|
allowDictateNextSigner: z.boolean().optional(),
|
||||||
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
||||||
typedSignatureEnabled: z.boolean().optional().default(true),
|
typedSignatureEnabled: z.boolean().optional().default(true),
|
||||||
|
uploadSignatureEnabled: z.boolean().optional().default(true),
|
||||||
|
drawSignatureEnabled: z.boolean().optional().default(true),
|
||||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
|
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||||
})
|
})
|
||||||
@ -218,6 +221,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
|||||||
dateFormat: z.string(),
|
dateFormat: z.string(),
|
||||||
redirectUrl: z.string(),
|
redirectUrl: z.string(),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||||
|
allowDictateNextSigner: z.boolean().optional(),
|
||||||
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
@ -285,9 +289,12 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
|||||||
dateFormat: z.string(),
|
dateFormat: z.string(),
|
||||||
redirectUrl: ZUrlSchema,
|
redirectUrl: ZUrlSchema,
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||||
|
allowDictateNextSigner: z.boolean(),
|
||||||
language: z.enum(SUPPORTED_LANGUAGE_CODES),
|
language: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
||||||
typedSignatureEnabled: z.boolean(),
|
typedSignatureEnabled: z.boolean(),
|
||||||
|
uploadSignatureEnabled: z.boolean(),
|
||||||
|
drawSignatureEnabled: z.boolean(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema,
|
emailSettings: ZDocumentEmailSettingsSchema,
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
|
|||||||
203
packages/app-tests/e2e/api/v2/embedding-presign.spec.ts
Normal file
203
packages/app-tests/e2e/api/v2/embedding-presign.spec.ts
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
test.describe('Embedding Presign API', () => {
|
||||||
|
test('createEmbeddingPresignToken: should create a token with default expiration', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request.post(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
apiToken: token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
|
||||||
|
console.log(responseData);
|
||||||
|
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
|
expect(responseData.token).toBeDefined();
|
||||||
|
expect(responseData.expiresAt).toBeDefined();
|
||||||
|
expect(responseData.expiresIn).toBe(3600); // Default 1 hour in seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createEmbeddingPresignToken: should create a token with custom expiration', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request.post(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
apiToken: token,
|
||||||
|
expiresIn: 120, // 2 hours
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
|
||||||
|
console.log(responseData);
|
||||||
|
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
|
expect(responseData.token).toBeDefined();
|
||||||
|
expect(responseData.expiresAt).toBeDefined();
|
||||||
|
expect(responseData.expiresIn).toBe(7200); // 2 hours in seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createEmbeddingPresignToken: should create a token with immediate expiration in dev mode', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request.post(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
apiToken: token,
|
||||||
|
expiresIn: 0, // Immediate expiration
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
|
||||||
|
console.log(responseData);
|
||||||
|
|
||||||
|
expect(responseData.token).toBeDefined();
|
||||||
|
expect(responseData.expiresAt).toBeDefined();
|
||||||
|
expect(responseData.expiresIn).toBe(0); // 0 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyEmbeddingPresignToken: should verify a valid token', async ({ request }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// First create a token
|
||||||
|
const createResponse = await request.post(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
apiToken: token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createResponse.ok()).toBeTruthy();
|
||||||
|
const createResponseData = await createResponse.json();
|
||||||
|
|
||||||
|
console.log('Create response:', createResponseData);
|
||||||
|
|
||||||
|
const presignToken = createResponseData.token;
|
||||||
|
|
||||||
|
// Then verify it
|
||||||
|
const verifyResponse = await request.post(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
token: presignToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(verifyResponse.ok()).toBeTruthy();
|
||||||
|
expect(verifyResponse.status()).toBe(200);
|
||||||
|
|
||||||
|
const verifyResponseData = await verifyResponse.json();
|
||||||
|
|
||||||
|
console.log('Verify response:', verifyResponseData);
|
||||||
|
|
||||||
|
expect(verifyResponseData.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyEmbeddingPresignToken: should reject an invalid token', async ({ request }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request.post(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
token: 'invalid-token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
|
||||||
|
console.log('Invalid token response:', responseData);
|
||||||
|
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
|
expect(responseData.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -210,7 +210,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
fields: [FieldType.DATE],
|
fields: [FieldType.DATE, FieldType.SIGNATURE],
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
for (const recipient of recipients) {
|
||||||
@ -246,7 +246,9 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await signSignaturePad(page);
|
if (fields.some((field) => field.type === FieldType.SIGNATURE)) {
|
||||||
|
await signSignaturePad(page);
|
||||||
|
}
|
||||||
|
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
@ -307,7 +309,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
fields: [FieldType.DATE],
|
fields: [FieldType.DATE, FieldType.SIGNATURE],
|
||||||
updateDocumentOptions: {
|
updateDocumentOptions: {
|
||||||
authOptions: createDocumentAuthOptions({
|
authOptions: createDocumentAuthOptions({
|
||||||
globalAccessAuth: null,
|
globalAccessAuth: null,
|
||||||
@ -349,7 +351,9 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await signSignaturePad(page);
|
if (fields.some((field) => field.type === FieldType.SIGNATURE)) {
|
||||||
|
await signSignaturePad(page);
|
||||||
|
}
|
||||||
|
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|||||||
@ -0,0 +1,390 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
DocumentSigningOrder,
|
||||||
|
DocumentStatus,
|
||||||
|
FieldType,
|
||||||
|
RecipientRole,
|
||||||
|
SigningStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { signDirectSignaturePad, signSignaturePad } from '../fixtures/signature';
|
||||||
|
|
||||||
|
test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dictation is enabled', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const firstSigner = await seedUser();
|
||||||
|
const secondSigner = await seedUser();
|
||||||
|
const thirdSigner = await seedUser();
|
||||||
|
|
||||||
|
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [firstSigner, secondSigner, thirdSigner],
|
||||||
|
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 3 }],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
documentMeta: {
|
||||||
|
upsert: {
|
||||||
|
create: {
|
||||||
|
allowDictateNextSigner: true,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
allowDictateNextSigner: true,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRecipient = recipients[0];
|
||||||
|
const { token, fields } = firstRecipient;
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
// Fill in all fields
|
||||||
|
for (const field of fields) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.locator('#custom-text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete signing and update next recipient
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
|
||||||
|
// Verify next recipient info is shown
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
|
||||||
|
|
||||||
|
// Update next recipient
|
||||||
|
await page.locator('button').filter({ hasText: 'Update Recipient' }).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Use dialog context to ensure we're targeting the correct form fields
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.getByLabel('Name').fill('New Recipient');
|
||||||
|
await dialog.getByLabel('Email').fill('new.recipient@example.com');
|
||||||
|
|
||||||
|
// Submit and verify completion
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
|
||||||
|
// Verify document and recipient states
|
||||||
|
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
||||||
|
where: { id: document.id },
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
orderBy: { signingOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document should still be pending as there are more recipients
|
||||||
|
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
|
||||||
|
|
||||||
|
// First recipient should be completed
|
||||||
|
const updatedFirstRecipient = updatedDocument.recipients[0];
|
||||||
|
expect(updatedFirstRecipient.signingStatus).toBe(SigningStatus.SIGNED);
|
||||||
|
|
||||||
|
// Second recipient should be the new recipient
|
||||||
|
const updatedSecondRecipient = updatedDocument.recipients[1];
|
||||||
|
expect(updatedSecondRecipient.name).toBe('New Recipient');
|
||||||
|
expect(updatedSecondRecipient.email).toBe('new.recipient@example.com');
|
||||||
|
expect(updatedSecondRecipient.signingOrder).toBe(2);
|
||||||
|
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[NEXT_RECIPIENT_DICTATION]: should not show dictation UI when disabled', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const firstSigner = await seedUser();
|
||||||
|
const secondSigner = await seedUser();
|
||||||
|
|
||||||
|
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [firstSigner, secondSigner],
|
||||||
|
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
documentMeta: {
|
||||||
|
upsert: {
|
||||||
|
create: {
|
||||||
|
allowDictateNextSigner: false,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
allowDictateNextSigner: false,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRecipient = recipients[0];
|
||||||
|
const { token, fields } = firstRecipient;
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
// Fill in all fields
|
||||||
|
for (const field of fields) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.locator('#custom-text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete signing
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
|
||||||
|
// Verify next recipient UI is not shown
|
||||||
|
await expect(
|
||||||
|
page.getByText('The next recipient to sign this document will be'),
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Submit and verify completion
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
|
||||||
|
// Verify document and recipient states
|
||||||
|
|
||||||
|
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
||||||
|
where: { id: document.id },
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
orderBy: { signingOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document should still be pending as there are more recipients
|
||||||
|
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
|
||||||
|
|
||||||
|
// First recipient should be completed
|
||||||
|
const updatedFirstRecipient = updatedDocument.recipients[0];
|
||||||
|
expect(updatedFirstRecipient.signingStatus).toBe(SigningStatus.SIGNED);
|
||||||
|
|
||||||
|
// Second recipient should remain unchanged
|
||||||
|
const updatedSecondRecipient = updatedDocument.recipients[1];
|
||||||
|
expect(updatedSecondRecipient.email).toBe(secondSigner.email);
|
||||||
|
expect(updatedSecondRecipient.signingOrder).toBe(2);
|
||||||
|
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[NEXT_RECIPIENT_DICTATION]: should work with parallel signing flow', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const firstSigner = await seedUser();
|
||||||
|
const secondSigner = await seedUser();
|
||||||
|
|
||||||
|
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [firstSigner, secondSigner],
|
||||||
|
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
documentMeta: {
|
||||||
|
upsert: {
|
||||||
|
create: {
|
||||||
|
allowDictateNextSigner: false,
|
||||||
|
signingOrder: DocumentSigningOrder.PARALLEL,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
allowDictateNextSigner: false,
|
||||||
|
signingOrder: DocumentSigningOrder.PARALLEL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test both recipients can sign in parallel
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const { token, fields } = recipient;
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
// Fill in all fields
|
||||||
|
for (const field of fields) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.locator('#custom-text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete signing
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
|
||||||
|
// Verify next recipient UI is not shown in parallel flow
|
||||||
|
await expect(
|
||||||
|
page.getByText('The next recipient to sign this document will be'),
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Submit and verify completion
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify final document and recipient states
|
||||||
|
await expect(async () => {
|
||||||
|
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
||||||
|
where: { id: document.id },
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
orderBy: { signingOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document should be completed since all recipients have signed
|
||||||
|
expect(updatedDocument.status).toBe(DocumentStatus.COMPLETED);
|
||||||
|
|
||||||
|
// All recipients should be completed
|
||||||
|
for (const recipient of updatedDocument.recipients) {
|
||||||
|
expect(recipient.signingStatus).toBe(SigningStatus.SIGNED);
|
||||||
|
}
|
||||||
|
}).toPass();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const assistant = await seedUser();
|
||||||
|
const signer = await seedUser();
|
||||||
|
const thirdSigner = await seedUser();
|
||||||
|
|
||||||
|
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [assistant, signer, thirdSigner],
|
||||||
|
recipientsCreateOptions: [
|
||||||
|
{ signingOrder: 1, role: RecipientRole.ASSISTANT },
|
||||||
|
{ signingOrder: 2, role: RecipientRole.SIGNER },
|
||||||
|
{ signingOrder: 3, role: RecipientRole.SIGNER },
|
||||||
|
],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
documentMeta: {
|
||||||
|
upsert: {
|
||||||
|
create: {
|
||||||
|
allowDictateNextSigner: true,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
allowDictateNextSigner: true,
|
||||||
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantRecipient = recipients[0];
|
||||||
|
const { token, fields } = assistantRecipient;
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Assist Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
await page.getByRole('radio', { name: assistantRecipient.name }).click();
|
||||||
|
|
||||||
|
// Fill in all fields
|
||||||
|
for (const field of fields) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.SIGNATURE) {
|
||||||
|
await signDirectSignaturePad(page);
|
||||||
|
await page.getByRole('button', { name: 'Sign', exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.locator('#custom-text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete assisting and update next recipient
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Verify next recipient info is shown
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
|
||||||
|
|
||||||
|
// Use dialog context to ensure we're targeting the correct form fields
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.getByRole('button', { name: 'Update Recipient' }).click();
|
||||||
|
await dialog.getByLabel('Name').fill('New Signer');
|
||||||
|
await dialog.getByLabel('Email').fill('new.signer@example.com');
|
||||||
|
|
||||||
|
// Submit and verify completion
|
||||||
|
await page.getByRole('button', { name: /Continue|Proceed/i }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
|
||||||
|
// Verify document and recipient states
|
||||||
|
await expect(async () => {
|
||||||
|
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
||||||
|
where: { id: document.id },
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
orderBy: { signingOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document should still be pending as there are more recipients
|
||||||
|
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
|
||||||
|
|
||||||
|
// Assistant should be completed
|
||||||
|
const updatedAssistant = updatedDocument.recipients[0];
|
||||||
|
expect(updatedAssistant.signingStatus).toBe(SigningStatus.SIGNED);
|
||||||
|
expect(updatedAssistant.role).toBe(RecipientRole.ASSISTANT);
|
||||||
|
|
||||||
|
// Second recipient should be the new signer
|
||||||
|
const updatedSigner = updatedDocument.recipients[1];
|
||||||
|
expect(updatedSigner.name).toBe('New Signer');
|
||||||
|
expect(updatedSigner.email).toBe('new.signer@example.com');
|
||||||
|
expect(updatedSigner.signingOrder).toBe(2);
|
||||||
|
expect(updatedSigner.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||||
|
expect(updatedSigner.role).toBe(RecipientRole.SIGNER);
|
||||||
|
|
||||||
|
// Third recipient should remain unchanged
|
||||||
|
const thirdRecipient = updatedDocument.recipients[2];
|
||||||
|
expect(thirdRecipient.email).toBe(thirdSigner.email);
|
||||||
|
expect(thirdRecipient.signingOrder).toBe(3);
|
||||||
|
expect(thirdRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||||
|
expect(thirdRecipient.role).toBe(RecipientRole.SIGNER);
|
||||||
|
}).toPass();
|
||||||
|
});
|
||||||
@ -222,7 +222,10 @@ test.describe('Signing Certificate Tests', () => {
|
|||||||
|
|
||||||
// Toggle signing certificate setting
|
// Toggle signing certificate setting
|
||||||
await page.getByLabel('Include the Signing Certificate in the Document').click();
|
await page.getByLabel('Include the Signing Certificate in the Document').click();
|
||||||
await page.getByRole('button', { name: /Save/ }).first().click();
|
await page
|
||||||
|
.getByRole('button', { name: /Update/ })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
@ -236,7 +239,10 @@ test.describe('Signing Certificate Tests', () => {
|
|||||||
|
|
||||||
// Toggle the setting back to true
|
// Toggle the setting back to true
|
||||||
await page.getByLabel('Include the Signing Certificate in the Document').click();
|
await page.getByLabel('Include the Signing Certificate in the Document').click();
|
||||||
await page.getByRole('button', { name: /Save/ }).first().click();
|
await page
|
||||||
|
.getByRole('button', { name: /Update/ })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
|||||||
@ -1,40 +1,28 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will open the signature pad dialog and sign it.
|
||||||
|
*/
|
||||||
export const signSignaturePad = async (page: Page) => {
|
export const signSignaturePad = async (page: Page) => {
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
const canvas = page.getByTestId('signature-pad');
|
await page.getByTestId('signature-pad-dialog-button').click();
|
||||||
|
|
||||||
const box = await canvas.boundingBox();
|
// Click type tab
|
||||||
|
await page.getByRole('tab', { name: 'Type' }).click();
|
||||||
|
await page.getByTestId('signature-pad-type-input').fill('Signature');
|
||||||
|
|
||||||
if (!box) {
|
// Click Next button
|
||||||
throw new Error('Signature pad not found');
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
}
|
};
|
||||||
|
|
||||||
// Calculate center point
|
/**
|
||||||
const centerX = box.x + box.width / 2;
|
* For when the signature pad is already open.
|
||||||
const centerY = box.y + box.height / 2;
|
*/
|
||||||
|
export const signDirectSignaturePad = async (page: Page) => {
|
||||||
// Calculate square size (making it slightly smaller than the canvas)
|
await page.waitForTimeout(200);
|
||||||
const squareSize = Math.min(box.width, box.height) * 0.4; // 40% of the smallest dimension
|
|
||||||
|
// Click type tab
|
||||||
// Move to center
|
await page.getByRole('tab', { name: 'Type' }).click();
|
||||||
await page.mouse.move(centerX, centerY);
|
await page.getByTestId('signature-pad-type-input').fill('Signature');
|
||||||
await page.mouse.down();
|
|
||||||
|
|
||||||
// Draw square clockwise from center
|
|
||||||
// Move right
|
|
||||||
await page.mouse.move(centerX + squareSize, centerY, { steps: 10 });
|
|
||||||
// Move down
|
|
||||||
await page.mouse.move(centerX + squareSize, centerY + squareSize, { steps: 10 });
|
|
||||||
// Move left
|
|
||||||
await page.mouse.move(centerX - squareSize, centerY + squareSize, { steps: 10 });
|
|
||||||
// Move up
|
|
||||||
await page.mouse.move(centerX - squareSize, centerY - squareSize, { steps: 10 });
|
|
||||||
// Move right
|
|
||||||
await page.mouse.move(centerX + squareSize, centerY - squareSize, { steps: 10 });
|
|
||||||
// Move down to close the square
|
|
||||||
await page.mouse.move(centerX + squareSize, centerY, { steps: 10 });
|
|
||||||
|
|
||||||
await page.mouse.up();
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -56,6 +56,7 @@ test('[PUBLIC_PROFILE]: create profile', async ({ page }) => {
|
|||||||
// Go back to public profile page.
|
// Go back to public profile page.
|
||||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/public-profile`);
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/public-profile`);
|
||||||
await page.getByRole('switch').click();
|
await page.getByRole('switch').click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
// Assert values.
|
// Assert values.
|
||||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
||||||
@ -127,6 +128,7 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
|||||||
// Go back to public profile page.
|
// Go back to public profile page.
|
||||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`);
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`);
|
||||||
await page.getByRole('switch').click();
|
await page.getByRole('switch').click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
// Assert values.
|
// Assert values.
|
||||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
||||||
|
|||||||
@ -23,7 +23,7 @@ test('[TEAMS]: update the default document visibility in the team global setting
|
|||||||
// !: Brittle selector
|
// !: Brittle selector
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').first().click();
|
||||||
await page.getByRole('option', { name: 'Admin' }).click();
|
await page.getByRole('option', { name: 'Admin' }).click();
|
||||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||||
|
|
||||||
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
||||||
await expect(toast).toBeVisible();
|
await expect(toast).toBeVisible();
|
||||||
@ -47,7 +47,7 @@ test('[TEAMS]: update the sender details in the team global settings', async ({
|
|||||||
|
|
||||||
await expect(checkbox).toBeChecked();
|
await expect(checkbox).toBeChecked();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||||
|
|
||||||
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
||||||
await expect(toast).toBeVisible();
|
await expect(toast).toBeVisible();
|
||||||
|
|||||||
186
packages/app-tests/e2e/teams/team-signature-settings.spec.ts
Normal file
186
packages/app-tests/e2e/teams/team-signature-settings.spec.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import {
|
||||||
|
seedTeamDocumentWithMeta,
|
||||||
|
seedTeamDocuments,
|
||||||
|
seedTeamTemplateWithMeta,
|
||||||
|
} from '@documenso/prisma/seed/documents';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test('[TEAMS]: check that default team signature settings are all enabled', async ({ page }) => {
|
||||||
|
const { team } = await seedTeamDocuments();
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: team.owner.email,
|
||||||
|
password: 'password',
|
||||||
|
redirectPath: `/t/${team.url}/settings/preferences`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that the default created team settings has all signatures enabled
|
||||||
|
await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
|
||||||
|
|
||||||
|
const document = await seedTeamDocumentWithMeta(team);
|
||||||
|
|
||||||
|
// Create a document and check the settings
|
||||||
|
await page.goto(`/t/${team.url}/documents/${document.id}/edit`);
|
||||||
|
|
||||||
|
// Verify that the settings match
|
||||||
|
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||||
|
await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
|
||||||
|
|
||||||
|
// Go to document and check that the signatured tabs are correct.
|
||||||
|
await page.goto(`/sign/${document.recipients[0].token}`);
|
||||||
|
await page.getByTestId('signature-pad-dialog-button').click();
|
||||||
|
|
||||||
|
// Check the tab values
|
||||||
|
await expect(page.getByRole('tab', { name: 'Type' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Upload' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
||||||
|
const { team } = await seedTeamDocuments();
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: team.owner.email,
|
||||||
|
password: 'password',
|
||||||
|
redirectPath: `/t/${team.url}/settings/preferences`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allTabs = ['Type', 'Upload', 'Draw'];
|
||||||
|
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
|
||||||
|
|
||||||
|
for (const tabs of tabTest) {
|
||||||
|
await page.goto(`/t/${team.url}/settings/preferences`);
|
||||||
|
|
||||||
|
// Update combobox to have the correct tabs
|
||||||
|
await page.getByTestId('signature-types-combobox').click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
||||||
|
|
||||||
|
// Clear all selected items.
|
||||||
|
for (const tab of allTabs) {
|
||||||
|
const item = page.getByRole('option', { name: tab });
|
||||||
|
|
||||||
|
const isSelected = (await item.innerHTML()).includes('opacity-100');
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
await item.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected wanted items.
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const item = page.getByRole('option', { name: tab });
|
||||||
|
await item.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||||
|
|
||||||
|
const document = await seedTeamDocumentWithMeta(team);
|
||||||
|
|
||||||
|
// Go to document and check that the signatured tabs are correct.
|
||||||
|
await page.goto(`/sign/${document.recipients[0].token}`);
|
||||||
|
await page.getByTestId('signature-pad-dialog-button').click();
|
||||||
|
|
||||||
|
// Check the tab values
|
||||||
|
for (const tab of allTabs) {
|
||||||
|
if (tabs.includes(tab)) {
|
||||||
|
await expect(page.getByRole('tab', { name: tab })).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.getByRole('tab', { name: tab })).not.toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
||||||
|
const { team } = await seedTeamDocuments();
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: team.owner.email,
|
||||||
|
password: 'password',
|
||||||
|
redirectPath: `/t/${team.url}/settings/preferences`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allTabs = ['Type', 'Upload', 'Draw'];
|
||||||
|
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
|
||||||
|
|
||||||
|
for (const tabs of tabTest) {
|
||||||
|
await page.goto(`/t/${team.url}/settings/preferences`);
|
||||||
|
|
||||||
|
// Update combobox to have the correct tabs
|
||||||
|
await page.getByTestId('signature-types-combobox').click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
||||||
|
|
||||||
|
// Clear all selected items.
|
||||||
|
for (const tab of allTabs) {
|
||||||
|
const item = page.getByRole('option', { name: tab });
|
||||||
|
|
||||||
|
const isSelected = (await item.innerHTML()).includes('opacity-100');
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
await item.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected wanted items.
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const item = page.getByRole('option', { name: tab });
|
||||||
|
await item.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||||
|
|
||||||
|
// Wait for finish
|
||||||
|
await page.getByText('Document preferences updated').waitFor({ state: 'visible' });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const template = await seedTeamTemplateWithMeta(team);
|
||||||
|
|
||||||
|
await page.goto(`/t/${team.url}/templates/${template.id}`);
|
||||||
|
await page.getByRole('button', { name: 'Use' }).click();
|
||||||
|
|
||||||
|
// Check the send document checkbox to true
|
||||||
|
await page.getByLabel('Send document').click();
|
||||||
|
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const document = await prisma.document.findFirst({
|
||||||
|
where: {
|
||||||
|
templateId: template.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
documentMeta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test kinda flaky, debug here.
|
||||||
|
// console.log({
|
||||||
|
// tabs,
|
||||||
|
// typedSignatureEnabled: document?.documentMeta?.typedSignatureEnabled,
|
||||||
|
// uploadSignatureEnabled: document?.documentMeta?.uploadSignatureEnabled,
|
||||||
|
// drawSignatureEnabled: document?.documentMeta?.drawSignatureEnabled,
|
||||||
|
// });
|
||||||
|
|
||||||
|
expect(document?.documentMeta?.typedSignatureEnabled).toEqual(tabs.includes('Type'));
|
||||||
|
expect(document?.documentMeta?.uploadSignatureEnabled).toEqual(tabs.includes('Upload'));
|
||||||
|
expect(document?.documentMeta?.drawSignatureEnabled).toEqual(tabs.includes('Draw'));
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -298,6 +298,7 @@ test('[DIRECT_TEMPLATES]: use direct template link with 2 recipients', async ({
|
|||||||
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
||||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
|
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-emai
|
|||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
import { signSignaturePad } from '../fixtures/signature';
|
||||||
|
|
||||||
test('[USER] update full name', async ({ page }) => {
|
test('[USER] update full name', async ({ page }) => {
|
||||||
const user = await seedUser();
|
const user = await seedUser();
|
||||||
@ -12,7 +13,7 @@ test('[USER] update full name', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByLabel('Full Name').fill('John Doe');
|
await page.getByLabel('Full Name').fill('John Doe');
|
||||||
|
|
||||||
await page.getByPlaceholder('Type your signature').fill('John Doe');
|
await signSignaturePad(page);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update profile' }).click();
|
await page.getByRole('button', { name: 'Update profile' }).click();
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
|
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
|
||||||
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
|
"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/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
|
"test:e2e": "NODE_OPTIONS=--experimental-require-module NODE_ENV=test start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { prisma } from '@documenso/prisma';
|
|||||||
|
|
||||||
import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices';
|
import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices';
|
||||||
|
|
||||||
export type IsDocumentPlatformOptions = Pick<Document, 'id' | 'userId' | 'teamId'>;
|
export type IsDocumentPlatformOptions = Pick<Document, 'userId' | 'teamId'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the user is platform, or has permission to use platform features on
|
* Whether the user is platform, or has permission to use platform features on
|
||||||
|
|||||||
@ -34,3 +34,29 @@ export const DOCUMENT_DISTRIBUTION_METHODS: Record<string, DocumentDistributionM
|
|||||||
description: msg`None`,
|
description: msg`None`,
|
||||||
},
|
},
|
||||||
} satisfies Record<DocumentDistributionMethod, DocumentDistributionMethodTypeData>;
|
} satisfies Record<DocumentDistributionMethod, DocumentDistributionMethodTypeData>;
|
||||||
|
|
||||||
|
export enum DocumentSignatureType {
|
||||||
|
DRAW = 'draw',
|
||||||
|
TYPE = 'type',
|
||||||
|
UPLOAD = 'upload',
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocumentSignatureTypeData = {
|
||||||
|
label: MessageDescriptor;
|
||||||
|
value: DocumentSignatureType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DOCUMENT_SIGNATURE_TYPES = {
|
||||||
|
[DocumentSignatureType.DRAW]: {
|
||||||
|
label: msg`Draw`,
|
||||||
|
value: DocumentSignatureType.DRAW,
|
||||||
|
},
|
||||||
|
[DocumentSignatureType.TYPE]: {
|
||||||
|
label: msg`Type`,
|
||||||
|
value: DocumentSignatureType.TYPE,
|
||||||
|
},
|
||||||
|
[DocumentSignatureType.UPLOAD]: {
|
||||||
|
label: msg`Upload`,
|
||||||
|
value: DocumentSignatureType.UPLOAD,
|
||||||
|
},
|
||||||
|
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;
|
||||||
|
|||||||
4
packages/lib/constants/signatures.ts
Normal file
4
packages/lib/constants/signatures.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const SIGNATURE_CANVAS_DPI = 2;
|
||||||
|
export const SIGNATURE_MIN_COVERAGE_THRESHOLD = 0.01;
|
||||||
|
|
||||||
|
export const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,');
|
||||||
@ -23,6 +23,8 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
|||||||
brandingHidePoweredBy: z.boolean(),
|
brandingHidePoweredBy: z.boolean(),
|
||||||
teamId: z.number(),
|
teamId: z.number(),
|
||||||
typedSignatureEnabled: z.boolean(),
|
typedSignatureEnabled: z.boolean(),
|
||||||
|
uploadSignatureEnabled: z.boolean(),
|
||||||
|
drawSignatureEnabled: z.boolean(),
|
||||||
})
|
})
|
||||||
.nullish(),
|
.nullish(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
"@lingui/core": "^5.2.0",
|
"@lingui/core": "^5.2.0",
|
||||||
"@lingui/macro": "^5.2.0",
|
"@lingui/macro": "^5.2.0",
|
||||||
"@lingui/react": "^5.2.0",
|
"@lingui/react": "^5.2.0",
|
||||||
|
"jose": "^6.0.0",
|
||||||
"@noble/ciphers": "0.4.0",
|
"@noble/ciphers": "0.4.0",
|
||||||
"@noble/hashes": "1.3.2",
|
"@noble/hashes": "1.3.2",
|
||||||
"@node-rs/bcrypt": "^1.10.0",
|
"@node-rs/bcrypt": "^1.10.0",
|
||||||
@ -58,4 +59,4 @@
|
|||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/pg": "^8.11.4"
|
"@types/pg": "^8.11.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -24,8 +24,11 @@ export type CreateDocumentMetaOptions = {
|
|||||||
redirectUrl?: string;
|
redirectUrl?: string;
|
||||||
emailSettings?: TDocumentEmailSettings;
|
emailSettings?: TDocumentEmailSettings;
|
||||||
signingOrder?: DocumentSigningOrder;
|
signingOrder?: DocumentSigningOrder;
|
||||||
|
allowDictateNextSigner?: boolean;
|
||||||
distributionMethod?: DocumentDistributionMethod;
|
distributionMethod?: DocumentDistributionMethod;
|
||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
|
uploadSignatureEnabled?: boolean;
|
||||||
|
drawSignatureEnabled?: boolean;
|
||||||
language?: SupportedLanguageCodes;
|
language?: SupportedLanguageCodes;
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
};
|
};
|
||||||
@ -41,9 +44,12 @@ export const upsertDocumentMeta = async ({
|
|||||||
password,
|
password,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
|
allowDictateNextSigner,
|
||||||
emailSettings,
|
emailSettings,
|
||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
|
uploadSignatureEnabled,
|
||||||
|
drawSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: CreateDocumentMetaOptions) => {
|
}: CreateDocumentMetaOptions) => {
|
||||||
@ -93,9 +99,12 @@ export const upsertDocumentMeta = async ({
|
|||||||
documentId,
|
documentId,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
|
allowDictateNextSigner,
|
||||||
emailSettings,
|
emailSettings,
|
||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
|
uploadSignatureEnabled,
|
||||||
|
drawSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
@ -106,9 +115,12 @@ export const upsertDocumentMeta = async ({
|
|||||||
timezone,
|
timezone,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
|
allowDictateNextSigner,
|
||||||
emailSettings,
|
emailSettings,
|
||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
|
uploadSignatureEnabled,
|
||||||
|
drawSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,7 +7,10 @@ import {
|
|||||||
WebhookTriggerEvents,
|
WebhookTriggerEvents,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
import {
|
||||||
|
DOCUMENT_AUDIT_LOG_TYPE,
|
||||||
|
RECIPIENT_DIFF_TYPE,
|
||||||
|
} from '@documenso/lib/types/document-audit-logs';
|
||||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
@ -30,6 +33,10 @@ export type CompleteDocumentWithTokenOptions = {
|
|||||||
userId?: number;
|
userId?: number;
|
||||||
authOptions?: TRecipientActionAuth;
|
authOptions?: TRecipientActionAuth;
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
|
nextSigner?: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
|
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
|
||||||
@ -57,6 +64,7 @@ export const completeDocumentWithToken = async ({
|
|||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
|
nextSigner,
|
||||||
}: CompleteDocumentWithTokenOptions) => {
|
}: CompleteDocumentWithTokenOptions) => {
|
||||||
const document = await getDocument({ token, documentId });
|
const document = await getDocument({ token, documentId });
|
||||||
|
|
||||||
@ -146,7 +154,6 @@ export const completeDocumentWithToken = async ({
|
|||||||
recipientName: recipient.name,
|
recipientName: recipient.name,
|
||||||
recipientId: recipient.id,
|
recipientId: recipient.id,
|
||||||
recipientRole: recipient.role,
|
recipientRole: recipient.role,
|
||||||
// actionAuth: derivedRecipientActionAuth || undefined,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -164,6 +171,9 @@ export const completeDocumentWithToken = async ({
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
signingOrder: true,
|
signingOrder: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
@ -186,9 +196,49 @@ export const completeDocumentWithToken = async ({
|
|||||||
const [nextRecipient] = pendingRecipients;
|
const [nextRecipient] = pendingRecipients;
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
|
if (nextSigner && document.documentMeta?.allowDictateNextSigner) {
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||||
|
documentId: document.id,
|
||||||
|
user: {
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
},
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
recipientEmail: nextRecipient.email,
|
||||||
|
recipientName: nextRecipient.name,
|
||||||
|
recipientId: nextRecipient.id,
|
||||||
|
recipientRole: nextRecipient.role,
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: RECIPIENT_DIFF_TYPE.NAME,
|
||||||
|
from: nextRecipient.name,
|
||||||
|
to: nextSigner.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
||||||
|
from: nextRecipient.email,
|
||||||
|
to: nextSigner.email,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await tx.recipient.update({
|
await tx.recipient.update({
|
||||||
where: { id: nextRecipient.id },
|
where: { id: nextRecipient.id },
|
||||||
data: { sendStatus: SendStatus.SENT },
|
data: {
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
...(nextSigner && document.documentMeta?.allowDictateNextSigner
|
||||||
|
? {
|
||||||
|
name: nextSigner.name,
|
||||||
|
email: nextSigner.email,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await jobs.triggerJob({
|
await jobs.triggerJob({
|
||||||
|
|||||||
@ -158,6 +158,10 @@ export const createDocumentV2 = async ({
|
|||||||
language: meta?.language || team?.teamGlobalSettings?.documentLanguage,
|
language: meta?.language || team?.teamGlobalSettings?.documentLanguage,
|
||||||
typedSignatureEnabled:
|
typedSignatureEnabled:
|
||||||
meta?.typedSignatureEnabled ?? team?.teamGlobalSettings?.typedSignatureEnabled,
|
meta?.typedSignatureEnabled ?? team?.teamGlobalSettings?.typedSignatureEnabled,
|
||||||
|
uploadSignatureEnabled:
|
||||||
|
meta?.uploadSignatureEnabled ?? team?.teamGlobalSettings?.uploadSignatureEnabled,
|
||||||
|
drawSignatureEnabled:
|
||||||
|
meta?.drawSignatureEnabled ?? team?.teamGlobalSettings?.drawSignatureEnabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -128,8 +128,10 @@ export const createDocument = async ({
|
|||||||
documentMeta: {
|
documentMeta: {
|
||||||
create: {
|
create: {
|
||||||
language: team?.teamGlobalSettings?.documentLanguage,
|
language: team?.teamGlobalSettings?.documentLanguage,
|
||||||
typedSignatureEnabled: team?.teamGlobalSettings?.typedSignatureEnabled,
|
|
||||||
timezone: timezone,
|
timezone: timezone,
|
||||||
|
typedSignatureEnabled: team?.teamGlobalSettings?.typedSignatureEnabled ?? true,
|
||||||
|
uploadSignatureEnabled: team?.teamGlobalSettings?.uploadSignatureEnabled ?? true,
|
||||||
|
drawSignatureEnabled: team?.teamGlobalSettings?.drawSignatureEnabled ?? true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -0,0 +1,63 @@
|
|||||||
|
import { SignJWT } from 'jose';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import { env } from '../../utils/env';
|
||||||
|
import { getApiTokenByToken } from '../public-api/get-api-token-by-token';
|
||||||
|
|
||||||
|
export type CreateEmbeddingPresignTokenOptions = {
|
||||||
|
apiToken: string;
|
||||||
|
/**
|
||||||
|
* Number of hours until the token expires
|
||||||
|
* In development mode, can be set to 0 to create a token that expires immediately (for testing)
|
||||||
|
*/
|
||||||
|
expiresIn?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEmbeddingPresignToken = async ({
|
||||||
|
apiToken,
|
||||||
|
expiresIn,
|
||||||
|
}: CreateEmbeddingPresignTokenOptions) => {
|
||||||
|
try {
|
||||||
|
// Validate the API token
|
||||||
|
const validatedToken = await getApiTokenByToken({ token: apiToken });
|
||||||
|
|
||||||
|
const now = DateTime.now();
|
||||||
|
|
||||||
|
// In development mode, allow setting expiresIn to 0 for testing
|
||||||
|
// In production, enforce a minimum expiration time
|
||||||
|
const isDevelopment = env('NODE_ENV') !== 'production';
|
||||||
|
console.log('isDevelopment', isDevelopment);
|
||||||
|
const minExpirationMinutes = isDevelopment ? 0 : 5;
|
||||||
|
|
||||||
|
// Ensure expiresIn is at least the minimum allowed value
|
||||||
|
const effectiveExpiresIn =
|
||||||
|
expiresIn !== undefined && expiresIn >= minExpirationMinutes ? expiresIn : 60; // Default to 1 hour if not specified or below minimum
|
||||||
|
|
||||||
|
const expiresAt = now.plus({ minutes: effectiveExpiresIn });
|
||||||
|
|
||||||
|
const secret = new TextEncoder().encode(validatedToken.token);
|
||||||
|
|
||||||
|
const token = await new SignJWT({
|
||||||
|
aud: String(validatedToken.teamId ?? validatedToken.userId),
|
||||||
|
sub: String(validatedToken.id),
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt(now.toJSDate())
|
||||||
|
.setExpirationTime(expiresAt.toJSDate())
|
||||||
|
.sign(secret);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
expiresAt: expiresAt.toJSDate(),
|
||||||
|
expiresIn: Math.floor(expiresAt.diff(now).toMillis() / 1000),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||||
|
message: 'Failed to create presign token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
import type { JWTPayload } from 'jose';
|
||||||
|
import { decodeJwt, jwtVerify } from 'jose';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
|
||||||
|
export type VerifyEmbeddingPresignTokenOptions = {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyEmbeddingPresignToken = async ({
|
||||||
|
token,
|
||||||
|
}: VerifyEmbeddingPresignTokenOptions) => {
|
||||||
|
// First decode the JWT to get the claims without verification
|
||||||
|
let decodedToken: JWTPayload;
|
||||||
|
|
||||||
|
try {
|
||||||
|
decodedToken = decodeJwt<JWTPayload>(token);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error decoding JWT token:', error);
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'Invalid presign token format',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the required claims
|
||||||
|
if (!decodedToken.sub || typeof decodedToken.sub !== 'string') {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'Invalid presign token format: missing or invalid subject claim',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decodedToken.aud || typeof decodedToken.aud !== 'string') {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'Invalid presign token format: missing or invalid audience claim',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert string IDs to numbers
|
||||||
|
const tokenId = Number(decodedToken.sub);
|
||||||
|
const audienceId = Number(decodedToken.aud);
|
||||||
|
|
||||||
|
if (Number.isNaN(tokenId) || !Number.isInteger(tokenId)) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'Invalid token ID format in subject claim',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(audienceId) || !Number.isInteger(audienceId)) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'Invalid user ID format in audience claim',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the API token to use as the verification secret
|
||||||
|
const apiToken = await prisma.apiToken.findFirst({
|
||||||
|
where: {
|
||||||
|
id: tokenId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiToken) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'Invalid presign token: API token not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should never happen but we need to narrow types
|
||||||
|
if (!apiToken.userId) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'Invalid presign token: API token does not have a user attached',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = apiToken.userId;
|
||||||
|
|
||||||
|
if (audienceId !== apiToken.teamId && audienceId !== apiToken.userId) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'Invalid presign token: API token does not match audience',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now verify the token with the actual secret
|
||||||
|
const secret = new TextEncoder().encode(apiToken.token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jwtVerify(token, secret);
|
||||||
|
} catch (error) {
|
||||||
|
// Check if the token has expired
|
||||||
|
if (error instanceof Error && error.name === 'JWTExpired') {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'Presign token has expired',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle invalid signature
|
||||||
|
if (error instanceof Error && error.name === 'JWSInvalid') {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'Invalid presign token signature',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log and rethrow other errors
|
||||||
|
console.error('Error verifying JWT token:', error);
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'Failed to verify presign token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...apiToken,
|
||||||
|
userId,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -201,7 +201,7 @@ export const signFieldWithToken = async ({
|
|||||||
throw new Error('Signature field must have a signature');
|
throw new Error('Signature field must have a signature');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSignatureField && !documentMeta?.typedSignatureEnabled && typedSignature) {
|
if (isSignatureField && documentMeta?.typedSignatureEnabled === false && typedSignature) {
|
||||||
throw new Error('Typed signatures are not allowed. Please draw your signature');
|
throw new Error('Typed signatures are not allowed. Please draw your signature');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export const getNextPendingRecipient = async ({
|
||||||
|
documentId,
|
||||||
|
currentRecipientId,
|
||||||
|
}: {
|
||||||
|
documentId: number;
|
||||||
|
currentRecipientId: number;
|
||||||
|
}) => {
|
||||||
|
const recipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
documentId,
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
signingOrder: {
|
||||||
|
sort: 'asc',
|
||||||
|
nulls: 'last',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'asc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentIndex = recipients.findIndex((r) => r.id === currentRecipientId);
|
||||||
|
|
||||||
|
if (currentIndex === -1 || currentIndex === recipients.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...recipients[currentIndex + 1],
|
||||||
|
token: '',
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -5,6 +5,7 @@ import { z } from 'zod';
|
|||||||
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
|
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
|
||||||
import { getTeamRelatedPrices } from '@documenso/ee/server-only/stripe/get-team-related-prices';
|
import { getTeamRelatedPrices } from '@documenso/ee/server-only/stripe/get-team-related-prices';
|
||||||
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
|
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
|
||||||
|
import { isDocumentPlatform as isUserPlatformPlan } from '@documenso/ee/server-only/util/is-document-platform';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
||||||
@ -60,6 +61,11 @@ export const createTeam = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isPlatformPlan = await isUserPlatformPlan({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: null,
|
||||||
|
});
|
||||||
|
|
||||||
let isPaymentRequired = IS_BILLING_ENABLED();
|
let isPaymentRequired = IS_BILLING_ENABLED();
|
||||||
let customerId: string | null = null;
|
let customerId: string | null = null;
|
||||||
|
|
||||||
@ -68,7 +74,25 @@ export const createTeam = async ({
|
|||||||
prices.map((price) => price.id),
|
prices.map((price) => price.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
isPaymentRequired = !subscriptionsContainsActivePlan(user.subscriptions, teamRelatedPriceIds);
|
const hasTeamRelatedSubscription = subscriptionsContainsActivePlan(
|
||||||
|
user.subscriptions,
|
||||||
|
teamRelatedPriceIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPlatformPlan) {
|
||||||
|
// For platform users, check if they already have any teams
|
||||||
|
const existingTeams = await prisma.team.findMany({
|
||||||
|
where: {
|
||||||
|
ownerUserId: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Payment is required if they already have any team
|
||||||
|
isPaymentRequired = existingTeams.length > 0;
|
||||||
|
} else {
|
||||||
|
// For non-platform users, payment is required if they don't have a team-related subscription
|
||||||
|
isPaymentRequired = !hasTeamRelatedSubscription;
|
||||||
|
}
|
||||||
|
|
||||||
customerId = await createTeamCustomer({
|
customerId = await createTeamCustomer({
|
||||||
name: user.name ?? teamName,
|
name: user.name ?? teamName,
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
import type { DocumentVisibility } from '@prisma/client';
|
|
||||||
import { TeamMemberRole } from '@prisma/client';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { TeamGlobalSettingsSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamGlobalSettingsSchema';
|
|
||||||
|
|
||||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
|
||||||
|
|
||||||
export type UpdateTeamDocumentSettingsOptions = {
|
|
||||||
userId: number;
|
|
||||||
teamId: number;
|
|
||||||
|
|
||||||
settings: {
|
|
||||||
documentVisibility: DocumentVisibility;
|
|
||||||
documentLanguage: SupportedLanguageCodes;
|
|
||||||
includeSenderDetails: boolean;
|
|
||||||
typedSignatureEnabled: boolean;
|
|
||||||
includeSigningCertificate: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ZUpdateTeamDocumentSettingsResponseSchema = TeamGlobalSettingsSchema;
|
|
||||||
|
|
||||||
export type TUpdateTeamDocumentSettingsResponse = z.infer<
|
|
||||||
typeof ZUpdateTeamDocumentSettingsResponseSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const updateTeamDocumentSettings = async ({
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
settings,
|
|
||||||
}: UpdateTeamDocumentSettingsOptions): Promise<TUpdateTeamDocumentSettingsResponse> => {
|
|
||||||
const {
|
|
||||||
documentVisibility,
|
|
||||||
documentLanguage,
|
|
||||||
includeSenderDetails,
|
|
||||||
includeSigningCertificate,
|
|
||||||
typedSignatureEnabled,
|
|
||||||
} = settings;
|
|
||||||
|
|
||||||
const member = await prisma.teamMember.findFirst({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!member || member.role !== TeamMemberRole.ADMIN) {
|
|
||||||
throw new Error('You do not have permission to update this team.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await prisma.teamGlobalSettings.upsert({
|
|
||||||
where: {
|
|
||||||
teamId,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
teamId,
|
|
||||||
documentVisibility,
|
|
||||||
documentLanguage,
|
|
||||||
includeSenderDetails,
|
|
||||||
typedSignatureEnabled,
|
|
||||||
includeSigningCertificate,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
documentVisibility,
|
|
||||||
documentLanguage,
|
|
||||||
includeSenderDetails,
|
|
||||||
typedSignatureEnabled,
|
|
||||||
includeSigningCertificate,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -324,6 +324,9 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
language: metaLanguage,
|
language: metaLanguage,
|
||||||
signingOrder: metaSigningOrder,
|
signingOrder: metaSigningOrder,
|
||||||
distributionMethod: template.templateMeta?.distributionMethod,
|
distributionMethod: template.templateMeta?.distributionMethod,
|
||||||
|
typedSignatureEnabled: template.templateMeta?.typedSignatureEnabled,
|
||||||
|
uploadSignatureEnabled: template.templateMeta?.uploadSignatureEnabled,
|
||||||
|
drawSignatureEnabled: template.templateMeta?.drawSignatureEnabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -96,6 +96,9 @@ export const createDocumentFromTemplateLegacy = async ({
|
|||||||
signingOrder: template.templateMeta?.signingOrder ?? undefined,
|
signingOrder: template.templateMeta?.signingOrder ?? undefined,
|
||||||
language:
|
language:
|
||||||
template.templateMeta?.language || template.team?.teamGlobalSettings?.documentLanguage,
|
template.templateMeta?.language || template.team?.teamGlobalSettings?.documentLanguage,
|
||||||
|
typedSignatureEnabled: template.templateMeta?.typedSignatureEnabled,
|
||||||
|
uploadSignatureEnabled: template.templateMeta?.uploadSignatureEnabled,
|
||||||
|
drawSignatureEnabled: template.templateMeta?.drawSignatureEnabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -82,8 +82,11 @@ export type CreateDocumentFromTemplateOptions = {
|
|||||||
signingOrder?: DocumentSigningOrder;
|
signingOrder?: DocumentSigningOrder;
|
||||||
language?: SupportedLanguageCodes;
|
language?: SupportedLanguageCodes;
|
||||||
distributionMethod?: DocumentDistributionMethod;
|
distributionMethod?: DocumentDistributionMethod;
|
||||||
typedSignatureEnabled?: boolean;
|
allowDictateNextSigner?: boolean;
|
||||||
emailSettings?: TDocumentEmailSettings;
|
emailSettings?: TDocumentEmailSettings;
|
||||||
|
typedSignatureEnabled?: boolean;
|
||||||
|
uploadSignatureEnabled?: boolean;
|
||||||
|
drawSignatureEnabled?: boolean;
|
||||||
};
|
};
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
};
|
};
|
||||||
@ -404,6 +407,14 @@ export const createDocumentFromTemplate = async ({
|
|||||||
template.team?.teamGlobalSettings?.documentLanguage,
|
template.team?.teamGlobalSettings?.documentLanguage,
|
||||||
typedSignatureEnabled:
|
typedSignatureEnabled:
|
||||||
override?.typedSignatureEnabled ?? template.templateMeta?.typedSignatureEnabled,
|
override?.typedSignatureEnabled ?? template.templateMeta?.typedSignatureEnabled,
|
||||||
|
uploadSignatureEnabled:
|
||||||
|
override?.uploadSignatureEnabled ?? template.templateMeta?.uploadSignatureEnabled,
|
||||||
|
drawSignatureEnabled:
|
||||||
|
override?.drawSignatureEnabled ?? template.templateMeta?.drawSignatureEnabled,
|
||||||
|
allowDictateNextSigner:
|
||||||
|
override?.allowDictateNextSigner ??
|
||||||
|
template.templateMeta?.allowDictateNextSigner ??
|
||||||
|
false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
recipients: {
|
recipients: {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user