mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
20 Commits
v1.10.0-rc
...
feat/bin-t
| Author | SHA1 | Date | |
|---|---|---|---|
| c3d3b934c4 | |||
| 201dc0f3fa | |||
| dd2ef3a657 | |||
| 435b3ca4f8 | |||
| 278cd8a9de | |||
| f1526315f5 | |||
| 353a7e8e0d | |||
| 34b2504268 | |||
| 566abda36b | |||
| 9121a062b3 | |||
| e613e0e347 | |||
| 95aae52fa4 | |||
| 5958f38719 | |||
| 419bc02171 | |||
| 5e4956f3a2 | |||
| da71613c9f | |||
| 4d6efe091e | |||
| 7e6ac4db40 | |||
| c560b9e9e3 | |||
| 27cd8f9c25 |
@ -1,4 +1 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npm run commitlint -- $1
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
||||
|
||||
|
||||
@ -6,5 +6,6 @@
|
||||
"solid": "Solid Integration",
|
||||
"preact": "Preact 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.
|
||||
|
||||
## 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
|
||||
|
||||
- [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)
|
||||
- [Angular Integration](/developers/embedding/angular)
|
||||
- [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)
|
||||
|
||||
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 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 { addZeroMonth } from '../add-zero-month';
|
||||
|
||||
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.selectFrom('Document')
|
||||
@ -35,7 +37,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
|
||||
],
|
||||
};
|
||||
|
||||
return transformedData;
|
||||
return addZeroMonth(transformedData);
|
||||
};
|
||||
|
||||
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
||||
|
||||
@ -2,6 +2,8 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
import { addZeroMonth } from '../add-zero-month';
|
||||
|
||||
export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.selectFrom('Recipient')
|
||||
@ -34,7 +36,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
|
||||
],
|
||||
};
|
||||
|
||||
return transformedData;
|
||||
return addZeroMonth(transformedData);
|
||||
};
|
||||
|
||||
export type GetSignerConversionMonthlyResult = Awaited<
|
||||
|
||||
@ -2,6 +2,8 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
import { addZeroMonth } from '../add-zero-month';
|
||||
|
||||
export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count') => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.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>>;
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { addZeroMonth } from './add-zero-month';
|
||||
|
||||
type MetricKeys = {
|
||||
stars: number;
|
||||
forks: number;
|
||||
@ -37,31 +39,77 @@ export function transformData({
|
||||
data: DataEntry;
|
||||
metric: MetricKey;
|
||||
}): TransformData {
|
||||
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
|
||||
const [yearA, monthA] = dateA.split('-').map(Number);
|
||||
const [yearB, monthB] = dateB.split('-').map(Number);
|
||||
try {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
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]) => {
|
||||
const [year, month] = date.split('-');
|
||||
const dateTime = DateTime.fromObject({
|
||||
year: Number(year),
|
||||
month: Number(month),
|
||||
if (isNaN(yearA) || isNaN(monthA) || isNaN(yearB) || isNaN(monthB)) {
|
||||
console.warn(`Invalid date format: ${dateA} or ${dateB}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
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 {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
|
||||
data: sortedEntries.map(([_, stats]) => stats[metric]),
|
||||
},
|
||||
],
|
||||
};
|
||||
const labels = sortedEntries.map(([date]) => {
|
||||
try {
|
||||
const [year, month] = date.split('-');
|
||||
|
||||
if (!year || !month || isNaN(Number(year)) || isNaN(Number(month))) {
|
||||
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
|
||||
|
||||
@ -162,7 +162,16 @@ export const DocumentDeleteDialog = ({
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.exhaustive()}
|
||||
// DocumentStatus.REJECTED isnt working currently so this is a fallback to prevent 500 error.
|
||||
// The union should work but currently its not
|
||||
.otherwise(() => (
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||
this document will be permanently deleted.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
))}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert variant="warning" className="-mt-1">
|
||||
|
||||
119
apps/remix/app/components/dialogs/document-restore-dialog.tsx
Normal file
119
apps/remix/app/components/dialogs/document-restore-dialog.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type DocumentRestoreDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
onRestore?: () => Promise<void> | void;
|
||||
documentTitle: string;
|
||||
teamId?: number;
|
||||
canManageDocument: boolean;
|
||||
};
|
||||
|
||||
export const DocumentRestoreDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
onRestore,
|
||||
documentTitle,
|
||||
canManageDocument,
|
||||
}: DocumentRestoreDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { refreshLimits } = useLimits();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { mutateAsync: restoreDocument, isPending } =
|
||||
trpcReact.document.restoreDocument.useMutation({
|
||||
onSuccess: async () => {
|
||||
void refreshLimits();
|
||||
|
||||
toast({
|
||||
title: _(msg`Document restored`),
|
||||
description: _(msg`"${documentTitle}" has been successfully restored`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await onRestore?.();
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`This document could not be restored at this time. Please try again.`),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Restore Document</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{canManageDocument ? (
|
||||
<Trans>
|
||||
You are about to restore <strong>"{documentTitle}"</strong>
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
You are about to unhide <strong>"{documentTitle}"</strong>
|
||||
</Trans>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="neutral" className="-mt-1">
|
||||
<AlertDescription>
|
||||
{canManageDocument ? (
|
||||
<Trans>
|
||||
The document will be restored to your account and will be available in your
|
||||
documents list.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
The document will be unhidden from your account and will be available in your
|
||||
documents list.
|
||||
</Trans>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isPending}
|
||||
onClick={() => void restoreDocument({ documentId: id })}
|
||||
>
|
||||
<Trans>Restore</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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 = () => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -15,6 +15,7 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
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 { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
@ -36,6 +37,7 @@ import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-sc
|
||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
||||
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 { EmbedDocumentCompleted } from './embed-document-completed';
|
||||
import { EmbedDocumentFields } from './embed-document-fields';
|
||||
@ -47,6 +49,7 @@ export type EmbedSignDocumentClientPageProps = {
|
||||
documentData: DocumentData;
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
completedFields: DocumentField[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
isCompleted?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
@ -60,6 +63,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
documentData,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
metadata,
|
||||
isCompleted,
|
||||
hidePoweredBy = false,
|
||||
@ -85,6 +89,8 @@ export const EmbedSignDocumentClientPage = ({
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||
useState(false);
|
||||
|
||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||
|
||||
@ -202,6 +208,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
// a to be provided by the parent application, unlike direct templates.
|
||||
setIsNameLocked(!!data.lockName);
|
||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
|
||||
|
||||
if (data.darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
@ -468,6 +475,9 @@ export const EmbedSignDocumentClientPage = ({
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
||||
|
||||
{/* Completed fields */}
|
||||
<DocumentReadOnlyFields documentMeta={metadata || undefined} fields={completedFields} />
|
||||
</div>
|
||||
|
||||
{!hidePoweredBy && (
|
||||
|
||||
@ -113,7 +113,11 @@ export const DirectTemplatePageView = ({
|
||||
|
||||
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) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
|
||||
@ -157,7 +157,7 @@ export const DocumentSigningPageView = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentReadOnlyFields fields={completedFields} />
|
||||
<DocumentReadOnlyFields documentMeta={documentMeta || undefined} fields={completedFields} />
|
||||
|
||||
{recipient.role !== RecipientRole.ASSISTANT && (
|
||||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
||||
|
||||
@ -38,11 +38,6 @@ export const DocumentSigningRecipientProvider = ({
|
||||
recipient,
|
||||
targetSigner = null,
|
||||
}: DocumentSigningRecipientProviderProps) => {
|
||||
// console.log({
|
||||
// recipient,
|
||||
// targetSigner,
|
||||
// isAssistantMode: !!targetSigner,
|
||||
// });
|
||||
return (
|
||||
<DocumentSigningRecipientContext.Provider
|
||||
value={{
|
||||
|
||||
@ -170,6 +170,7 @@ export const DocumentHistorySheet = ({
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED },
|
||||
() => null,
|
||||
)
|
||||
.with(
|
||||
|
||||
@ -25,7 +25,7 @@ export const DocumentPageViewInformation = ({
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const documentInformation = useMemo(() => {
|
||||
return [
|
||||
const documentInfo = [
|
||||
{
|
||||
description: msg`Uploaded by`,
|
||||
value:
|
||||
@ -44,6 +44,19 @@ export const DocumentPageViewInformation = ({
|
||||
.toRelative(),
|
||||
},
|
||||
];
|
||||
|
||||
if (document.deletedAt) {
|
||||
documentInfo.push({
|
||||
description: msg`Deleted`,
|
||||
value:
|
||||
document.deletedAt &&
|
||||
DateTime.fromJSDate(document.deletedAt)
|
||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||
.toFormat('MMMM d, yyyy'),
|
||||
});
|
||||
}
|
||||
|
||||
return documentInfo;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMounted, document, userId]);
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
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 { Clock, EyeOffIcon } from 'lucide-react';
|
||||
import { P, match } from 'ts-pattern';
|
||||
@ -27,7 +27,7 @@ import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
|
||||
export type DocumentReadOnlyFieldsProps = {
|
||||
fields: DocumentField[];
|
||||
documentMeta?: DocumentMeta;
|
||||
documentMeta?: DocumentMeta | TemplateMeta;
|
||||
showFieldStatus?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import type { HTMLAttributes } from 'react';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { CheckCircle2, Clock, File, XCircle } from 'lucide-react';
|
||||
import { CheckCircle2, Clock, File, Trash, XCircle } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
@ -36,11 +36,11 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
|
||||
icon: File,
|
||||
color: 'text-yellow-500 dark:text-yellow-200',
|
||||
},
|
||||
REJECTED: {
|
||||
label: msg`Rejected`,
|
||||
labelExtended: msg`Document rejected`,
|
||||
icon: XCircle,
|
||||
color: 'text-red-500 dark:text-red-300',
|
||||
DELETED: {
|
||||
label: msg`Deleted`,
|
||||
labelExtended: msg`Document deleted`,
|
||||
icon: Trash,
|
||||
color: 'text-red-700 dark:text-red-500',
|
||||
},
|
||||
INBOX: {
|
||||
label: msg`Inbox`,
|
||||
@ -53,6 +53,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
|
||||
labelExtended: msg`Document All`,
|
||||
color: 'text-muted-foreground',
|
||||
},
|
||||
REJECTED: {
|
||||
label: msg`Rejected`,
|
||||
labelExtended: msg`Document rejected`,
|
||||
icon: XCircle,
|
||||
color: 'text-red-500 dark:text-red-300',
|
||||
},
|
||||
};
|
||||
|
||||
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
MoreHorizontal,
|
||||
MoveRight,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Share,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
@ -39,6 +40,7 @@ import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialo
|
||||
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
||||
import { DocumentMoveDialog } from '~/components/dialogs/document-move-dialog';
|
||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||
import { DocumentRestoreDialog } from '~/components/dialogs/document-restore-dialog';
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
@ -58,18 +60,20 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isRestoreDialogOpen, setRestoreDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
|
||||
|
||||
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
const isOwner = row.user.id === user.id;
|
||||
// const isRecipient = !!recipient;
|
||||
const isRecipient = !!recipient;
|
||||
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||
const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isComplete = isDocumentCompleted(row.status);
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
const isDocumentDeleted = row.deletedAt !== null;
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
@ -171,10 +175,17 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
||||
Void
|
||||
</DropdownMenuItem> */}
|
||||
|
||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
|
||||
</DropdownMenuItem>
|
||||
{isDocumentDeleted || (isRecipient && !canManageDocument) ? (
|
||||
<DropdownMenuItem disabled={isRecipient} onClick={() => setRestoreDialogOpen(true)}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
<Trans>Restore</Trans>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Share</Trans>
|
||||
@ -220,6 +231,15 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
||||
canManageDocument={canManageDocument}
|
||||
/>
|
||||
|
||||
<DocumentRestoreDialog
|
||||
id={row.id}
|
||||
open={isRestoreDialogOpen}
|
||||
onOpenChange={setRestoreDialogOpen}
|
||||
documentTitle={row.title}
|
||||
teamId={team?.id}
|
||||
canManageDocument={canManageDocument}
|
||||
/>
|
||||
|
||||
<DocumentMoveDialog
|
||||
documentId={row.id}
|
||||
open={isMoveDialogOpen}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Bird, CheckCircle2 } from 'lucide-react';
|
||||
import { Bird, CheckCircle2, Trash } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
@ -30,6 +30,11 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
|
||||
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
|
||||
icon: Bird,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.DELETED, () => ({
|
||||
title: msg`Nothing in the trash`,
|
||||
message: msg`There are no documents in the trash.`,
|
||||
icon: Trash,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
title: msg`Nothing to do`,
|
||||
message: msg`All documents have been processed. Any new documents that are sent or received will show here.`,
|
||||
|
||||
@ -9,7 +9,6 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
@ -76,13 +75,12 @@ export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTab
|
||||
},
|
||||
{
|
||||
header: _(msg`Actions`),
|
||||
cell: ({ row }) =>
|
||||
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DocumentsTableActionButton row={row.original} />
|
||||
<DocumentsTableActionDropdown row={row.original} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DocumentsTableActionButton row={row.original} />
|
||||
<DocumentsTableActionDropdown row={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<DocumentsTableRow>[];
|
||||
}, [team]);
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
@ -51,6 +50,7 @@ export default function DocumentsPage() {
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.DELETED]: 0,
|
||||
[ExtendedDocumentStatus.INBOX]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
});
|
||||
@ -114,13 +114,17 @@ export default function DocumentsPage() {
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
||||
<Tabs
|
||||
value={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
||||
className="overflow-x-auto"
|
||||
>
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.DELETED,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
].map((value) => (
|
||||
<TabsTrigger
|
||||
|
||||
@ -249,24 +249,24 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
{signature.secondaryId}
|
||||
</span>
|
||||
</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 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 truncate={false} className="w-[min-content] align-top">
|
||||
|
||||
@ -6,7 +6,7 @@ import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
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) {
|
||||
const { token } = params;
|
||||
@ -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 { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
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 { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||
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 [document, fields, recipient] = await Promise.all([
|
||||
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||
getDocumentAndSenderByToken({
|
||||
token,
|
||||
userId: user?.id,
|
||||
@ -41,6 +42,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
}).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
getCompletedFieldsForToken({ token }).catch(() => []),
|
||||
]);
|
||||
|
||||
// `document.directLink` is always available but we're doing this to
|
||||
@ -130,6 +132,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
allRecipients,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
hidePoweredBy,
|
||||
isPlatformDocument,
|
||||
isEnterpriseDocument,
|
||||
@ -145,6 +148,7 @@ export default function EmbedSignDocumentPage() {
|
||||
allRecipients,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
hidePoweredBy,
|
||||
isPlatformDocument,
|
||||
isEnterpriseDocument,
|
||||
@ -171,6 +175,7 @@ export default function EmbedSignDocumentPage() {
|
||||
documentData={document.documentData}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
completedFields={completedFields}
|
||||
metadata={document.documentMeta}
|
||||
isCompleted={isDocumentCompleted(document.status)}
|
||||
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),
|
||||
lockName: z.boolean().optional().default(false),
|
||||
allowDocumentRejection: z.boolean().optional(),
|
||||
showOtherRecipientsCompletedFields: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@ -100,5 +100,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.10.0-rc.2"
|
||||
"version": "1.10.0-rc.5"
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { lingui } from '@lingui/vite-plugin';
|
||||
import { reactRouter } from '@react-router/dev/vite';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
import serverAdapter from 'hono-react-router-adapter/vite';
|
||||
import path from 'node:path';
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import { defineConfig } from 'vite';
|
||||
import macrosPlugin from 'vite-plugin-babel-macros';
|
||||
@ -44,9 +45,15 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
https: 'node:https',
|
||||
'.prisma/client/default': '../../node_modules/.prisma/client/default.js',
|
||||
'.prisma/client/index-browser': '../../node_modules/.prisma/client/index-browser.js',
|
||||
canvas: './app/types/empty-module.ts',
|
||||
'.prisma/client/default': path.resolve(
|
||||
__dirname,
|
||||
'../../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'),
|
||||
},
|
||||
},
|
||||
/**
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.10.0-rc.2",
|
||||
"version": "1.10.0-rc.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.10.0-rc.2",
|
||||
"version": "1.10.0-rc.5",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@ -95,7 +95,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "1.10.0-rc.2",
|
||||
"version": "1.10.0-rc.5",
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
"@documenso/assets": "*",
|
||||
@ -41560,6 +41560,7 @@
|
||||
"@vvo/tzdb": "^6.117.0",
|
||||
"csv-parse": "^5.6.0",
|
||||
"inngest": "^3.19.13",
|
||||
"jose": "^6.0.0",
|
||||
"kysely": "0.26.3",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
@ -41582,6 +41583,15 @@
|
||||
"@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": {
|
||||
"name": "@documenso/prettier-config",
|
||||
"version": "0.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.10.0-rc.2",
|
||||
"version": "1.10.0-rc.5",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
@ -18,7 +18,7 @@
|
||||
"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: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: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",
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
|
||||
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
|
||||
"test:e2e": "NODE_OPTIONS=--experimental-require-module start-server-and-test \"npm run start -w @documenso/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": [],
|
||||
"author": "",
|
||||
|
||||
@ -6,7 +6,7 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
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
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"@lingui/core": "^5.2.0",
|
||||
"@lingui/macro": "^5.2.0",
|
||||
"@lingui/react": "^5.2.0",
|
||||
"jose": "^6.0.0",
|
||||
"@noble/ciphers": "0.4.0",
|
||||
"@noble/hashes": "1.3.2",
|
||||
"@node-rs/bcrypt": "^1.10.0",
|
||||
@ -58,4 +59,4 @@
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/pg": "^8.11.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ export const getDocumentStats = async () => {
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
[ExtendedDocumentStatus.DELETED]: 0,
|
||||
};
|
||||
|
||||
counts.forEach((stat) => {
|
||||
|
||||
@ -136,18 +136,26 @@ export const findDocuments = async ({
|
||||
};
|
||||
}
|
||||
|
||||
const deletedDateRange =
|
||||
status === ExtendedDocumentStatus.DELETED
|
||||
? {
|
||||
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
|
||||
lte: DateTime.now().toJSDate(),
|
||||
}
|
||||
: null;
|
||||
|
||||
let deletedFilter: Prisma.DocumentWhereInput = {
|
||||
AND: {
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
deletedAt: deletedDateRange,
|
||||
},
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
documentDeletedAt: null,
|
||||
documentDeletedAt: deletedDateRange,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -162,19 +170,19 @@ export const findDocuments = async ({
|
||||
? [
|
||||
{
|
||||
teamId: team.id,
|
||||
deletedAt: null,
|
||||
deletedAt: deletedDateRange,
|
||||
},
|
||||
{
|
||||
user: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
deletedAt: null,
|
||||
deletedAt: deletedDateRange,
|
||||
},
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: team.teamEmail.email,
|
||||
documentDeletedAt: null,
|
||||
documentDeletedAt: deletedDateRange,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -182,7 +190,7 @@ export const findDocuments = async ({
|
||||
: [
|
||||
{
|
||||
teamId: team.id,
|
||||
deletedAt: null,
|
||||
deletedAt: deletedDateRange,
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -297,6 +305,14 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.REJECTED,
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.INBOX, () => ({
|
||||
@ -368,7 +384,24 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.REJECTED,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.DELETED, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
deletedAt: {
|
||||
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -410,7 +443,7 @@ const findTeamDocumentsFilter = (
|
||||
status: ExtendedDocumentStatus,
|
||||
team: Team & { teamEmail: TeamEmail | null },
|
||||
visibilityFilters: Prisma.DocumentWhereInput[],
|
||||
) => {
|
||||
): Prisma.DocumentWhereInput | null => {
|
||||
const teamEmail = team.teamEmail?.email ?? null;
|
||||
|
||||
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput | null>(status)
|
||||
@ -599,5 +632,32 @@ const findTeamDocumentsFilter = (
|
||||
|
||||
return filter;
|
||||
})
|
||||
.with(ExtendedDocumentStatus.DELETED, () => {
|
||||
return {
|
||||
OR: teamEmail
|
||||
? [
|
||||
{
|
||||
teamId: team.id,
|
||||
},
|
||||
{
|
||||
user: {
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
teamId: team.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import type { Prisma, User } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -17,7 +15,7 @@ export type GetStatsInput = {
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export const getStats = async ({ user, period, search = '', ...options }: GetStatsInput) => {
|
||||
export const getStats = async ({ user, period, search, ...options }: GetStatsInput) => {
|
||||
let createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
|
||||
if (period) {
|
||||
@ -30,7 +28,7 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
|
||||
};
|
||||
}
|
||||
|
||||
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
|
||||
const [ownerCounts, notSignedCounts, hasSignedCounts, deletedCounts] = await (options.team
|
||||
? getTeamCounts({
|
||||
...options.team,
|
||||
createdAt,
|
||||
@ -45,6 +43,7 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.DELETED]: 0,
|
||||
[ExtendedDocumentStatus.INBOX]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
};
|
||||
@ -71,6 +70,8 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
|
||||
}
|
||||
});
|
||||
|
||||
stats[ExtendedDocumentStatus.DELETED] = deletedCounts || 0;
|
||||
|
||||
Object.keys(stats).forEach((key) => {
|
||||
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
|
||||
stats[ExtendedDocumentStatus.ALL] += stats[key];
|
||||
@ -167,6 +168,32 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
|
||||
AND: [searchFilter],
|
||||
},
|
||||
}),
|
||||
// Deleted count
|
||||
prisma.document.count({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
deletedAt: {
|
||||
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
documentDeletedAt: {
|
||||
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: [searchFilter],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
@ -336,5 +363,40 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
}),
|
||||
notSignedCountsGroupByArgs ? prisma.document.groupBy(notSignedCountsGroupByArgs) : [],
|
||||
hasSignedCountsGroupByArgs ? prisma.document.groupBy(hasSignedCountsGroupByArgs) : [],
|
||||
prisma.document.count({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
teamId,
|
||||
userId: userIdWhereClause,
|
||||
deletedAt: {
|
||||
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
user: {
|
||||
email: teamEmail,
|
||||
},
|
||||
deletedAt: {
|
||||
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
documentDeletedAt: {
|
||||
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: [searchFilter],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
108
packages/lib/server-only/document/restore-document.ts
Normal file
108
packages/lib/server-only/document/restore-document.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '@documenso/lib/types/webhook-payload';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type RestoreDocumentOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
export const restoreDocument = async ({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
requestMetadata,
|
||||
}: RestoreDocumentOptions) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
team: {
|
||||
include: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document || (teamId !== undefined && teamId !== document.teamId)) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const isUserOwner = document.userId === userId;
|
||||
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
|
||||
|
||||
if (!isUserOwner && !isUserTeamMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Not allowed to restore this document',
|
||||
});
|
||||
}
|
||||
|
||||
const restoredDocument = await prisma.$transaction(async (tx) => {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: document.id,
|
||||
type: 'DOCUMENT_RESTORED',
|
||||
metadata: requestMetadata,
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
|
||||
return await tx.document.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
data: {
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await prisma.recipient.updateMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
documentDeletedAt: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_RESTORED,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(document)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return restoredDocument;
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -5,6 +5,7 @@ import { z } from 'zod';
|
||||
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
|
||||
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 { isDocumentPlatform as isUserPlatformPlan } from '@documenso/ee/server-only/util/is-document-platform';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
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 customerId: string | null = null;
|
||||
|
||||
@ -68,7 +74,25 @@ export const createTeam = async ({
|
||||
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({
|
||||
name: user.name ?? teamName,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -21,6 +21,10 @@ msgstr " Enable direct link signing"
|
||||
msgid " The events that will trigger a webhook to be sent to your URL."
|
||||
msgstr " The events that will trigger a webhook to be sent to your URL."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid ".PDF documents accepted (max {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB)"
|
||||
msgstr ".PDF documents accepted (max {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB)"
|
||||
|
||||
#. placeholder {0}: team.name
|
||||
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
|
||||
msgid "\"{0}\" has invited you to sign \"example document\"."
|
||||
@ -48,6 +52,10 @@ msgstr "“{documentName}” was signed by all signers"
|
||||
msgid "\"{documentTitle}\" has been successfully deleted"
|
||||
msgstr "\"{documentTitle}\" has been successfully deleted"
|
||||
|
||||
#: apps/remix/app/components/dialogs/document-restore-dialog.tsx
|
||||
msgid "\"{documentTitle}\" has been successfully restored"
|
||||
msgstr "\"{documentTitle}\" has been successfully restored"
|
||||
|
||||
#. placeholder {0}: team.name
|
||||
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
|
||||
msgid "\"{placeholderEmail}\" on behalf of \"{0}\" has invited you to sign \"example document\"."
|
||||
@ -265,6 +273,10 @@ msgstr "{prefix} removed a recipient"
|
||||
msgid "{prefix} resent an email to {0}"
|
||||
msgstr "{prefix} resent an email to {0}"
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "{prefix} restored the document"
|
||||
msgstr "{prefix} restored the document"
|
||||
|
||||
#. placeholder {0}: data.recipientEmail
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "{prefix} sent an email to {0}"
|
||||
@ -722,6 +734,7 @@ msgstr "Add"
|
||||
msgid "Add a document"
|
||||
msgstr "Add a document"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.tsx
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
msgid "Add a URL to redirect the user to once the document is signed"
|
||||
@ -795,6 +808,7 @@ msgstr "Add Placeholder Recipient"
|
||||
msgid "Add Placeholders"
|
||||
msgstr "Add Placeholders"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
|
||||
#: packages/ui/primitives/document-flow/add-signers.tsx
|
||||
msgid "Add Signer"
|
||||
msgstr "Add Signer"
|
||||
@ -803,6 +817,10 @@ msgstr "Add Signer"
|
||||
msgid "Add Signers"
|
||||
msgstr "Add Signers"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
|
||||
msgid "Add signers and configure signing preferences"
|
||||
msgstr "Add signers and configure signing preferences"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-add-dialog.tsx
|
||||
msgid "Add team email"
|
||||
msgstr "Add team email"
|
||||
@ -848,11 +866,16 @@ msgstr "Admin panel"
|
||||
msgid "Advanced Options"
|
||||
msgstr "Advanced Options"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Advanced settings"
|
||||
msgstr "Advanced settings"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
msgid "Advanced Settings"
|
||||
msgstr "Advanced Settings"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
||||
msgid "After signing a document electronically, you will be provided the opportunity to view, download, and print the document for your records. It is highly recommended that you retain a copy of all electronically signed documents for your personal records. We will also retain a copy of the signed document for our records however we may not be able to provide you with a copy of the signed document after a certain period of time."
|
||||
msgstr "After signing a document electronically, you will be provided the opportunity to view, download, and print the document for your records. It is highly recommended that you retain a copy of all electronically signed documents for your personal records. We will also retain a copy of the signed document for our records however we may not be able to provide you with a copy of the signed document after a certain period of time."
|
||||
@ -905,11 +928,13 @@ msgstr "All Time"
|
||||
msgid "Allow document recipients to reply directly to this email address"
|
||||
msgstr "Allow document recipients to reply directly to this email address"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
|
||||
#: packages/ui/primitives/document-flow/add-signers.tsx
|
||||
msgid "Allow signers to dictate next signer"
|
||||
msgstr "Allow signers to dictate next signer"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.tsx
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
msgid "Allowed Signature Types"
|
||||
@ -1292,6 +1317,8 @@ msgstr "Awaiting email confirmation"
|
||||
|
||||
#: apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-fields-view.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-fields-view.tsx
|
||||
msgid "Back"
|
||||
msgstr "Back"
|
||||
|
||||
@ -1460,6 +1487,7 @@ msgstr "Can prepare"
|
||||
#: apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-restore-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-resend-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-move-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
|
||||
@ -1475,6 +1503,10 @@ msgstr "Cancel"
|
||||
msgid "Cancelled by user"
|
||||
msgstr "Cancelled by user"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Cannot remove document"
|
||||
msgstr "Cannot remove document"
|
||||
|
||||
#: packages/ui/primitives/document-flow/add-signers.tsx
|
||||
msgid "Cannot remove signer"
|
||||
msgstr "Cannot remove signer"
|
||||
@ -1528,6 +1560,10 @@ msgstr "Choose Direct Link Recipient"
|
||||
msgid "Choose how the document will reach recipients"
|
||||
msgstr "Choose how the document will reach recipients"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
msgid "Choose how to distribute your document to recipients. Email will send notifications, None will generate signing links for manual distribution."
|
||||
msgstr "Choose how to distribute your document to recipients. Email will send notifications, None will generate signing links for manual distribution."
|
||||
|
||||
#: apps/remix/app/components/forms/token.tsx
|
||||
msgid "Choose..."
|
||||
msgstr "Choose..."
|
||||
@ -1597,6 +1633,10 @@ msgstr "Click to insert field"
|
||||
msgid "Close"
|
||||
msgstr "Close"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
msgid "Communication"
|
||||
msgstr "Communication"
|
||||
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
|
||||
#: apps/remix/app/components/forms/signup.tsx
|
||||
@ -1653,10 +1693,29 @@ msgstr "Completed documents"
|
||||
msgid "Completed Documents"
|
||||
msgstr "Completed Documents"
|
||||
|
||||
#. placeholder {0}: parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[currentField.type])
|
||||
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
|
||||
msgid "Configure {0} Field"
|
||||
msgstr "Configure {0} Field"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
msgid "Configure additional options and preferences"
|
||||
msgstr "Configure additional options and preferences"
|
||||
|
||||
#: packages/lib/constants/template.ts
|
||||
msgid "Configure Direct Recipient"
|
||||
msgstr "Configure Direct Recipient"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||
msgid "Configure Document"
|
||||
msgstr "Configure Document"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-fields-view.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-fields-view.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-fields-view.tsx
|
||||
msgid "Configure Fields"
|
||||
msgstr "Configure Fields"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-edit-form.tsx
|
||||
msgid "Configure general settings for the document."
|
||||
msgstr "Configure general settings for the document."
|
||||
@ -1669,12 +1728,22 @@ msgstr "Configure general settings for the template."
|
||||
msgid "Configure template"
|
||||
msgstr "Configure template"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||
msgid "Configure Template"
|
||||
msgstr "Configure Template"
|
||||
|
||||
#. placeholder {0}: parseMessageDescriptor( _, FRIENDLY_FIELD_TYPE[currentField.type], )
|
||||
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Configure the {0} field"
|
||||
msgstr "Configure the {0} field"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-fields-view.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-fields-view.tsx
|
||||
msgid "Configure the fields you want to place on the document."
|
||||
msgstr "Configure the fields you want to place on the document."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
|
||||
msgid "Confirm"
|
||||
@ -1719,12 +1788,13 @@ msgid "Content"
|
||||
msgstr "Content"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.$token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.$token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.email.$token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.email.$token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.invite.$token.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
|
||||
@ -1988,6 +2058,7 @@ msgstr "Date"
|
||||
msgid "Date created"
|
||||
msgstr "Date created"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.tsx
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
msgid "Date Format"
|
||||
@ -2099,6 +2170,8 @@ msgstr "Delete your account and all its contents, including completed documents.
|
||||
|
||||
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
|
||||
#: apps/remix/app/components/general/document/document-status.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-information.tsx
|
||||
msgid "Deleted"
|
||||
msgstr "Deleted"
|
||||
|
||||
@ -2212,6 +2285,10 @@ msgstr "Display your name and email in documents"
|
||||
msgid "Distribute Document"
|
||||
msgstr "Distribute Document"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
msgid "Distribution Method"
|
||||
msgstr "Distribution Method"
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-delete-dialog.tsx
|
||||
msgid "Do you want to delete this template?"
|
||||
msgstr "Do you want to delete this template?"
|
||||
@ -2289,6 +2366,10 @@ msgstr "Document Completed!"
|
||||
msgid "Document created"
|
||||
msgstr "Document created"
|
||||
|
||||
#: apps/remix/app/routes/embed+/v1.authoring+/create-completed.tsx
|
||||
msgid "Document Created"
|
||||
msgstr "Document Created"
|
||||
|
||||
#. placeholder {0}: document.user.name
|
||||
#: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx
|
||||
msgid "Document created by <0>{0}</0>"
|
||||
@ -2308,6 +2389,7 @@ msgid "Document Creation"
|
||||
msgstr "Document Creation"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/documents.$id._index.tsx
|
||||
#: apps/remix/app/components/general/document/document-status.tsx
|
||||
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
@ -2353,6 +2435,10 @@ msgstr "Document ID"
|
||||
msgid "Document inbox"
|
||||
msgstr "Document inbox"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Document is already uploaded"
|
||||
msgstr "Document is already uploaded"
|
||||
|
||||
#: apps/remix/app/components/tables/templates-table.tsx
|
||||
msgid "Document Limit Exceeded!"
|
||||
msgstr "Document Limit Exceeded!"
|
||||
@ -2407,6 +2493,11 @@ msgstr "Document Rejected"
|
||||
msgid "Document resealed"
|
||||
msgstr "Document resealed"
|
||||
|
||||
#: apps/remix/app/components/dialogs/document-restore-dialog.tsx
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "Document restored"
|
||||
msgstr "Document restored"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-edit-form.tsx
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "Document sent"
|
||||
@ -2534,10 +2625,18 @@ msgstr "Drafted Documents"
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "Drag & drop your PDF here."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "Drag and drop or click to upload"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
msgid "Draw"
|
||||
msgstr "Draw"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drop your document here"
|
||||
msgstr "Drop your document here"
|
||||
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Dropdown"
|
||||
@ -2602,6 +2701,9 @@ msgstr "Electronic Signature Disclosure"
|
||||
#: apps/remix/app/components/forms/forgot-password.tsx
|
||||
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
|
||||
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
@ -2695,6 +2797,7 @@ msgstr "Enable custom branding for all documents in this team."
|
||||
msgid "Enable Direct Link Signing"
|
||||
msgstr "Enable Direct Link Signing"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
|
||||
#: packages/ui/primitives/document-flow/add-signers.tsx
|
||||
msgid "Enable signing order"
|
||||
@ -2788,6 +2891,10 @@ msgstr "Enter your text here"
|
||||
msgid "Error"
|
||||
msgstr "Error"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Error uploading file"
|
||||
msgstr "Error uploading file"
|
||||
|
||||
#: apps/remix/app/components/forms/team-document-preferences-form.tsx
|
||||
msgid "Everyone can access and view the document"
|
||||
msgstr "Everyone can access and view the document"
|
||||
@ -2883,6 +2990,7 @@ msgid "Fields"
|
||||
msgstr "Fields"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "File cannot be larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB"
|
||||
msgstr "File cannot be larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB"
|
||||
|
||||
@ -2936,6 +3044,7 @@ msgstr "Full Name"
|
||||
#: apps/remix/app/components/general/teams/team-settings-nav-desktop.tsx
|
||||
#: apps/remix/app/components/general/document/document-edit-form.tsx
|
||||
#: apps/remix/app/components/general/direct-template/direct-template-page.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
msgid "General"
|
||||
msgstr "General"
|
||||
|
||||
@ -3137,7 +3246,7 @@ msgstr "Invalid code. Please try again."
|
||||
msgid "Invalid email"
|
||||
msgstr "Invalid email"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.$token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.email.$token.tsx
|
||||
msgid "Invalid link"
|
||||
msgstr "Invalid link"
|
||||
@ -3234,6 +3343,7 @@ msgid "Label"
|
||||
msgstr "Label"
|
||||
|
||||
#: apps/remix/app/components/general/menu-switcher.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.tsx
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
msgid "Language"
|
||||
@ -3466,6 +3576,7 @@ msgstr "Member Since"
|
||||
msgid "Members"
|
||||
msgstr "Members"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.tsx
|
||||
#: packages/ui/primitives/document-flow/add-subject.tsx
|
||||
msgid "Message <0>(Optional)</0>"
|
||||
@ -3529,6 +3640,8 @@ msgstr "My templates"
|
||||
#: apps/remix/app/components/general/claim-account.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-name-field.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
|
||||
@ -3617,8 +3730,8 @@ msgstr "No recent activity"
|
||||
msgid "No recent documents"
|
||||
msgstr "No recent documents"
|
||||
|
||||
#: packages/ui/primitives/recipient-selector.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "No recipient matching this description was found."
|
||||
msgstr "No recipient matching this description was found."
|
||||
|
||||
@ -3629,8 +3742,8 @@ msgstr "No recipient matching this description was found."
|
||||
msgid "No recipients"
|
||||
msgstr "No recipients"
|
||||
|
||||
#: packages/ui/primitives/recipient-selector.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "No recipients with this role"
|
||||
msgstr "No recipients with this role"
|
||||
|
||||
@ -3672,6 +3785,7 @@ msgstr "No value found."
|
||||
msgid "No worries, it happens! Enter your email and we'll email you a special link to reset your password."
|
||||
msgstr "No worries, it happens! Enter your email and we'll email you a special link to reset your password."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
#: packages/lib/constants/document.ts
|
||||
msgid "None"
|
||||
msgstr "None"
|
||||
@ -3680,6 +3794,10 @@ msgstr "None"
|
||||
msgid "Not supported"
|
||||
msgstr "Not supported"
|
||||
|
||||
#: apps/remix/app/components/tables/documents-table-empty-state.tsx
|
||||
msgid "Nothing in the trash"
|
||||
msgstr "Nothing in the trash"
|
||||
|
||||
#: apps/remix/app/components/tables/documents-table-empty-state.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-empty-state.tsx
|
||||
msgid "Nothing to do"
|
||||
@ -4018,6 +4136,7 @@ msgstr "Please note that proceeding will remove direct linking recipient and tur
|
||||
msgid "Please note that this action is <0>irreversible</0>."
|
||||
msgstr "Please note that this action is <0>irreversible</0>."
|
||||
|
||||
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
|
||||
msgid "Please note that this action is <0>irreversible</0>. Once confirmed, this document will be permanently deleted."
|
||||
msgstr "Please note that this action is <0>irreversible</0>. Once confirmed, this document will be permanently deleted."
|
||||
@ -4256,6 +4375,7 @@ msgstr "Recipient updated"
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
|
||||
#: apps/remix/app/components/general/template/template-page-view-recipients.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
|
||||
msgid "Recipients"
|
||||
msgstr "Recipients"
|
||||
|
||||
@ -4279,6 +4399,7 @@ msgstr "Recovery codes"
|
||||
msgid "Red"
|
||||
msgstr "Red"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.tsx
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
msgid "Redirect URL"
|
||||
@ -4429,6 +4550,15 @@ msgstr "Resolve payment"
|
||||
msgid "Rest assured, your document is strictly confidential and will never be shared. Only your signing experience will be highlighted. Share your personalized signing card to showcase your signature!"
|
||||
msgstr "Rest assured, your document is strictly confidential and will never be shared. Only your signing experience will be highlighted. Share your personalized signing card to showcase your signature!"
|
||||
|
||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||
#: apps/remix/app/components/dialogs/document-restore-dialog.tsx
|
||||
msgid "Restore"
|
||||
msgstr "Restore"
|
||||
|
||||
#: apps/remix/app/components/dialogs/document-restore-dialog.tsx
|
||||
msgid "Restore Document"
|
||||
msgstr "Restore Document"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
|
||||
msgid "Retention of Documents"
|
||||
msgstr "Retention of Documents"
|
||||
@ -4437,7 +4567,7 @@ msgstr "Retention of Documents"
|
||||
msgid "Retry"
|
||||
msgstr "Retry"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.$token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.email.$token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.invite.$token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.decline.$token.tsx
|
||||
@ -4468,6 +4598,7 @@ msgstr "Revoke access"
|
||||
#: apps/remix/app/components/tables/user-settings-current-teams-table.tsx
|
||||
#: apps/remix/app/components/tables/team-settings-members-table.tsx
|
||||
#: apps/remix/app/components/tables/team-settings-member-invites-table.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-member-invite-dialog.tsx
|
||||
@ -4485,6 +4616,8 @@ msgstr "Rows per page"
|
||||
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-number-field.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-fields-view.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-fields-view.tsx
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item-advanced-settings.tsx
|
||||
msgid "Save"
|
||||
@ -4660,6 +4793,14 @@ msgstr "Sent"
|
||||
msgid "Set a password"
|
||||
msgstr "Set a password"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||
msgid "Set up your document properties and recipient information"
|
||||
msgstr "Set up your document properties and recipient information"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||
msgid "Set up your template properties and recipient information"
|
||||
msgstr "Set up your template properties and recipient information"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/_layout.tsx
|
||||
#: apps/remix/app/components/general/app-nav-mobile.tsx
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
@ -4959,6 +5100,7 @@ msgstr "Some signers have not been assigned a signature field. Please assign at
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-restore-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-resend-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
|
||||
@ -4967,7 +5109,7 @@ msgid "Something went wrong"
|
||||
msgstr "Something went wrong"
|
||||
|
||||
#. placeholder {0}: data.teamName
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.$token.tsx
|
||||
msgid "Something went wrong while attempting to transfer the ownership of team <0>{0}</0> to your. Please try again later or contact support."
|
||||
msgstr "Something went wrong while attempting to transfer the ownership of team <0>{0}</0> to your. Please try again later or contact support."
|
||||
|
||||
@ -5039,6 +5181,7 @@ msgstr "Status"
|
||||
msgid "Step <0>{step} of {maxStep}</0>"
|
||||
msgstr "Step <0>{step} of {maxStep}</0>"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.tsx
|
||||
#: packages/ui/primitives/document-flow/add-subject.tsx
|
||||
msgid "Subject <0>(Optional)</0>"
|
||||
@ -5196,15 +5339,15 @@ msgstr "Team Only"
|
||||
msgid "Team only templates are not linked anywhere and are visible only to your team."
|
||||
msgstr "Team only templates are not linked anywhere and are visible only to your team."
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.$token.tsx
|
||||
msgid "Team ownership transfer"
|
||||
msgstr "Team ownership transfer"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.$token.tsx
|
||||
msgid "Team ownership transfer already completed!"
|
||||
msgstr "Team ownership transfer already completed!"
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.$token.tsx
|
||||
msgid "Team ownership transferred!"
|
||||
msgstr "Team ownership transferred!"
|
||||
|
||||
@ -5263,6 +5406,10 @@ msgstr "Teams restricted"
|
||||
msgid "Template"
|
||||
msgstr "Template"
|
||||
|
||||
#: apps/remix/app/routes/embed+/v1.authoring+/create-completed.tsx
|
||||
msgid "Template Created"
|
||||
msgstr "Template Created"
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-delete-dialog.tsx
|
||||
msgid "Template deleted"
|
||||
msgstr "Template deleted"
|
||||
@ -5378,6 +5525,10 @@ msgstr "The direct link has been copied to your clipboard"
|
||||
msgid "The document has been successfully moved to the selected team."
|
||||
msgstr "The document has been successfully moved to the selected team."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "The document is already saved and cannot be changed."
|
||||
msgstr "The document is already saved and cannot be changed."
|
||||
|
||||
#: apps/remix/app/components/embed/embed-document-completed.tsx
|
||||
msgid "The document is now completed, please follow any instructions provided within the parent application."
|
||||
msgstr "The document is now completed, please follow any instructions provided within the parent application."
|
||||
@ -5403,6 +5554,14 @@ msgstr "The document will be hidden from your account"
|
||||
msgid "The document will be immediately sent to recipients if this is checked."
|
||||
msgstr "The document will be immediately sent to recipients if this is checked."
|
||||
|
||||
#: apps/remix/app/components/dialogs/document-restore-dialog.tsx
|
||||
msgid "The document will be restored to your account and will be available in your documents list."
|
||||
msgstr "The document will be restored to your account and will be available in your documents list."
|
||||
|
||||
#: apps/remix/app/components/dialogs/document-restore-dialog.tsx
|
||||
msgid "The document will be unhidden from your account and will be available in your documents list."
|
||||
msgstr "The document will be unhidden from your account and will be available in your documents list."
|
||||
|
||||
#: packages/ui/components/document/document-send-email-message-helper.tsx
|
||||
msgid "The document's name"
|
||||
msgstr "The document's name"
|
||||
@ -5429,7 +5588,7 @@ msgid "The following team has been deleted by you"
|
||||
msgstr "The following team has been deleted by you"
|
||||
|
||||
#. placeholder {0}: data.teamName
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.$token.tsx
|
||||
msgid "The ownership of team <0>{0}</0> has been successfully transferred to you."
|
||||
msgstr "The ownership of team <0>{0}</0> has been successfully transferred to you."
|
||||
|
||||
@ -5588,6 +5747,14 @@ msgstr "There are no active drafts at the current moment. You can upload a docum
|
||||
msgid "There are no completed documents yet. Documents that you have created or received will appear here once completed."
|
||||
msgstr "There are no completed documents yet. Documents that you have created or received will appear here once completed."
|
||||
|
||||
#: apps/remix/app/components/tables/documents-table-empty-state.tsx
|
||||
msgid "There are no documents in the trash."
|
||||
msgstr "There are no documents in the trash."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "There was an error uploading your file. Please try again."
|
||||
msgstr "There was an error uploading your file. Please try again."
|
||||
|
||||
#: apps/remix/app/components/general/teams/team-email-usage.tsx
|
||||
msgid "They have permission on your behalf to:"
|
||||
msgstr "They have permission on your behalf to:"
|
||||
@ -5618,6 +5785,10 @@ msgstr "This can be overriden by setting the authentication requirements directl
|
||||
msgid "This document can not be recovered, if you would like to dispute the reason for future documents please contact support."
|
||||
msgstr "This document can not be recovered, if you would like to dispute the reason for future documents please contact support."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "This document cannot be changed"
|
||||
msgstr "This document cannot be changed"
|
||||
|
||||
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
|
||||
msgid "This document could not be deleted at this time. Please try again."
|
||||
msgstr "This document could not be deleted at this time. Please try again."
|
||||
@ -5630,7 +5801,11 @@ msgstr "This document could not be duplicated at this time. Please try again."
|
||||
msgid "This document could not be re-sent at this time. Please try again."
|
||||
msgstr "This document could not be re-sent at this time. Please try again."
|
||||
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
#: apps/remix/app/components/dialogs/document-restore-dialog.tsx
|
||||
msgid "This document could not be restored at this time. Please try again."
|
||||
msgstr "This document could not be restored at this time. Please try again."
|
||||
|
||||
#: packages/ui/primitives/recipient-selector.tsx
|
||||
msgid "This document has already been sent to this recipient. You can no longer edit this recipient."
|
||||
msgstr "This document has already been sent to this recipient. You can no longer edit this recipient."
|
||||
|
||||
@ -5698,7 +5873,7 @@ msgstr "This field cannot be modified or deleted. When you share this template's
|
||||
msgid "This is how the document will reach the recipients once the document is ready for signing."
|
||||
msgstr "This is how the document will reach the recipients once the document is ready for signing."
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.$token.tsx
|
||||
msgid "This link is invalid or has expired. Please contact your team to resend a transfer request."
|
||||
msgstr "This link is invalid or has expired. Please contact your team to resend a transfer request."
|
||||
|
||||
@ -5781,6 +5956,7 @@ msgid "Time zone"
|
||||
msgstr "Time zone"
|
||||
|
||||
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.tsx
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
msgid "Time Zone"
|
||||
@ -5790,6 +5966,7 @@ msgstr "Time Zone"
|
||||
#: apps/remix/app/components/tables/templates-table.tsx
|
||||
#: apps/remix/app/components/tables/documents-table.tsx
|
||||
#: apps/remix/app/components/general/template/template-page-view-documents-table.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
msgid "Title"
|
||||
msgstr "Title"
|
||||
@ -6187,6 +6364,10 @@ msgstr "Upload CSV"
|
||||
msgid "Upload custom document"
|
||||
msgstr "Upload custom document"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Upload Document"
|
||||
msgstr "Upload Document"
|
||||
|
||||
#: packages/ui/primitives/signature-pad/signature-pad-upload.tsx
|
||||
msgid "Upload Signature"
|
||||
msgstr "Upload Signature"
|
||||
@ -6728,6 +6909,7 @@ msgstr "Welcome to Documenso!"
|
||||
msgid "Were you trying to edit this document instead?"
|
||||
msgstr "Were you trying to edit this document instead?"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
|
||||
#: packages/ui/primitives/document-flow/add-signers.tsx
|
||||
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
|
||||
@ -6796,6 +6978,10 @@ msgstr "You are about to leave the following team."
|
||||
msgid "You are about to remove the following user from <0>{teamName}</0>."
|
||||
msgstr "You are about to remove the following user from <0>{teamName}</0>."
|
||||
|
||||
#: apps/remix/app/components/dialogs/document-restore-dialog.tsx
|
||||
msgid "You are about to restore <0>\"{documentTitle}\"</0>"
|
||||
msgstr "You are about to restore <0>\"{documentTitle}\"</0>"
|
||||
|
||||
#. placeholder {0}: teamEmail.team.name
|
||||
#. placeholder {1}: teamEmail.team.url
|
||||
#: apps/remix/app/components/general/teams/team-email-usage.tsx
|
||||
@ -6806,6 +6992,10 @@ msgstr "You are about to revoke access for team <0>{0}</0> ({1}) to use your ema
|
||||
msgid "You are about to send this document to the recipients. Are you sure you want to continue?"
|
||||
msgstr "You are about to send this document to the recipients. Are you sure you want to continue?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/document-restore-dialog.tsx
|
||||
msgid "You are about to unhide <0>\"{documentTitle}\"</0>"
|
||||
msgstr "You are about to unhide <0>\"{documentTitle}\"</0>"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/billing.tsx
|
||||
msgid "You are currently on the <0>Free Plan</0>."
|
||||
msgstr "You are currently on the <0>Free Plan</0>."
|
||||
@ -6913,7 +7103,7 @@ msgid "You have accepted an invitation from <0>{0}</0> to join their team."
|
||||
msgstr "You have accepted an invitation from <0>{0}</0> to join their team."
|
||||
|
||||
#. placeholder {0}: data.teamName
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.token.tsx
|
||||
#: apps/remix/app/routes/_unauthenticated+/team.verify.transfer.$token.tsx
|
||||
msgid "You have already completed the ownership transfer for <0>{0}</0>."
|
||||
msgstr "You have already completed the ownership transfer for <0>{0}</0>."
|
||||
|
||||
@ -7115,6 +7305,7 @@ msgid "Your direct signing templates"
|
||||
msgstr "Your direct signing templates"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload.tsx
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Your document failed to upload."
|
||||
msgstr "Your document failed to upload."
|
||||
|
||||
@ -7122,6 +7313,10 @@ msgstr "Your document failed to upload."
|
||||
msgid "Your document has been created from the template successfully."
|
||||
msgstr "Your document has been created from the template successfully."
|
||||
|
||||
#: apps/remix/app/routes/embed+/v1.authoring+/create-completed.tsx
|
||||
msgid "Your document has been created successfully"
|
||||
msgstr "Your document has been created successfully"
|
||||
|
||||
#: packages/email/template-components/template-document-super-delete.tsx
|
||||
msgid "Your document has been deleted by an admin!"
|
||||
msgstr "Your document has been deleted by an admin!"
|
||||
@ -7232,6 +7427,10 @@ msgstr "Your team has been successfully deleted."
|
||||
msgid "Your team has been successfully updated."
|
||||
msgstr "Your team has been successfully updated."
|
||||
|
||||
#: apps/remix/app/routes/embed+/v1.authoring+/create-completed.tsx
|
||||
msgid "Your template has been created successfully"
|
||||
msgstr "Your template has been created successfully"
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
|
||||
msgid "Your template has been duplicated successfully."
|
||||
msgstr "Your template has been duplicated successfully."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
7264
packages/lib/translations/ko/web.po
Normal file
7264
packages/lib/translations/ko/web.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
7264
packages/lib/translations/sq/web.po
Normal file
7264
packages/lib/translations/sq/web.po
Normal file
File diff suppressed because it is too large
Load Diff
@ -39,6 +39,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
||||
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
|
||||
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
|
||||
'DOCUMENT_RESTORED', // When a deleted document is restored.
|
||||
]);
|
||||
|
||||
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
||||
@ -551,6 +552,14 @@ export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document restored.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRestoredSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED),
|
||||
data: z.object({}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
@ -588,6 +597,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventRecipientAddedSchema,
|
||||
ZDocumentAuditLogEventRecipientUpdatedSchema,
|
||||
ZDocumentAuditLogEventRecipientRemovedSchema,
|
||||
ZDocumentAuditLogEventDocumentRestoredSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@ -388,6 +388,10 @@ export const formatDocumentAuditLogAction = (
|
||||
anonymous: msg`Document completed`,
|
||||
identified: msg`Document completed`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED }, () => ({
|
||||
anonymous: msg`Document restored`,
|
||||
identified: msg`${prefix} restored the document`,
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
return {
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_RESTORED';
|
||||
@ -178,6 +178,7 @@ enum WebhookTriggerEvents {
|
||||
DOCUMENT_COMPLETED
|
||||
DOCUMENT_REJECTED
|
||||
DOCUMENT_CANCELLED
|
||||
DOCUMENT_RESTORED
|
||||
}
|
||||
|
||||
model Webhook {
|
||||
|
||||
@ -4,6 +4,7 @@ export const ExtendedDocumentStatus = {
|
||||
...DocumentStatus,
|
||||
INBOX: 'INBOX',
|
||||
ALL: 'ALL',
|
||||
DELETED: 'DELETED',
|
||||
} as const;
|
||||
|
||||
export type ExtendedDocumentStatus =
|
||||
|
||||
@ -21,9 +21,9 @@ import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stat
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { moveDocumentToTeam } from '@documenso/lib/server-only/document/move-document-to-team';
|
||||
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
||||
import { restoreDocument } from '@documenso/lib/server-only/document/restore-document';
|
||||
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
@ -53,15 +53,12 @@ import {
|
||||
ZMoveDocumentToTeamResponseSchema,
|
||||
ZMoveDocumentToTeamSchema,
|
||||
ZResendDocumentMutationSchema,
|
||||
ZRestoreDocumentMutationSchema,
|
||||
ZSearchDocumentsMutationSchema,
|
||||
ZSetSigningOrderForDocumentMutationSchema,
|
||||
ZSuccessResponseSchema,
|
||||
} from './schema';
|
||||
import { updateDocumentRoute } from './update-document';
|
||||
import {
|
||||
ZUpdateDocumentRequestSchema,
|
||||
ZUpdateDocumentResponseSchema,
|
||||
} from './update-document.types';
|
||||
|
||||
export const documentRouter = router({
|
||||
/**
|
||||
@ -340,49 +337,6 @@ export const documentRouter = router({
|
||||
|
||||
updateDocument: updateDocumentRoute,
|
||||
|
||||
/**
|
||||
* @deprecated Delete this after updateDocument endpoint is deployed
|
||||
*/
|
||||
setSettingsForDocument: authenticatedProcedure
|
||||
.input(ZUpdateDocumentRequestSchema)
|
||||
.output(ZUpdateDocumentResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const { documentId, data, meta = {} } = input;
|
||||
|
||||
const userId = ctx.user.id;
|
||||
|
||||
if (Object.values(meta).length > 0) {
|
||||
await upsertDocumentMeta({
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
documentId,
|
||||
subject: meta.subject,
|
||||
message: meta.message,
|
||||
timezone: meta.timezone,
|
||||
dateFormat: meta.dateFormat,
|
||||
language: meta.language,
|
||||
typedSignatureEnabled: meta.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: meta.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: meta.drawSignatureEnabled,
|
||||
redirectUrl: meta.redirectUrl,
|
||||
distributionMethod: meta.distributionMethod,
|
||||
signingOrder: meta.signingOrder,
|
||||
allowDictateNextSigner: meta.allowDictateNextSigner,
|
||||
emailSettings: meta.emailSettings,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
return await updateDocument({
|
||||
userId,
|
||||
teamId,
|
||||
documentId,
|
||||
data,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@ -413,6 +367,36 @@ export const documentRouter = router({
|
||||
return ZGenericSuccessResponse;
|
||||
}),
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
restoreDocument: authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/document/restore',
|
||||
summary: 'Restore deleted document',
|
||||
tags: ['Document'],
|
||||
},
|
||||
})
|
||||
.input(ZRestoreDocumentMutationSchema)
|
||||
.output(ZSuccessResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const { documentId } = input;
|
||||
|
||||
const userId = ctx.user.id;
|
||||
|
||||
await restoreDocument({
|
||||
id: documentId,
|
||||
userId,
|
||||
teamId,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
}),
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
|
||||
@ -153,6 +153,7 @@ export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({
|
||||
[ExtendedDocumentStatus.PENDING]: z.number(),
|
||||
[ExtendedDocumentStatus.COMPLETED]: z.number(),
|
||||
[ExtendedDocumentStatus.REJECTED]: z.number(),
|
||||
[ExtendedDocumentStatus.DELETED]: z.number(),
|
||||
[ExtendedDocumentStatus.INBOX]: z.number(),
|
||||
[ExtendedDocumentStatus.ALL]: z.number(),
|
||||
}),
|
||||
@ -329,6 +330,12 @@ export const ZDeleteDocumentMutationSchema = z.object({
|
||||
|
||||
export type TDeleteDocumentMutationSchema = z.infer<typeof ZDeleteDocumentMutationSchema>;
|
||||
|
||||
export const ZRestoreDocumentMutationSchema = z.object({
|
||||
documentId: z.number(),
|
||||
});
|
||||
|
||||
export type TRestoreDocumentMutationSchema = z.infer<typeof ZRestoreDocumentMutationSchema>;
|
||||
|
||||
export const ZSearchDocumentsMutationSchema = z.object({
|
||||
query: z.string(),
|
||||
});
|
||||
|
||||
14
packages/trpc/server/embedding-router/_router.ts
Normal file
14
packages/trpc/server/embedding-router/_router.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { router } from '../trpc';
|
||||
import { createEmbeddingDocumentRoute } from './create-embedding-document';
|
||||
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
|
||||
import { createEmbeddingTemplateRoute } from './create-embedding-template';
|
||||
import { getEmbeddingDocumentRoute } from './get-embedding-document';
|
||||
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
|
||||
|
||||
export const embeddingPresignRouter = router({
|
||||
createEmbeddingPresignToken: createEmbeddingPresignTokenRoute,
|
||||
verifyEmbeddingPresignToken: verifyEmbeddingPresignTokenRoute,
|
||||
createEmbeddingDocument: createEmbeddingDocumentRoute,
|
||||
createEmbeddingTemplate: createEmbeddingTemplateRoute,
|
||||
getEmbeddingDocument: getEmbeddingDocumentRoute,
|
||||
});
|
||||
@ -0,0 +1,63 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createDocumentV2 } from '@documenso/lib/server-only/document/create-document-v2';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZCreateEmbeddingDocumentRequestSchema,
|
||||
ZCreateEmbeddingDocumentResponseSchema,
|
||||
} from './create-embedding-document.types';
|
||||
|
||||
export const createEmbeddingDocumentRoute = procedure
|
||||
.input(ZCreateEmbeddingDocumentRequestSchema)
|
||||
.output(ZCreateEmbeddingDocumentResponseSchema)
|
||||
.mutation(async ({ input, ctx: { req, metadata } }) => {
|
||||
try {
|
||||
const authorizationHeader = req.headers.get('authorization');
|
||||
|
||||
const [presignToken] = (authorizationHeader || '')
|
||||
.split('Bearer ')
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
if (!presignToken) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'No presign token provided',
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
|
||||
|
||||
const { title, documentDataId, externalId, recipients, meta } = input;
|
||||
|
||||
const document = await createDocumentV2({
|
||||
data: {
|
||||
title,
|
||||
externalId,
|
||||
recipients,
|
||||
},
|
||||
meta,
|
||||
documentDataId,
|
||||
userId: apiToken.userId,
|
||||
teamId: apiToken.teamId ?? undefined,
|
||||
requestMetadata: metadata,
|
||||
});
|
||||
|
||||
if (!document.id) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to create document: missing document ID',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
documentId: document.id,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to create document',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,83 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import {
|
||||
ZFieldHeightSchema,
|
||||
ZFieldPageNumberSchema,
|
||||
ZFieldPageXSchema,
|
||||
ZFieldPageYSchema,
|
||||
ZFieldWidthSchema,
|
||||
} from '@documenso/lib/types/field';
|
||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { DocumentSigningOrder } from '@documenso/prisma/generated/types';
|
||||
|
||||
import {
|
||||
ZDocumentExternalIdSchema,
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
ZDocumentMetaTypedSignatureEnabledSchema,
|
||||
ZDocumentMetaUploadSignatureEnabledSchema,
|
||||
ZDocumentTitleSchema,
|
||||
} from '../document-router/schema';
|
||||
import { ZCreateRecipientSchema } from '../recipient-router/schema';
|
||||
|
||||
export const ZCreateEmbeddingDocumentRequestSchema = z.object({
|
||||
title: ZDocumentTitleSchema,
|
||||
documentDataId: z.string(),
|
||||
externalId: ZDocumentExternalIdSchema.optional(),
|
||||
recipients: z
|
||||
.array(
|
||||
ZCreateRecipientSchema.extend({
|
||||
fields: ZFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
pageNumber: ZFieldPageNumberSchema,
|
||||
pageX: ZFieldPageXSchema,
|
||||
pageY: ZFieldPageYSchema,
|
||||
width: ZFieldWidthSchema,
|
||||
height: ZFieldHeightSchema,
|
||||
}),
|
||||
)
|
||||
.array()
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{ message: 'Recipients must have unique emails' },
|
||||
)
|
||||
.optional(),
|
||||
meta: z
|
||||
.object({
|
||||
subject: ZDocumentMetaSubjectSchema.optional(),
|
||||
message: ZDocumentMetaMessageSchema.optional(),
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ZCreateEmbeddingDocumentResponseSchema = z.object({
|
||||
documentId: z.number(),
|
||||
});
|
||||
|
||||
export type TCreateEmbeddingDocumentRequestSchema = z.infer<
|
||||
typeof ZCreateEmbeddingDocumentRequestSchema
|
||||
>;
|
||||
@ -0,0 +1,73 @@
|
||||
import { isCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
|
||||
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZCreateEmbeddingPresignTokenRequestSchema,
|
||||
ZCreateEmbeddingPresignTokenResponseSchema,
|
||||
createEmbeddingPresignTokenMeta,
|
||||
} from './create-embedding-presign-token.types';
|
||||
|
||||
/**
|
||||
* Route to create embedding presign tokens.
|
||||
*/
|
||||
export const createEmbeddingPresignTokenRoute = procedure
|
||||
.meta(createEmbeddingPresignTokenMeta)
|
||||
.input(ZCreateEmbeddingPresignTokenRequestSchema)
|
||||
.output(ZCreateEmbeddingPresignTokenResponseSchema)
|
||||
.mutation(async ({ input, ctx: { req } }) => {
|
||||
try {
|
||||
const authorizationHeader = req.headers.get('authorization');
|
||||
const [apiToken] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||
|
||||
if (!apiToken) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'No API token provided',
|
||||
});
|
||||
}
|
||||
|
||||
const { expiresIn } = input;
|
||||
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
const token = await getApiTokenByToken({ token: apiToken });
|
||||
|
||||
if (!token.userId) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid API token',
|
||||
});
|
||||
}
|
||||
|
||||
const [hasCommunityPlan, hasPlatformPlan, hasEnterprisePlan] = await Promise.all([
|
||||
isCommunityPlan({ userId: token.userId, teamId: token.teamId ?? undefined }),
|
||||
isDocumentPlatform({ userId: token.userId, teamId: token.teamId }),
|
||||
isUserEnterprise({ userId: token.userId, teamId: token.teamId ?? undefined }),
|
||||
]);
|
||||
|
||||
if (!hasCommunityPlan && !hasPlatformPlan && !hasEnterprisePlan) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to create embedding presign tokens',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const presignToken = await createEmbeddingPresignToken({
|
||||
apiToken,
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
return { ...presignToken };
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to create embedding presign token',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,38 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const createEmbeddingPresignTokenMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/embedding/create-presign-token',
|
||||
summary: 'Create embedding presign token',
|
||||
description:
|
||||
'Creates a presign token for embedding operations with configurable expiration time',
|
||||
tags: ['Embedding'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZCreateEmbeddingPresignTokenRequestSchema = z.object({
|
||||
expiresIn: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(10080)
|
||||
.optional()
|
||||
.default(60)
|
||||
.describe('Expiration time in minutes (default: 60, max: 10,080)'),
|
||||
});
|
||||
|
||||
export const ZCreateEmbeddingPresignTokenResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
expiresAt: z.date(),
|
||||
expiresIn: z.number().describe('Expiration time in seconds'),
|
||||
});
|
||||
|
||||
export type TCreateEmbeddingPresignTokenRequestSchema = z.infer<
|
||||
typeof ZCreateEmbeddingPresignTokenRequestSchema
|
||||
>;
|
||||
|
||||
export type TCreateEmbeddingPresignTokenResponseSchema = z.infer<
|
||||
typeof ZCreateEmbeddingPresignTokenResponseSchema
|
||||
>;
|
||||
@ -0,0 +1,112 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZCreateEmbeddingTemplateRequestSchema,
|
||||
ZCreateEmbeddingTemplateResponseSchema,
|
||||
} from './create-embedding-template.types';
|
||||
|
||||
export const createEmbeddingTemplateRoute = procedure
|
||||
.input(ZCreateEmbeddingTemplateRequestSchema)
|
||||
.output(ZCreateEmbeddingTemplateResponseSchema)
|
||||
.mutation(async ({ input, ctx: { req } }) => {
|
||||
try {
|
||||
const authorizationHeader = req.headers.get('authorization');
|
||||
|
||||
const [presignToken] = (authorizationHeader || '')
|
||||
.split('Bearer ')
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
if (!presignToken) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'No presign token provided',
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
|
||||
|
||||
const { title, documentDataId, recipients, meta } = input;
|
||||
|
||||
// First create the template
|
||||
const template = await createTemplate({
|
||||
userId: apiToken.userId,
|
||||
title,
|
||||
templateDocumentDataId: documentDataId,
|
||||
teamId: apiToken.teamId ?? undefined,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
recipients.map(async (recipient) => {
|
||||
const createdRecipient = await prisma.recipient.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name || '',
|
||||
role: recipient.role || 'SIGNER',
|
||||
token: `template-${template.id}-${recipient.email}`,
|
||||
signingOrder: recipient.signingOrder,
|
||||
},
|
||||
});
|
||||
|
||||
const fields = recipient.fields ?? [];
|
||||
|
||||
const createdFields = await prisma.field.createMany({
|
||||
data: fields.map((field) => ({
|
||||
recipientId: createdRecipient.id,
|
||||
type: field.type,
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
templateId: template.id,
|
||||
})),
|
||||
});
|
||||
|
||||
return {
|
||||
...createdRecipient,
|
||||
fields: createdFields,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Update the template meta if needed
|
||||
if (meta) {
|
||||
await prisma.templateMeta.upsert({
|
||||
where: {
|
||||
templateId: template.id,
|
||||
},
|
||||
create: {
|
||||
templateId: template.id,
|
||||
...meta,
|
||||
},
|
||||
update: {
|
||||
...meta,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!template.id) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to create template: missing template ID',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
templateId: template.id,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to create template',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,74 @@
|
||||
import { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import {
|
||||
ZFieldHeightSchema,
|
||||
ZFieldPageNumberSchema,
|
||||
ZFieldPageXSchema,
|
||||
ZFieldPageYSchema,
|
||||
ZFieldWidthSchema,
|
||||
} from '@documenso/lib/types/field';
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
|
||||
import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
ZDocumentMetaTypedSignatureEnabledSchema,
|
||||
ZDocumentMetaUploadSignatureEnabledSchema,
|
||||
ZDocumentTitleSchema,
|
||||
} from '../document-router/schema';
|
||||
|
||||
const ZFieldSchema = z.object({
|
||||
type: z.nativeEnum(FieldType),
|
||||
pageNumber: ZFieldPageNumberSchema,
|
||||
pageX: ZFieldPageXSchema,
|
||||
pageY: ZFieldPageYSchema,
|
||||
width: ZFieldWidthSchema,
|
||||
height: ZFieldHeightSchema,
|
||||
fieldMeta: ZFieldMetaSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZCreateEmbeddingTemplateRequestSchema = z.object({
|
||||
title: ZDocumentTitleSchema,
|
||||
documentDataId: z.string(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
role: z.nativeEnum(RecipientRole).optional(),
|
||||
signingOrder: z.number().optional(),
|
||||
fields: z.array(ZFieldSchema).optional(),
|
||||
}),
|
||||
),
|
||||
meta: z
|
||||
.object({
|
||||
subject: ZDocumentMetaSubjectSchema.optional(),
|
||||
message: ZDocumentMetaMessageSchema.optional(),
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ZCreateEmbeddingTemplateResponseSchema = z.object({
|
||||
templateId: z.number(),
|
||||
});
|
||||
|
||||
export type TCreateEmbeddingTemplateRequestSchema = z.infer<
|
||||
typeof ZCreateEmbeddingTemplateRequestSchema
|
||||
>;
|
||||
@ -0,0 +1,63 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZGetEmbeddingDocumentRequestSchema,
|
||||
ZGetEmbeddingDocumentResponseSchema,
|
||||
} from './get-embedding-document.types';
|
||||
|
||||
export const getEmbeddingDocumentRoute = procedure
|
||||
.input(ZGetEmbeddingDocumentRequestSchema)
|
||||
.output(ZGetEmbeddingDocumentResponseSchema)
|
||||
.query(async ({ input, ctx: { req } }) => {
|
||||
try {
|
||||
const authorizationHeader = req.headers.get('authorization');
|
||||
|
||||
const [presignToken] = (authorizationHeader || '')
|
||||
.split('Bearer ')
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
if (!presignToken) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'No presign token provided',
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
|
||||
|
||||
const { documentId } = input;
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId: apiToken.userId,
|
||||
...(apiToken.teamId ? { teamId: apiToken.teamId } : {}),
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
recipients: true,
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
document,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to get document',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,34 @@
|
||||
import { DocumentDataType, type Field, type Recipient } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZGetEmbeddingDocumentRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
});
|
||||
|
||||
export const ZGetEmbeddingDocumentResponseSchema = z.object({
|
||||
document: z
|
||||
.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
status: z.string(),
|
||||
documentDataId: z.string(),
|
||||
userId: z.number(),
|
||||
teamId: z.number().nullable(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
documentData: z.object({
|
||||
id: z.string(),
|
||||
type: z.nativeEnum(DocumentDataType),
|
||||
data: z.string(),
|
||||
initialData: z.string(),
|
||||
}),
|
||||
recipients: z.array(z.custom<Recipient>()),
|
||||
fields: z.array(z.custom<Field>()),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type TGetEmbeddingDocumentRequestSchema = z.infer<typeof ZGetEmbeddingDocumentRequestSchema>;
|
||||
export type TGetEmbeddingDocumentResponseSchema = z.infer<
|
||||
typeof ZGetEmbeddingDocumentResponseSchema
|
||||
>;
|
||||
@ -0,0 +1,36 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZVerifyEmbeddingPresignTokenRequestSchema,
|
||||
ZVerifyEmbeddingPresignTokenResponseSchema,
|
||||
verifyEmbeddingPresignTokenMeta,
|
||||
} from './verify-embedding-presign-token.types';
|
||||
|
||||
/**
|
||||
* Public route.
|
||||
*/
|
||||
export const verifyEmbeddingPresignTokenRoute = procedure
|
||||
.meta(verifyEmbeddingPresignTokenMeta)
|
||||
.input(ZVerifyEmbeddingPresignTokenRequestSchema)
|
||||
.output(ZVerifyEmbeddingPresignTokenResponseSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const { token } = input;
|
||||
|
||||
const apiToken = await verifyEmbeddingPresignToken({
|
||||
token,
|
||||
}).catch(() => null);
|
||||
|
||||
return { success: !!apiToken };
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to verify embedding presign token',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const verifyEmbeddingPresignTokenMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/embedding/verify-presign-token',
|
||||
summary: 'Verify embedding presign token',
|
||||
description:
|
||||
'Verifies a presign token for embedding operations and returns the associated API token',
|
||||
tags: ['Embedding'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZVerifyEmbeddingPresignTokenRequestSchema = z.object({
|
||||
token: z
|
||||
.string()
|
||||
.min(1, { message: 'Token is required' })
|
||||
.describe('The presign token to verify'),
|
||||
});
|
||||
|
||||
export const ZVerifyEmbeddingPresignTokenResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
});
|
||||
|
||||
export type TVerifyEmbeddingPresignTokenRequestSchema = z.infer<
|
||||
typeof ZVerifyEmbeddingPresignTokenRequestSchema
|
||||
>;
|
||||
|
||||
export type TVerifyEmbeddingPresignTokenResponseSchema = z.infer<
|
||||
typeof ZVerifyEmbeddingPresignTokenResponseSchema
|
||||
>;
|
||||
@ -2,6 +2,7 @@ import { adminRouter } from './admin-router/router';
|
||||
import { apiTokenRouter } from './api-token-router/router';
|
||||
import { authRouter } from './auth-router/router';
|
||||
import { documentRouter } from './document-router/router';
|
||||
import { embeddingPresignRouter } from './embedding-router/_router';
|
||||
import { fieldRouter } from './field-router/router';
|
||||
import { profileRouter } from './profile-router/router';
|
||||
import { recipientRouter } from './recipient-router/router';
|
||||
@ -23,6 +24,7 @@ export const appRouter = router({
|
||||
team: teamRouter,
|
||||
template: templateRouter,
|
||||
webhook: webhookRouter,
|
||||
embeddingPresign: embeddingPresignRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@ -31,16 +31,16 @@ export const DocumentGlobalAuthAccessSelect = forwardRef<HTMLButtonElement, Sele
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>No restrictions</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{Object.values(DocumentAccessAuth).map((authType) => (
|
||||
<SelectItem key={authType} value={authType}>
|
||||
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>No restrictions</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@ -32,6 +32,11 @@ export const DocumentGlobalAuthActionSelect = forwardRef<HTMLButtonElement, Sele
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>No restrictions</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{Object.values(DocumentActionAuth)
|
||||
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
|
||||
.map((authType) => (
|
||||
@ -39,11 +44,6 @@ export const DocumentGlobalAuthActionSelect = forwardRef<HTMLButtonElement, Sele
|
||||
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>No restrictions</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
@ -8,14 +8,11 @@ import type { Field, Recipient } from '@prisma/client';
|
||||
import { FieldType, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import {
|
||||
CalendarDays,
|
||||
Check,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Contact,
|
||||
Disc,
|
||||
Hash,
|
||||
Info,
|
||||
Mail,
|
||||
Type,
|
||||
User,
|
||||
@ -27,7 +24,6 @@ import { prop, sortBy } from 'remeda';
|
||||
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 { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import {
|
||||
type TFieldMetaSchema as FieldMeta,
|
||||
ZFieldMetaSchema,
|
||||
@ -42,16 +38,13 @@ import {
|
||||
} from '@documenso/lib/utils/recipients';
|
||||
|
||||
import { FieldToolTip } from '../../components/field/field-tooltip';
|
||||
import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors';
|
||||
import { useSignerColors } from '../../lib/signer-colors';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Alert, AlertDescription } from '../alert';
|
||||
import { Button } from '../button';
|
||||
import { Card, CardContent } from '../card';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../command';
|
||||
import { Form } from '../form/form';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
|
||||
import { RecipientSelector } from '../recipient-selector';
|
||||
import { useStep } from '../stepper';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
|
||||
import { useToast } from '../use-toast';
|
||||
import type { TAddFieldsFormSchema } from './add-fields.types';
|
||||
import {
|
||||
@ -663,123 +656,12 @@ export const AddFieldsFormPartial = ({
|
||||
})}
|
||||
|
||||
{!hideRecipients && (
|
||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'bg-background text-muted-foreground hover:text-foreground mb-12 mt-2 justify-between font-normal',
|
||||
selectedSignerStyles.default.base,
|
||||
)}
|
||||
>
|
||||
{selectedSigner?.email && (
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedSigner?.name} ({selectedSigner?.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!selectedSigner?.email && (
|
||||
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
|
||||
)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command value={selectedSigner?.email}>
|
||||
<CommandInput />
|
||||
|
||||
<CommandEmpty>
|
||||
<span className="text-muted-foreground inline-block px-4">
|
||||
<Trans>No recipient matching this description was found.</Trans>
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
|
||||
{recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
|
||||
<CommandGroup key={roleIndex}>
|
||||
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
|
||||
</div>
|
||||
|
||||
{roleRecipients.length === 0 && (
|
||||
<div
|
||||
key={`${role}-empty`}
|
||||
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||
>
|
||||
<Trans>No recipients with this role</Trans>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roleRecipients.map((recipient) => (
|
||||
<CommandItem
|
||||
key={recipient.id}
|
||||
className={cn(
|
||||
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
|
||||
getSignerColorStyles(
|
||||
Math.max(
|
||||
recipients.findIndex((r) => r.id === recipient.id),
|
||||
0,
|
||||
),
|
||||
).default.comboxBoxItem,
|
||||
{
|
||||
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||
},
|
||||
)}
|
||||
onSelect={() => {
|
||||
setSelectedSigner(recipient);
|
||||
setShowRecipientsSelector(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn('text-foreground/70 truncate', {
|
||||
'text-foreground/80': recipient === selectedSigner,
|
||||
})}
|
||||
>
|
||||
{recipient.name && (
|
||||
<span title={`${recipient.name} (${recipient.email})`}>
|
||||
{recipient.name} ({recipient.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!recipient.name && (
|
||||
<span title={recipient.email}>{recipient.email}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center justify-center">
|
||||
{recipient.sendStatus !== SendStatus.SENT ? (
|
||||
<Check
|
||||
aria-hidden={recipient !== selectedSigner}
|
||||
className={cn('h-4 w-4 flex-shrink-0', {
|
||||
'opacity-0': recipient !== selectedSigner,
|
||||
'opacity-100': recipient === selectedSigner,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="ml-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
<Trans>
|
||||
This document has already been sent to this recipient. You
|
||||
can no longer edit this recipient.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<RecipientSelector
|
||||
selectedRecipient={selectedSigner}
|
||||
onSelectedRecipientChange={setSelectedSigner}
|
||||
recipients={recipients}
|
||||
className="mb-12 mt-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
|
||||
128
packages/ui/primitives/field-selector.tsx
Normal file
128
packages/ui/primitives/field-selector.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
import {
|
||||
CalendarDays,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
Contact,
|
||||
Disc,
|
||||
Hash,
|
||||
Mail,
|
||||
Type,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Card, CardContent } from './card';
|
||||
|
||||
export interface FieldSelectorProps {
|
||||
className?: string;
|
||||
selectedField: FieldType | null;
|
||||
onSelectedFieldChange: (fieldType: FieldType) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const FieldSelector = ({
|
||||
className,
|
||||
selectedField,
|
||||
onSelectedFieldChange,
|
||||
disabled = false,
|
||||
}: FieldSelectorProps) => {
|
||||
const fieldTypes = [
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
label: 'Signature',
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
type: FieldType.INITIALS,
|
||||
label: 'Initials',
|
||||
icon: Contact,
|
||||
},
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
label: 'Email',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
type: FieldType.NAME,
|
||||
label: 'Name',
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
label: 'Date',
|
||||
icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
label: 'Text',
|
||||
icon: Type,
|
||||
},
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
label: 'Number',
|
||||
icon: Hash,
|
||||
},
|
||||
{
|
||||
type: FieldType.RADIO,
|
||||
label: 'Radio',
|
||||
icon: Disc,
|
||||
},
|
||||
{
|
||||
type: FieldType.CHECKBOX,
|
||||
label: 'Checkbox',
|
||||
icon: CheckSquare,
|
||||
},
|
||||
{
|
||||
type: FieldType.DROPDOWN,
|
||||
label: 'Dropdown',
|
||||
icon: ChevronDown,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{fieldTypes.map((field) => {
|
||||
const Icon = field.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={field.type}
|
||||
type="button"
|
||||
className="group w-full"
|
||||
onPointerDown={() => onSelectedFieldChange(field.type)}
|
||||
disabled={disabled}
|
||||
data-selected={selectedField === field.type ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
{
|
||||
'border-primary': selectedField === field.type,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<CardContent className="relative flex items-center justify-center gap-x-2 px-6 py-4">
|
||||
{Icon && <Icon className="text-muted-foreground h-4 w-4" />}
|
||||
<span
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-sm',
|
||||
field.type === FieldType.SIGNATURE && 'invisible',
|
||||
)}
|
||||
>
|
||||
{field.label}
|
||||
</span>
|
||||
|
||||
{field.type === FieldType.SIGNATURE && (
|
||||
<div className="text-muted-foreground font-signature absolute inset-0 flex items-center justify-center text-lg">
|
||||
Signature
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
195
packages/ui/primitives/recipient-selector.tsx
Normal file
195
packages/ui/primitives/recipient-selector.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { Check, ChevronsUpDown, Info } from 'lucide-react';
|
||||
import { sortBy } from 'remeda';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
|
||||
import { getSignerColorStyles } from '../lib/signer-colors';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './button';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from './command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
|
||||
|
||||
export interface RecipientSelectorProps {
|
||||
className?: string;
|
||||
selectedRecipient: Recipient | null;
|
||||
onSelectedRecipientChange: (recipient: Recipient) => void;
|
||||
recipients: Recipient[];
|
||||
}
|
||||
|
||||
export const RecipientSelector = ({
|
||||
className,
|
||||
selectedRecipient,
|
||||
onSelectedRecipientChange,
|
||||
recipients,
|
||||
}: RecipientSelectorProps) => {
|
||||
const { _ } = useLingui();
|
||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||
|
||||
const recipientsByRole = useCallback(() => {
|
||||
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
|
||||
CC: [],
|
||||
VIEWER: [],
|
||||
SIGNER: [],
|
||||
APPROVER: [],
|
||||
ASSISTANT: [],
|
||||
};
|
||||
|
||||
recipients.forEach((recipient) => {
|
||||
recipientsByRole[recipient.role].push(recipient);
|
||||
});
|
||||
|
||||
return recipientsByRole;
|
||||
}, [recipients]);
|
||||
|
||||
const recipientsByRoleToDisplay = useCallback(() => {
|
||||
return Object.entries(recipientsByRole())
|
||||
.filter(
|
||||
([role]) =>
|
||||
role !== RecipientRole.CC &&
|
||||
role !== RecipientRole.VIEWER &&
|
||||
role !== RecipientRole.ASSISTANT,
|
||||
)
|
||||
.map(
|
||||
([role, roleRecipients]) =>
|
||||
[
|
||||
role,
|
||||
sortBy(
|
||||
roleRecipients,
|
||||
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
|
||||
[(r) => r.id, 'asc'],
|
||||
),
|
||||
] as [RecipientRole, Recipient[]],
|
||||
);
|
||||
}, [recipientsByRole]);
|
||||
|
||||
return (
|
||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
|
||||
getSignerColorStyles(
|
||||
Math.max(
|
||||
recipients.findIndex((r) => r.id === selectedRecipient?.id),
|
||||
0,
|
||||
),
|
||||
).default.base,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{selectedRecipient?.email && (
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedRecipient?.name} ({selectedRecipient?.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!selectedRecipient?.email && (
|
||||
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
|
||||
)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command value={selectedRecipient?.email}>
|
||||
<CommandInput />
|
||||
|
||||
<CommandEmpty>
|
||||
<span className="text-muted-foreground inline-block px-4">
|
||||
<Trans>No recipient matching this description was found.</Trans>
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
|
||||
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
|
||||
<CommandGroup key={roleIndex}>
|
||||
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
|
||||
</div>
|
||||
|
||||
{roleRecipients.length === 0 && (
|
||||
<div
|
||||
key={`${role}-empty`}
|
||||
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||
>
|
||||
<Trans>No recipients with this role</Trans>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roleRecipients.map((recipient) => (
|
||||
<CommandItem
|
||||
key={recipient.id}
|
||||
className={cn(
|
||||
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
|
||||
getSignerColorStyles(
|
||||
Math.max(
|
||||
recipients.findIndex((r) => r.id === recipient.id),
|
||||
0,
|
||||
),
|
||||
).default.comboxBoxItem,
|
||||
{
|
||||
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||
},
|
||||
)}
|
||||
onSelect={() => {
|
||||
onSelectedRecipientChange(recipient);
|
||||
setShowRecipientsSelector(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn('text-foreground/70 truncate', {
|
||||
'text-foreground/80': recipient === selectedRecipient,
|
||||
})}
|
||||
>
|
||||
{recipient.name && (
|
||||
<span title={`${recipient.name} (${recipient.email})`}>
|
||||
{recipient.name} ({recipient.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center justify-center">
|
||||
{recipient.sendStatus !== SendStatus.SENT ? (
|
||||
<Check
|
||||
aria-hidden={recipient !== selectedRecipient}
|
||||
className={cn('h-4 w-4 flex-shrink-0', {
|
||||
'opacity-0': recipient !== selectedRecipient,
|
||||
'opacity-100': recipient === selectedRecipient,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="ml-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
<Trans>
|
||||
This document has already been sent to this recipient. You can no longer
|
||||
edit this recipient.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -3,7 +3,7 @@ services:
|
||||
runtime: node
|
||||
name: documenso-app
|
||||
plan: free
|
||||
buildCommand: npm i && npm run build:web
|
||||
buildCommand: npm i && npm run build
|
||||
startCommand: npx prisma migrate deploy --schema packages/prisma/schema.prisma && npx turbo run start --filter=@documenso/web
|
||||
healthCheckPath: /api/health
|
||||
|
||||
|
||||
Reference in New Issue
Block a user