Compare commits

..

4 Commits

Author SHA1 Message Date
Lucas Smith 092d4a0593 Merge branch 'feat/rr7' into chore/webhook-trigger-multiselect 2025-05-15 10:57:50 +10:00
David Nguyen 3e97643e7e feat: add signature configurations (#1710)
Add ability to enable or disable allowed signature types: 
- Drawn
- Typed
- Uploaded

**Tabbed style signature dialog**

![image](https://github.com/user-attachments/assets/a816fab6-b071-42a5-bb5c-6d4a2572431e)

**Document settings**

![image](https://github.com/user-attachments/assets/f0c1bff1-6be1-4c87-b384-1666fa25d7a6)

**Team preferences**

![image](https://github.com/user-attachments/assets/8767b05e-1463-4087-8672-f3f43d8b0f2c)

## Changes Made

- Add multiselect to select allowed signatures in document and templates
settings tab
- Add multiselect to select allowed signatures in teams preferences 
- Removed "Enable typed signatures" from document/template edit page
- Refactored signature pad to use tabs instead of an all in one
signature pad

## Testing Performed

Added E2E tests to check settings are applied correctly for documents
and templates
2025-03-24 15:25:29 +11:00
Catalin Pit 1b5d24e308 chore: add terms and privacy policy link (#1707) 2025-03-19 10:05:44 +02:00
Ephraim Atta-Duncan 7f9f7c4092 feat: use multiselect for webhook triggers 2025-02-18 18:02:31 +00:00
186 changed files with 2962 additions and 23277 deletions
+23
View File
@@ -0,0 +1,23 @@
name: Cache production build binaries
description: 'Cache or restore if necessary'
inputs:
node_version:
required: false
default: v22.x
runs:
using: 'composite'
steps:
- name: Cache production build
uses: actions/cache@v3
id: production-build-cache
with:
path: |
${{ github.workspace }}/apps/web/.next
**/.turbo/**
**/dist/**
key: prod-build-${{ github.run_id }}-${{ hashFiles('package-lock.json') }}
restore-keys: prod-build-
- run: npm run build
shell: bash
+1 -2
View File
@@ -26,8 +26,7 @@ jobs:
- name: Copy env
run: cp .env.example .env
- name: Build app
run: npm run build
- uses: ./.github/actions/cache-build
build_docker:
name: Build Docker Image
+29
View File
@@ -0,0 +1,29 @@
name: cleanup caches by a branch
on:
pull_request:
types:
- closed
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Cleanup
run: |
gh extension install actions/gh-actions-cache
echo "Fetching list of cache key"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 )
## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
do
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
done
echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge
+2 -3
View File
@@ -10,7 +10,7 @@ on:
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
permissions:
actions: read
contents: read
@@ -30,8 +30,7 @@ jobs:
- uses: ./.github/actions/node-install
- name: Build app
run: npm run build
- uses: ./.github/actions/cache-build
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
+1 -5
View File
@@ -28,11 +28,7 @@ jobs:
- name: Seed the database
run: npm run prisma:seed
- name: Build app
run: npm run build
- name: Install playwright browsers
run: npx playwright install --with-deps
- uses: ./.github/actions/cache-build
- name: Run Playwright tests
run: npm run ci
+3
View File
@@ -1 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run commitlint -- $1
+3
View File
@@ -1,3 +1,6 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
@@ -6,6 +6,5 @@
"solid": "Solid Integration",
"preact": "Preact Integration",
"angular": "Angular Integration",
"css-variables": "CSS Variables",
"authoring": "Authoring"
}
"css-variables": "CSS Variables"
}
@@ -1,167 +0,0 @@
---
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,19 +169,6 @@ 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)
@@ -191,4 +178,3 @@ For detailed implementation instructions and code examples, see our [Embedded Au
- [Preact Integration](/developers/embedding/preact)
- [Angular Integration](/developers/embedding/angular)
- [CSS Variables](/developers/embedding/css-variables)
- [Embedded Authoring](/developers/embedding/authoring)
@@ -532,93 +532,3 @@ 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,3 +1,5 @@
'use client';
import React from 'react';
import NextPlausibleProvider from 'next-plausible';
-54
View File
@@ -1,54 +0,0 @@
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,8 +3,6 @@ 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')
@@ -37,7 +35,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
],
};
return addZeroMonth(transformedData);
return transformedData;
};
export type GetCompletedDocumentsMonthlyResult = Awaited<
@@ -2,8 +2,6 @@ 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')
@@ -36,7 +34,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
],
};
return addZeroMonth(transformedData);
return transformedData;
};
export type GetSignerConversionMonthlyResult = Awaited<
@@ -2,8 +2,6 @@ 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')
@@ -34,7 +32,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
],
};
return addZeroMonth(transformedData);
return transformedData;
};
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
+21 -69
View File
@@ -1,7 +1,5 @@
import { DateTime } from 'luxon';
import { addZeroMonth } from './add-zero-month';
type MetricKeys = {
stars: number;
forks: number;
@@ -39,77 +37,31 @@ export function transformData({
data: DataEntry;
metric: MetricKey;
}): TransformData {
try {
if (!data || Object.keys(data).length === 0) {
return {
labels: [],
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
};
}
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
const [yearA, monthA] = dateA.split('-').map(Number);
const [yearB, monthB] = dateB.split('-').map(Number);
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
try {
const [yearA, monthA] = dateA.split('-').map(Number);
const [yearB, monthB] = dateB.split('-').map(Number);
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
});
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;
}
const labels = sortedEntries.map(([date]) => {
const [year, month] = date.split('-');
const dateTime = DateTime.fromObject({
year: Number(year),
month: Number(month),
});
return dateTime.toFormat('MMM yyyy');
});
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: [] }],
};
}
return {
labels,
datasets: [
{
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
data: sortedEntries.map(([_, stats]) => stats[metric]),
},
],
};
}
// To be on the safer side
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
#!/usr/bin/env sh
# Exit on error.
set -e
set -eo pipefail
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
WEB_APP_DIR="$SCRIPT_DIR/.."
@@ -1,9 +1,4 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -14,192 +9,64 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
export type NextSigner = {
name: string;
email: string;
};
type ConfirmationDialogProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: (nextSigner?: NextSigner) => void;
onConfirm: () => void;
hasUninsertedFields: boolean;
isSubmitting: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: NextSigner;
};
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
export function AssistantConfirmationDialog({
isOpen,
onClose,
onConfirm,
hasUninsertedFields,
isSubmitting,
allowDictateNextSigner = false,
defaultNextSigner,
}: ConfirmationDialogProps) {
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
resolver: zodResolver(ZNextSignerFormSchema),
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
},
});
const onOpenChange = () => {
if (isSubmitting) {
return;
}
form.reset({
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
});
onClose();
};
const handleSubmit = () => {
// Validate the form and submit it if dictate signer is enabled.
if (allowDictateNextSigner) {
void form.handleSubmit(onConfirm)();
return;
}
onConfirm();
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<Form {...form}>
<form>
<fieldset disabled={isSubmitting} className="border-none p-0">
<DialogHeader>
<DialogTitle>
<Trans>Complete Document</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Are you sure you want to complete the document? This action cannot be undone.
Please ensure that you have completed prefilling all relevant fields before
proceeding.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogHeader>
<DialogTitle>
<Trans>Complete Document</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Are you sure you want to complete the document? This action cannot be undone. Please
ensure that you have completed prefilling all relevant fields before proceeding.
</Trans>
</DialogDescription>
</DialogHeader>
<div className="mt-4 flex flex-col gap-4">
{allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<div className="flex flex-col gap-4">
<DocumentSigningDisclosure />
</div>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
</div>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
variant={hasUninsertedFields ? 'destructive' : 'default'}
disabled={isSubmitting}
onClick={handleSubmit}
loading={isSubmitting}
>
{hasUninsertedFields ? <Trans>Proceed</Trans> : <Trans>Continue</Trans>}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
variant={hasUninsertedFields ? 'destructive' : 'default'}
onClick={onConfirm}
disabled={isSubmitting}
loading={isSubmitting}
>
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
@@ -114,7 +114,7 @@ export const TemplateBulkSendDialog = ({
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline">
<Button>
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</Button>
@@ -1,355 +0,0 @@
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>
);
};
@@ -1,68 +0,0 @@
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;
};
@@ -1,393 +0,0 @@
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>
);
};
@@ -1,238 +0,0 @@
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>
);
};
@@ -1,131 +0,0 @@
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>
);
};
@@ -1,48 +0,0 @@
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(),
});
@@ -1,661 +0,0 @@
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 { useRecipientColors } from '@documenso/ui/lib/recipient-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 selectedRecipientStyles = useRecipientColors(
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]',
selectedRecipientStyles.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}
/>
</>
);
};
@@ -1,83 +0,0 @@
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,11 +1,7 @@
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">
<Loader className="mr-2 h-4 w-4 animate-spin" />
<span>Loading...</span>
Loading...
</div>
);
};
@@ -10,6 +10,7 @@ export type EmbedDocumentCompletedPageProps = {
};
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
console.log({ signature });
return (
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-2xl font-semibold">
@@ -15,12 +15,10 @@ 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';
import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@@ -49,7 +47,6 @@ export type EmbedSignDocumentClientPageProps = {
documentData: DocumentData;
recipient: RecipientWithFields;
fields: Field[];
completedFields: DocumentField[];
metadata?: DocumentMeta | TemplateMeta | null;
isCompleted?: boolean;
hidePoweredBy?: boolean;
@@ -63,7 +60,6 @@ export const EmbedSignDocumentClientPage = ({
documentData,
recipient,
fields,
completedFields,
metadata,
isCompleted,
hidePoweredBy = false,
@@ -89,8 +85,6 @@ 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);
@@ -208,7 +202,6 @@ 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');
@@ -475,9 +468,6 @@ export const EmbedSignDocumentClientPage = ({
{/* Fields */}
<EmbedDocumentFields fields={fields} metadata={metadata} />
{/* Completed fields */}
<DocumentReadOnlyFields documentMeta={metadata || undefined} fields={completedFields} />
</div>
{!hidePoweredBy && (
@@ -9,10 +9,6 @@ import { z } from 'zod';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TTemplate } from '@documenso/lib/types/template';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '@documenso/ui/components/document/document-read-only-fields';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
@@ -20,6 +16,7 @@ import {
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { ShowFieldItem } from '@documenso/ui/primitives/document-flow/show-field-item';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import {
Form,
@@ -100,16 +97,14 @@ export const DirectTemplateConfigureForm = ({
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded && (
<DocumentReadOnlyFields
fields={mapFieldsWithRecipients(
directTemplateRecipient.fields,
recipientsWithBlankDirectRecipientEmail,
)}
recipientIds={recipients.map((recipient) => recipient.id)}
showRecipientColors={true}
/>
)}
{isDocumentPdfLoaded &&
directTemplateRecipient.fields.map((field, index) => (
<ShowFieldItem
key={index}
field={field}
recipients={recipientsWithBlankDirectRecipientEmail}
/>
))}
<Form {...form}>
<fieldset
@@ -8,7 +8,6 @@ import { useNavigate, useSearchParams } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TTemplate } from '@documenso/lib/types/template';
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
@@ -104,27 +103,17 @@ export const DirectTemplatePageView = ({
directRecipientEmail: recipient.email,
templateUpdatedAt: template.updatedAt,
signedFieldValues: fields.map((field) => {
if (isRequiredField(field) && !field.signedValue) {
if (!field.signedValue) {
throw new Error('Invalid configuration');
}
return {
token: field.signedValue?.token ?? '',
fieldId: field.signedValue?.fieldId ?? 0,
value: field.signedValue?.value,
isBase64: field.signedValue?.isBase64,
authOptions: field.signedValue?.authOptions,
};
return field.signedValue;
}),
});
const redirectUrl = template.templateMeta?.redirectUrl;
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
await navigate(`/sign/${token}/complete`);
}
await (redirectUrl ? navigate(redirectUrl) : navigate(`/sign/${token}/complete`));
} catch (err) {
toast({
title: _(msg`Something went wrong`),
@@ -17,7 +17,6 @@ import {
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { TTemplate } from '@documenso/lib/types/template';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
@@ -79,10 +78,6 @@ export const DirectTemplateSigningForm = ({
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const fieldsRequiringValidation = useMemo(() => {
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
}, [localFields]);
const { currentStep, totalSteps, previousStep } = useStep();
const onSignField = (value: TSignFieldWithTokenMutationSchema) => {
@@ -139,18 +134,18 @@ export const DirectTemplateSigningForm = ({
};
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fieldsRequiringValidation);
return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
}, [localFields]);
const fieldsValidated = () => {
setValidateUninsertedFields(true);
validateFieldsInserted(fieldsRequiringValidation);
validateFieldsInserted(localFields);
};
const handleSubmit = async () => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
const isFieldsValid = validateFieldsInserted(localFields);
if (!isFieldsValid) {
return;
@@ -97,12 +97,6 @@ export const DocumentSigningCheckboxField = ({
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
// Do nothing, this should only happen when the user clicks the field, but
// misses the checkbox which triggers this callback.
if (checkedValues.length === 0) {
return;
}
if (!isLengthConditionMet) {
return;
}
@@ -276,26 +270,21 @@ export const DocumentSigningCheckboxField = ({
{validationSign?.label} {checkboxValidationLength}
</FieldToolTip>
)}
<div className="z-50 my-0.5 flex flex-col gap-y-1">
<div className="z-50 flex flex-col gap-y-2">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`;
return (
<div key={index} className="flex items-center">
<div key={index} className="flex items-center gap-x-1.5">
<Checkbox
className="h-3 w-3"
id={`checkbox-${field.id}-${item.id}`}
className="h-4 w-4"
id={`checkbox-${index}`}
checked={checkedValues.includes(itemValue)}
onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
/>
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`checkbox-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
<Label htmlFor={`checkbox-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
);
})}
@@ -304,27 +293,22 @@ export const DocumentSigningCheckboxField = ({
)}
{field.inserted && (
<div className="my-0.5 flex flex-col gap-y-1">
<div className="flex flex-col gap-y-1">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`;
return (
<div key={index} className="flex items-center">
<div key={index} className="flex items-center gap-x-1.5">
<Checkbox
className="h-3 w-3"
id={`checkbox-${field.id}-${item.id}`}
id={`checkbox-${index}`}
checked={parsedCheckedValues.includes(itemValue)}
disabled={isLoading}
onCheckedChange={() => void handleCheckboxOptionClick(item)}
/>
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`checkbox-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
<Label htmlFor={`checkbox-${index}`} className="text-xs">
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
);
})}
@@ -1,12 +1,9 @@
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { Button } from '@documenso/ui/primitives/button';
@@ -17,15 +14,6 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
@@ -34,23 +22,11 @@ export type DocumentSigningCompleteDialogProps = {
documentTitle: string;
fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>;
role: RecipientRole;
disabled?: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: {
name: string;
email: string;
};
};
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
export const DocumentSigningCompleteDialog = ({
isSubmitting,
documentTitle,
@@ -59,54 +35,19 @@ export const DocumentSigningCompleteDialog = ({
onSignatureComplete,
role,
disabled = false,
allowDictateNextSigner = false,
defaultNextSigner,
}: DocumentSigningCompleteDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
},
});
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
const handleOpenChange = (open: boolean) => {
if (form.formState.isSubmitting || !isComplete) {
if (isSubmitting || !isComplete) {
return;
}
if (open) {
form.reset({
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
});
}
setIsEditingNextSigner(false);
setShowDialog(open);
};
const onFormSubmit = async (data: TNextSignerFormSchema) => {
console.log('data', data);
console.log('form.formState.errors', form.formState.errors);
try {
if (allowDictateNextSigner && data.name && data.email) {
await onSignatureComplete({ name: data.name, email: data.email });
} else {
await onSignatureComplete();
}
} catch (error) {
console.error('Error completing signature:', error);
}
};
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
return (
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
@@ -130,184 +71,110 @@ export const DocumentSigningCompleteDialog = ({
</DialogTrigger>
<DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</div>
</DialogTitle>
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{match(role)
.with(RecipientRole.VIEWER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
<div className="text-muted-foreground max-w-[50ch]">
{match(role)
.with(RecipientRole.VIEWER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
))
.with(RecipientRole.SIGNER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.SIGNER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
))
.with(RecipientRole.APPROVER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.APPROVER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
))
.otherwise(() => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
.
</span>
<br /> Are you sure?
</Trans>
</span>
))
.otherwise(() => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
))}
</div>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))}
</div>
{allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<DocumentSigningDisclosure className="mt-4" />
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowDialog(false);
}}
>
<Trans>Cancel</Trans>
</Button>
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
<DialogFooter className="mt-4">
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="flex-1"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
className="flex-1"
disabled={!isComplete || !isNextSignerValid}
loading={form.formState.isSubmitting}
>
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
<Button
type="button"
className="flex-1"
disabled={!isComplete}
loading={isSubmitting}
onClick={onSignatureComplete}
>
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
@@ -142,7 +142,7 @@ export const DocumentSigningDateField = ({
)}
{!field.inserted && (
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<Trans>Date</Trans>
</p>
)}
@@ -151,10 +151,12 @@ export const DocumentSigningDateField = ({
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-foreground w-full whitespace-nowrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'!text-center': parsedFieldMeta?.textAlign === 'center',
'!text-right': parsedFieldMeta?.textAlign === 'right',
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
@@ -177,11 +177,15 @@ export const DocumentSigningDropdownField = ({
)}
{!field.inserted && (
<p className="group-hover:text-primary text-foreground flex flex-col items-center justify-center duration-200">
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200">
<Select value={localChoice} onValueChange={handleSelectItem}>
<SelectTrigger
className={cn(
'text-foreground z-10 h-full w-full border-none ring-0 focus:border-none focus:ring-0',
'text-muted-foreground z-10 h-full w-full border-none ring-0 focus:ring-0',
{
'hover:text-red-300': parsedFieldMeta.required,
'hover:text-yellow-300': !parsedFieldMeta.required,
},
)}
>
<SelectValue
@@ -201,7 +205,7 @@ export const DocumentSigningDropdownField = ({
)}
{field.inserted && (
<p className="text-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
)}
@@ -1,6 +1,7 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@@ -13,14 +14,10 @@ import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
@@ -123,18 +120,34 @@ export const DocumentSigningEmailField = ({
return (
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
{isLoading && <DocumentSigningFieldsLoader />}
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<DocumentSigningFieldsUninserted>
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<Trans>Email</Trans>
</DocumentSigningFieldsUninserted>
</p>
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText}
</DocumentSigningFieldsInserted>
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)}
</DocumentSigningFieldContainer>
);
@@ -2,14 +2,12 @@ import React from 'react';
import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { TooltipArrow } from '@radix-ui/react-tooltip';
import { X } from 'lucide-react';
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
@@ -130,51 +128,57 @@ export const DocumentSigningFieldContainer = ({
};
return (
<div className={cn('[container-type:size]')}>
<FieldRootContainer color={RECIPIENT_COLOR_STYLES.green} field={field}>
<div className={cn('[container-type:size]', { group: type === 'Checkbox' })}>
<FieldRootContainer field={field}>
{!field.inserted && !loading && !readOnlyField && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
className="absolute inset-0 z-10 h-full w-full rounded-md border"
onClick={async () => handleInsertField()}
/>
)}
{readOnlyField && (
<button className="bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100">
<span className="bg-foreground/50 text-background rounded-xl p-2">
<span className="bg-foreground/50 dark:bg-background/50 text-background dark:text-foreground rounded-xl p-2">
<Trans>Read only field</Trans>
</span>
</button>
)}
{type === 'Date' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
<Trans>Remove</Trans>
</button>
</TooltipTrigger>
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
</Tooltip>
)}
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
className="dark:bg-background absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
onClick={() => void onClearCheckBoxValues(type)}
>
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<span className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<X className="h-4 w-4" />
</span>
</button>
)}
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
</TooltipTrigger>
<TooltipContent
className="border-0 bg-orange-300 fill-orange-300 font-bold text-orange-900"
sideOffset={2}
>
{tooltipText && <p>{tooltipText}</p>}
<Trans>Remove</Trans>
<TooltipArrow />
</TooltipContent>
</Tooltip>
{type !== 'Date' && type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="text-destructive bg-background/50 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
<Trans>Remove</Trans>
</button>
)}
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
@@ -1,51 +0,0 @@
import { Loader } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
export const DocumentSigningFieldsLoader = () => {
return (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
);
};
export const DocumentSigningFieldsUninserted = ({ children }: { children: React.ReactNode }) => {
return (
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{children}
</p>
);
};
type DocumentSigningFieldsInsertedProps = {
children: React.ReactNode;
/**
* The text alignment of the field.
*
* Defaults to left.
*/
textAlign?: 'left' | 'center' | 'right';
};
export const DocumentSigningFieldsInserted = ({
children,
textAlign = 'left',
}: DocumentSigningFieldsInsertedProps) => {
return (
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-foreground w-full text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'!text-center': textAlign === 'center',
'!text-right': textAlign === 'right',
},
)}
>
{children}
</p>
</div>
);
};
@@ -24,10 +24,7 @@ import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import {
AssistantConfirmationDialog,
type NextSigner,
} from '../../dialogs/assistant-confirmation-dialog';
import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
@@ -67,11 +64,8 @@ export const DocumentSigningForm = ({
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
const {
mutateAsync: completeDocumentWithToken,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation();
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
defaultValues: {
@@ -79,8 +73,10 @@ export const DocumentSigningForm = ({
},
});
const { handleSubmit, formState } = useForm();
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = isPending || isSuccess;
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
@@ -102,6 +98,18 @@ export const DocumentSigningForm = ({
validateFieldsInserted(fieldsRequiringValidation);
};
const onFormSubmit = async () => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
if (!isFieldsValid) {
return;
}
await completeDocument();
};
const onAssistantFormSubmit = () => {
if (uninsertedRecipientFields.length > 0) {
return;
@@ -110,11 +118,11 @@ export const DocumentSigningForm = ({
setIsConfirmationDialogOpen(true);
};
const handleAssistantConfirmDialogSubmit = async (nextSigner?: NextSigner) => {
const handleAssistantConfirmDialogSubmit = async () => {
setIsAssistantSubmitting(true);
try {
await completeDocument(undefined, nextSigner);
await completeDocument();
} catch (err) {
toast({
title: 'Error',
@@ -127,18 +135,12 @@ export const DocumentSigningForm = ({
}
};
const completeDocument = async (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => {
const payload = {
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
await completeDocumentWithToken({
token: recipient.token,
documentId: document.id,
authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocumentWithToken(payload);
});
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
@@ -153,29 +155,6 @@ export const DocumentSigningForm = ({
}
};
const nextRecipient = useMemo(() => {
if (
!document.documentMeta?.signingOrder ||
document.documentMeta.signingOrder !== 'SEQUENTIAL'
) {
return undefined;
}
const sortedRecipients = allRecipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
return (
<div
className={cn(
@@ -225,19 +204,12 @@ export const DocumentSigningForm = ({
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
disabled={!isRecipientsTurn}
/>
</div>
</div>
@@ -316,8 +288,9 @@ export const DocumentSigningForm = ({
className="w-full"
size="lg"
loading={isAssistantSubmitting}
disabled={isAssistantSubmitting || uninsertedRecipientFields.length > 0}
>
<Trans>Continue</Trans>
{isAssistantSubmitting ? <Trans>Submitting...</Trans> : <Trans>Continue</Trans>}
</Button>
</div>
@@ -327,20 +300,12 @@ export const DocumentSigningForm = ({
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</form>
</>
) : (
<>
<div>
<form onSubmit={handleSubmit(onFormSubmit)}>
<p className="text-muted-foreground mt-2 text-sm">
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
<Trans>Please review the document before approving.</Trans>
@@ -388,41 +353,31 @@ export const DocumentSigningForm = ({
</div>
)}
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
disabled={!isRecipientsTurn}
/>
</div>
</fieldset>
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting || isAssistantSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</div>
</div>
</form>
</>
)}
</div>
@@ -1,12 +1,12 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZInitialsFieldMeta } from '@documenso/lib/types/field-meta';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
@@ -17,11 +17,6 @@ import type {
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
@@ -55,9 +50,6 @@ export const DocumentSigningInitialsField = ({
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
const safeFieldMeta = ZInitialsFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
const value = initials ?? '';
@@ -130,18 +122,22 @@ export const DocumentSigningInitialsField = ({
onRemove={onRemove}
type="Initials"
>
{isLoading && <DocumentSigningFieldsLoader />}
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<DocumentSigningFieldsUninserted>
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<Trans>Initials</Trans>
</DocumentSigningFieldsUninserted>
</p>
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</DocumentSigningFieldsInserted>
</p>
)}
</DocumentSigningFieldContainer>
);
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@@ -15,6 +16,7 @@ import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
@@ -23,11 +25,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
@@ -169,18 +166,34 @@ export const DocumentSigningNameField = ({
onRemove={onRemove}
type="Name"
>
{isLoading && <DocumentSigningFieldsLoader />}
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<DocumentSigningFieldsUninserted>
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
<Trans>Name</Trans>
</DocumentSigningFieldsUninserted>
</p>
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText}
</DocumentSigningFieldsInserted>
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)}
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
@@ -189,7 +202,7 @@ export const DocumentSigningNameField = ({
<Trans>
Sign as
<div>
{recipient.name} <div className="text-foreground">({recipient.email})</div>
{recipient.name} <div className="text-muted-foreground">({recipient.email})</div>
</div>
</Trans>
</DialogTitle>
@@ -211,7 +224,7 @@ export const DocumentSigningNameField = ({
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="flex-1"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowFullNameModal(false);
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Hash, Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
@@ -24,11 +25,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
type ValidationErrors = {
@@ -249,16 +245,45 @@ export const DocumentSigningNumberField = ({
onRemove={onRemove}
type="Number"
>
{isLoading && <DocumentSigningFieldsLoader />}
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<DocumentSigningFieldsUninserted>{fieldDisplayName}</DocumentSigningFieldsUninserted>
<p
className={cn(
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
{
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
},
)}
>
<span className="flex items-center justify-center gap-x-1">
<Hash className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />{' '}
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{fieldDisplayName}</span>
</span>
</p>
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText}
</DocumentSigningFieldsInserted>
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)}
<Dialog open={showNumberModal} onOpenChange={setShowNumberModal}>
@@ -314,7 +339,7 @@ export const DocumentSigningNumberField = ({
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="flex-1"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowNumberModal(false);
@@ -19,7 +19,6 @@ import {
import type { CompletedField } from '@documenso/lib/types/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
@@ -37,12 +36,13 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
export type DocumentSigningPageViewProps = {
recipient: RecipientWithFields;
export type SigningPageViewProps = {
document: DocumentAndSender;
recipient: RecipientWithFields;
fields: Field[];
completedFields: CompletedField[];
isRecipientsTurn: boolean;
@@ -50,13 +50,13 @@ export type DocumentSigningPageViewProps = {
};
export const DocumentSigningPageView = ({
recipient,
document,
recipient,
fields,
completedFields,
isRecipientsTurn,
allRecipients = [],
}: DocumentSigningPageViewProps) => {
}: SigningPageViewProps) => {
const { documentData, documentMeta } = document;
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
@@ -157,11 +157,7 @@ export const DocumentSigningPageView = ({
</div>
</div>
<DocumentReadOnlyFields
documentMeta={documentMeta || undefined}
fields={completedFields}
showRecipientTooltip={true}
/>
<DocumentReadOnlyFields fields={completedFields} />
{recipient.role !== RecipientRole.ASSISTANT && (
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@@ -20,7 +21,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { DocumentSigningFieldsLoader } from './document-signing-fields';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningRadioFieldProps = {
@@ -150,52 +150,44 @@ export const DocumentSigningRadioField = ({
return (
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Radio">
{isLoading && <DocumentSigningFieldsLoader />}
{isLoading && (
<div className="bg-background absolute inset-0 z-20 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<RadioGroup
onValueChange={(value) => handleSelectItem(value)}
className="z-10 my-0.5 gap-y-1"
>
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10">
{values?.map((item, index) => (
<div key={index} className="flex items-center">
<div key={index} className="flex items-center gap-x-1.5">
<RadioGroupItem
className="h-3 w-3 shrink-0"
className="h-4 w-4 shrink-0"
value={item.value}
id={`option-${field.id}-${item.id}`}
id={`option-${index}`}
checked={item.checked}
/>
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`option-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
<Label htmlFor={`option-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
))}
</RadioGroup>
)}
{field.inserted && (
<RadioGroup className="my-0.5 gap-y-1">
<RadioGroup className="gap-y-1">
{values?.map((item, index) => (
<div key={index} className="flex items-center">
<div key={index} className="flex items-center gap-x-1.5">
<RadioGroupItem
className="h-3 w-3"
value={item.value}
id={`option-${field.id}-${item.id}`}
id={`option-${index}`}
checked={item.value === field.customText}
/>
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`option-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
<Label htmlFor={`option-${index}`} className="text-xs">
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
))}
</RadioGroup>
@@ -31,7 +31,10 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZRejectDocumentFormSchema = z.object({
reason: z.string().max(500, msg`Reason must be less than 500 characters`),
reason: z
.string()
.min(5, msg`Please provide a reason`)
.max(500, msg`Reason must be less than 500 characters`),
});
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
@@ -242,7 +242,7 @@ export const DocumentSigningSignatureField = ({
)}
{state === 'empty' && (
<p className="group-hover:text-primary font-signature text-muted-foreground group-hover:text-recipient-green text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200">
<p className="group-hover:text-primary font-signature text-muted-foreground text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200 group-hover:text-yellow-300">
<Trans>Signature</Trans>
</p>
)}
@@ -259,7 +259,7 @@ export const DocumentSigningSignatureField = ({
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
<p
ref={signatureRef}
className="font-signature text-muted-foreground w-full overflow-hidden break-all text-center leading-tight duration-200"
className="font-signature text-muted-foreground dark:text-background w-full overflow-hidden break-all text-center leading-tight duration-200"
style={{ fontSize: `${fontSize}rem` }}
>
{signature?.typedSignature}
@@ -291,7 +291,7 @@ export const DocumentSigningSignatureField = ({
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="flex-1"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowSignatureModal(false);
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { Loader, Type } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
@@ -24,11 +25,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningTextFieldProps = {
@@ -252,20 +248,49 @@ export const DocumentSigningTextField = ({
onRemove={onRemove}
type="Text"
>
{isLoading && <DocumentSigningFieldsLoader />}
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<DocumentSigningFieldsUninserted>
{fieldDisplayName || <Trans>Text</Trans>}
</DocumentSigningFieldsUninserted>
<p
className={cn(
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
{
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
},
)}
>
<span className="flex items-center justify-center gap-x-1">
<Type className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
{fieldDisplayName || <Trans>Text</Trans>}
</span>
</span>
</p>
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 20) + '...'}
</DocumentSigningFieldsInserted>
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 20) + '...'}
</p>
</div>
)}
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
@@ -279,9 +304,11 @@ export const DocumentSigningTextField = ({
id="custom-text"
placeholder={parsedFieldMeta?.placeholder ?? _(msg`Enter your text here`)}
className={cn('mt-2 w-full rounded-md', {
'border-2 border-red-300 text-left ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
userInputHasErrors,
'text-center': parsedFieldMeta?.textAlign === 'center',
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
})}
value={localText}
@@ -327,8 +354,8 @@ export const DocumentSigningTextField = ({
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
className="flex-1"
onClick={() => {
setShowCustomTextModal(false);
setLocalCustomText('');
@@ -217,13 +217,6 @@ export const DocumentEditForm = ({
signingOrder: data.signingOrder,
}),
updateDocument({
documentId: document.id,
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
},
}),
setRecipients({
documentId: document.id,
recipients: data.signers.map((signer) => ({
@@ -368,7 +361,6 @@ export const DocumentEditForm = ({
documentFlow={documentFlow.signers}
recipients={recipients}
signingOrder={document.documentMeta?.signingOrder}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
fields={fields}
isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit}
@@ -0,0 +1,171 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta } from '@prisma/client';
import { FieldType, SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
import { P, match } from 'ts-pattern';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PopoverHover } from '@documenso/ui/primitives/popover';
export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[];
documentMeta?: DocumentMeta;
showFieldStatus?: boolean;
};
export const DocumentReadOnlyFields = ({
documentMeta,
fields,
showFieldStatus = true,
}: DocumentReadOnlyFieldsProps) => {
const { _ } = useLingui();
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
const handleHideField = (fieldId: string) => {
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
return (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
<FieldRootContainer
field={field}
key={field.id}
cardClassName="border-gray-300/50 !shadow-none backdrop-blur-[1px] bg-gray-50 ring-0 ring-offset-0"
>
<div className="absolute -right-3 -top-3">
<PopoverHover
trigger={
<Avatar className="dark:border-foreground h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
{extractInitials(field.recipient.name || field.recipient.email)}
</AvatarFallback>
</Avatar>
}
contentProps={{
className: 'relative flex w-fit flex-col p-4 text-sm',
}}
>
{showFieldStatus && (
<Badge
className="mx-auto mb-1 py-0.5"
variant={
field.recipient.signingStatus === SigningStatus.SIGNED
? 'default'
: 'secondary'
}
>
{field.recipient.signingStatus === SigningStatus.SIGNED ? (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
) : (
<>
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</>
)}
</Badge>
)}
<p className="text-center font-semibold">
<span>{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])} field</span>
</p>
<p className="text-muted-foreground mt-1 text-center text-xs">
{field.recipient.name
? `${field.recipient.name} (${field.recipient.email})`
: field.recipient.email}{' '}
</p>
<button
className="absolute right-0 top-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
onClick={() => handleHideField(field.secondaryId)}
title="Hide field"
>
<EyeOffIcon className="h-3 w-3" />
</button>
</PopoverHover>
</div>
<div className="text-muted-foreground dark:text-background/70 break-all text-sm">
{field.recipient.signingStatus === SigningStatus.SIGNED &&
match(field)
.with({ type: FieldType.SIGNATURE }, (field) =>
field.signature?.signatureImageAsBase64 ? (
<img
src={field.signature.signatureImageAsBase64}
alt="Signature"
className="h-full w-full object-contain dark:invert"
/>
) : (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl">
{field.signature?.typedSignature}
</p>
),
)
.with(
{
type: P.union(
FieldType.NAME,
FieldType.INITIALS,
FieldType.EMAIL,
FieldType.NUMBER,
FieldType.RADIO,
FieldType.CHECKBOX,
FieldType.DROPDOWN,
),
},
() => field.customText,
)
.with({ type: FieldType.TEXT }, () => field.customText.substring(0, 20) + '...')
.with({ type: FieldType.DATE }, () =>
convertToLocalSystemFormat(
field.customText,
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
),
)
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
.exhaustive()}
{field.recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<p
className={cn('text-muted-foreground text-lg duration-200', {
'font-signature sm:text-xl md:text-2xl':
field.type === FieldType.SIGNATURE ||
field.type === FieldType.FREE_SIGNATURE,
})}
>
{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])}
</p>
)}
</div>
</FieldRootContainer>
),
)}
</ElementVisible>
);
};
@@ -1,112 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertCircle } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type LegacyFieldWarningPopoverProps = {
type?: 'document' | 'template';
documentId?: number;
templateId?: number;
};
export const LegacyFieldWarningPopover = ({
type = 'document',
documentId,
templateId,
}: LegacyFieldWarningPopoverProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const revalidator = useRevalidator();
const { mutateAsync: updateTemplate, isPending: isUpdatingTemplate } =
trpc.template.updateTemplate.useMutation();
const { mutateAsync: updateDocument, isPending: isUpdatingDocument } =
trpc.document.updateDocument.useMutation();
const onUpdateFieldsClick = async () => {
if (type === 'document') {
if (!documentId) {
return;
}
await updateDocument({
documentId,
data: {
useLegacyFieldInsertion: false,
},
});
}
if (type === 'template') {
if (!templateId) {
return;
}
await updateTemplate({
templateId,
data: {
useLegacyFieldInsertion: false,
},
});
}
void revalidator.revalidate();
toast({
title: _(msg`Fields updated`),
description: _(
msg`The fields have been updated to the new field insertion method successfully`,
),
});
};
return (
<PopoverHover
side="bottom"
trigger={
<Button variant="outline" className="h-9 w-9 p-0">
<span className="sr-only">
{type === 'document' ? (
<Trans>Document is using legacy field insertion</Trans>
) : (
<Trans>Template is using legacy field insertion</Trans>
)}
</span>
<AlertCircle className="h-5 w-5" />
</Button>
}
>
<p className="text-muted-foreground text-sm">
{type === 'document' ? (
<Trans>
This document is using legacy field insertion, we recommend using the new field
insertion method for more accurate results.
</Trans>
) : (
<Trans>
This template is using legacy field insertion, we recommend using the new field
insertion method for more accurate results.
</Trans>
)}
</p>
<div className="mt-2 flex w-full items-center justify-end">
<Button
type="button"
size="sm"
loading={isUpdatingDocument || isUpdatingTemplate}
onClick={onUpdateFieldsClick}
>
<Trans>Update Fields</Trans>
</Button>
</div>
</PopoverHover>
);
};
@@ -167,7 +167,6 @@ export const TemplateEditForm = ({
templateId: template.id,
meta: {
signingOrder: data.signingOrder,
allowDictateNextSigner: data.allowDictateNextSigner,
},
}),
@@ -271,7 +270,6 @@ export const TemplateEditForm = ({
recipients={recipients}
fields={fields}
signingOrder={template.templateMeta?.signingOrder}
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit}
isEnterprise={isEnterprise}
@@ -1,96 +1,43 @@
import { useEffect, useState } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { WebhookTriggerEvents } from '@prisma/client';
import { Check, ChevronsUpDown } from 'lucide-react';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { truncateTitle } from '~/utils/truncate-title';
import { MultipleSelector } from '@documenso/ui/primitives/multiselect';
type WebhookMultiSelectComboboxProps = {
listValues: string[];
onChange: (_values: string[]) => void;
};
const triggerEvents = Object.values(WebhookTriggerEvents).map((value) => ({
value,
label: toFriendlyWebhookEventName(value),
}));
export const WebhookMultiSelectCombobox = ({
listValues,
onChange,
}: WebhookMultiSelectComboboxProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const triggerEvents = Object.values(WebhookTriggerEvents);
useEffect(() => {
setSelectedValues(listValues);
}, [listValues]);
const allEvents = [...new Set([...triggerEvents, ...selectedValues])];
const handleSelect = (currentValue: string) => {
let newSelectedValues;
if (selectedValues.includes(currentValue)) {
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
} else {
newSelectedValues = [...selectedValues, currentValue];
}
setSelectedValues(newSelectedValues);
onChange(newSelectedValues);
setIsOpen(false);
const handleOnChange = (options: { value: string; label: string }[]) => {
onChange(options.map((option) => option.value));
};
const mappedValues = listValues.map((value) => ({
value,
label: toFriendlyWebhookEventName(value),
}));
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isOpen}
className="w-[200px] justify-between"
>
<Plural value={selectedValues.length} zero="Select values" other="# selected..." />
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="z-9999 w-full max-w-[280px] p-0">
<Command>
<CommandInput
placeholder={truncateTitle(
selectedValues.map((v) => toFriendlyWebhookEventName(v)).join(', '),
15,
)}
/>
<CommandEmpty>
<Trans>No value found.</Trans>
</CommandEmpty>
<CommandGroup>
{allEvents.map((value: string, i: number) => (
<CommandItem key={i} onSelect={() => handleSelect(value)}>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
)}
/>
{toFriendlyWebhookEventName(value)}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<MultipleSelector
commandProps={{
label: 'Select triggers',
}}
defaultOptions={triggerEvents}
value={mappedValues}
onChange={handleOnChange}
placeholder="Select triggers"
hideClearAllButton
hidePlaceholderWhenSelected
emptyIndicator={<p className="text-center text-sm">No triggers available</p>}
/>
);
};
@@ -23,12 +23,12 @@ import {
getUsersWithSubscriptionsCount,
} from '@documenso/lib/server-only/admin/get-users-stats';
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
import { env } from '@documenso/lib/utils/env';
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents';
import { CardMetric } from '~/components/general/metric-card';
import { version } from '../../../../package.json';
import type { Route } from './+types/stats';
export async function loader() {
@@ -89,7 +89,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
value={usersWithSubscriptionsCount}
/>
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${version}`} />
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${env('APP_VERSION')}`} />
</div>
<div className="mt-16 gap-8">
@@ -13,7 +13,6 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -25,6 +24,7 @@ import { DocumentPageViewDropdown } from '~/components/general/document/document
import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information';
import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity';
import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients';
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import {
DocumentStatus as DocumentStatusComponent,
@@ -200,14 +200,8 @@ export default function DocumentPage() {
</CardContent>
</Card>
{document.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields
fields={fields}
documentMeta={documentMeta || undefined}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
/>
{document.status === DocumentStatus.PENDING && (
<DocumentReadOnlyFields fields={fields} documentMeta={documentMeta || undefined} />
)}
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
@@ -14,7 +14,6 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
import { DocumentStatus } from '~/components/general/document/document-status';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
@@ -101,43 +100,29 @@ export default function DocumentEditPage() {
<Trans>Documents</Trans>
</Link>
<div className="mt-4 flex w-full items-end justify-between">
<div className="flex-1">
<h1
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus
inheritColor
status={document.status}
className="text-muted-foreground"
/>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
</div>
{document.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="document" documentId={document.id} />
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
@@ -7,7 +7,6 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
@@ -15,6 +14,7 @@ import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplatePageViewDocumentsTable } from '~/components/general/template/template-page-view-documents-table';
import { TemplatePageViewInformation } from '~/components/general/template/template-page-view-information';
@@ -151,9 +151,6 @@ export default function TemplatePage() {
<DocumentReadOnlyFields
fields={readOnlyFields}
showFieldStatus={false}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
documentMeta={mockedDocumentMeta}
/>
@@ -8,7 +8,6 @@ import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
import { TemplateType } from '~/components/general/template/template-type';
@@ -92,14 +91,8 @@ export default function TemplateEditPage() {
</div>
</div>
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
<div className="mt-2 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
{template.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="template" templateId={template.id} />
</div>
)}
</div>
</div>
@@ -6,7 +6,6 @@ import { redirect } from 'react-router';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
import {
RECIPIENT_ROLES_DESCRIPTION,
@@ -60,8 +59,6 @@ export async function loader({ request }: Route.LoaderArgs) {
throw redirect('/');
}
const isPlatformDocument = await isDocumentPlatform(document);
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
const auditLogs = await getDocumentCertificateAuditLogs({
@@ -73,7 +70,6 @@ export async function loader({ request }: Route.LoaderArgs) {
return {
document,
documentLanguage,
isPlatformDocument,
auditLogs,
messages,
};
@@ -89,7 +85,7 @@ export async function loader({ request }: Route.LoaderArgs) {
* Update: Maybe <Trans> tags work now after RR7 migration.
*/
export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
const { document, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
const { document, documentLanguage, auditLogs, messages } = loaderData;
const { i18n, _ } = useLingui();
@@ -249,24 +245,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">
@@ -341,17 +337,15 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</CardContent>
</Card>
{isPlatformDocument && (
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
{_(msg`Signing certificate provided by`)}:
</p>
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
{_(msg`Signing certificate provided by`)}:
</p>
<BrandingLogo className="max-h-6 print:max-h-4" />
</div>
<BrandingLogo className="max-h-6 print:max-h-4" />
</div>
)}
</div>
</div>
);
}
@@ -1,5 +1,5 @@
import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { Clock8 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
@@ -13,7 +13,6 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum
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 { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
@@ -73,24 +72,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
? await getRecipientsForAssistant({
token,
})
: [recipient];
if (
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
recipient.role !== RecipientRole.ASSISTANT
) {
const nextPendingRecipient = await getNextPendingRecipient({
documentId: document.id,
currentRecipientId: recipient.id,
});
if (nextPendingRecipient) {
allRecipients.push({
...nextPendingRecipient,
fields: [],
});
}
}
: [];
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
@@ -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;
@@ -1,6 +1,6 @@
import sharp from 'sharp';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { prisma } from '@documenso/prisma';
import type { Route } from './+types/branding.logo.team.$teamId';
@@ -24,14 +24,7 @@ export async function loader({ params }: Route.LoaderArgs) {
},
});
console.log('########################');
console.log('########################');
console.log('########################');
console.log('########################');
console.log('########################');
if (!settings || !settings.brandingEnabled) {
console.log('----------------------------');
return Response.json(
{
status: 'error',
@@ -42,7 +35,6 @@ export async function loader({ params }: Route.LoaderArgs) {
}
if (!settings.brandingLogo) {
console.log('!!!!!!!!!!!!!!!!!!!!!!!!');
return Response.json(
{
status: 'error',
@@ -52,10 +44,7 @@ export async function loader({ params }: Route.LoaderArgs) {
);
}
const file = await getFileServerSide(JSON.parse(settings.brandingLogo)).catch((e) => {
console.log('@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@');
console.error(e);
});
const file = await getFile(JSON.parse(settings.brandingLogo)).catch(() => null);
if (!file) {
return Response.json(
@@ -8,7 +8,6 @@ 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';
@@ -34,7 +33,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
const [document, fields, recipient, completedFields] = await Promise.all([
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
userId: user?.id,
@@ -42,7 +41,6 @@ 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
@@ -132,7 +130,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
allRecipients,
recipient,
fields,
completedFields,
hidePoweredBy,
isPlatformDocument,
isEnterpriseDocument,
@@ -148,7 +145,6 @@ export default function EmbedSignDocumentPage() {
allRecipients,
recipient,
fields,
completedFields,
hidePoweredBy,
isPlatformDocument,
isEnterpriseDocument,
@@ -175,7 +171,6 @@ export default function EmbedSignDocumentPage() {
documentData={document.documentData}
recipient={recipient}
fields={fields}
completedFields={completedFields}
metadata={document.documentMeta}
isCompleted={isDocumentCompleted(document.status)}
hidePoweredBy={
@@ -1,66 +0,0 @@
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>
);
}
@@ -1,53 +0,0 @@
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>
);
}
@@ -1,184 +0,0 @@
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>
);
}
@@ -1,175 +0,0 @@
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>
);
}
@@ -1,23 +0,0 @@
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,5 +14,4 @@ export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
.transform((value) => value || undefined),
lockName: z.boolean().optional().default(false),
allowDocumentRejection: z.boolean().optional(),
showOtherRecipientsCompletedFields: z.boolean().optional(),
});
+2 -3
View File
@@ -99,6 +99,5 @@
"vite": "^6.1.0",
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.10.0-rc.5"
}
}
}
+3 -10
View File
@@ -2,7 +2,6 @@ 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';
@@ -45,15 +44,9 @@ export default defineConfig({
resolve: {
alias: {
https: 'node:https',
'.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'),
'.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',
},
},
/**
+1 -1
View File
@@ -33,7 +33,7 @@ FROM base AS installer
RUN apk add --no-cache libc6-compat
RUN apk add --no-cache jq
# Required for node_modules/aws-crt
RUN apk add --no-cache make cmake g++ openssl bash
RUN apk add --no-cache make cmake g++ openssl
WORKDIR /app
+6 -34
View File
@@ -8,7 +8,6 @@ command -v docker >/dev/null 2>&1 || {
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
# Get Git information
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
@@ -16,39 +15,12 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA"
# Build with temporary base tag
docker build -f "$SCRIPT_DIR/Dockerfile" \
--progress=plain \
-t "documenso-base" \
-t "documenso/documenso:latest" \
-t "documenso/documenso:$GIT_SHA" \
-t "documenso/documenso:$APP_VERSION" \
-t "ghcr.io/documenso/documenso:latest" \
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
"$MONOREPO_ROOT"
# Handle repository tagging
if [ ! -z "$DOCKER_REPOSITORY" ]; then
echo "Using custom repository: $DOCKER_REPOSITORY"
# Add tags for custom repository
docker tag "documenso-base" "$DOCKER_REPOSITORY:latest"
docker tag "documenso-base" "$DOCKER_REPOSITORY:$GIT_SHA"
# Add version tag if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "$DOCKER_REPOSITORY:$APP_VERSION"
fi
else
echo "Using default repositories: dockerhub and ghcr.io"
# Add tags for both default repositories
docker tag "documenso-base" "documenso/documenso:latest"
docker tag "documenso-base" "documenso/documenso:$GIT_SHA"
docker tag "documenso-base" "ghcr.io/documenso/documenso:latest"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$GIT_SHA"
# Add version tags if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "documenso/documenso:$APP_VERSION"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$APP_VERSION"
fi
fi
# Remove the temporary base tag
docker rmi "documenso-base"
+7 -34
View File
@@ -9,11 +9,11 @@ SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
# Get the platform from environment variable or set to linux/amd64 if not set
# quote the string to prevent word splitting
if [ -z "$PLATFORM" ]; then
PLATFORM="linux/amd64"
fi
# Get Git information
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
@@ -21,41 +21,14 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA"
# Build with temporary base tag
docker buildx build \
-f "$SCRIPT_DIR/Dockerfile" \
--platform=$PLATFORM \
--progress=plain \
-t "documenso-base" \
-t "documenso/documenso:latest" \
-t "documenso/documenso:$GIT_SHA" \
-t "documenso/documenso:$APP_VERSION" \
-t "ghcr.io/documenso/documenso:latest" \
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
"$MONOREPO_ROOT"
# Handle repository tagging
if [ ! -z "$DOCKER_REPOSITORY" ]; then
echo "Using custom repository: $DOCKER_REPOSITORY"
# Add tags for custom repository
docker tag "documenso-base" "$DOCKER_REPOSITORY:latest"
docker tag "documenso-base" "$DOCKER_REPOSITORY:$GIT_SHA"
# Add version tag if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "$DOCKER_REPOSITORY:$APP_VERSION"
fi
else
echo "Using default repositories: dockerhub and ghcr.io"
# Add tags for both default repositories
docker tag "documenso-base" "documenso/documenso:latest"
docker tag "documenso-base" "documenso/documenso:$GIT_SHA"
docker tag "documenso-base" "ghcr.io/documenso/documenso:latest"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$GIT_SHA"
# Add version tags if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "documenso/documenso:$APP_VERSION"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$APP_VERSION"
fi
fi
# Remove the temporary base tag
docker rmi "documenso-base"
+2 -13
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.10.0-rc.5",
"version": "1.9.0-rc.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.10.0-rc.5",
"version": "1.9.0-rc.11",
"workspaces": [
"apps/*",
"packages/*"
@@ -95,7 +95,6 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.10.0-rc.5",
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",
@@ -41560,7 +41559,6 @@
"@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",
@@ -41583,15 +41581,6 @@
"@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",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.10.0-rc.5",
"version": "1.9.0-rc.11",
"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 build --filter=@documenso/remix && turbo run test:e2e",
"ci": "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",
-1
View File
@@ -323,7 +323,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
dateFormat: dateFormat?.value,
redirectUrl: body.meta.redirectUrl,
signingOrder: body.meta.signingOrder,
allowDictateNextSigner: body.meta.allowDictateNextSigner,
language: body.meta.language,
typedSignatureEnabled: body.meta.typedSignatureEnabled,
uploadSignatureEnabled: body.meta.uploadSignatureEnabled,
-3
View File
@@ -155,7 +155,6 @@ export const ZCreateDocumentMutationSchema = z.object({
}),
redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
typedSignatureEnabled: z.boolean().optional().default(true),
uploadSignatureEnabled: z.boolean().optional().default(true),
@@ -221,7 +220,6 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
dateFormat: z.string(),
redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
})
.partial()
@@ -289,7 +287,6 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
dateFormat: z.string(),
redirectUrl: ZUrlSchema,
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean(),
language: z.enum(SUPPORTED_LANGUAGE_CODES),
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
typedSignatureEnabled: z.boolean(),
@@ -1,203 +0,0 @@
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.skip('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,6 +7,8 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page }) => {
const user = await seedUser();
@@ -210,7 +210,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
}),
},
],
fields: [FieldType.DATE, FieldType.SIGNATURE],
fields: [FieldType.DATE],
});
for (const recipient of recipients) {
@@ -309,7 +309,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
}),
},
],
fields: [FieldType.DATE, FieldType.SIGNATURE],
fields: [FieldType.DATE],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
@@ -1,393 +0,0 @@
import { expect, test } from '@playwright/test';
import {
DocumentSigningOrder,
DocumentStatus,
FieldType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { signDirectSignaturePad, signSignaturePad } from '../fixtures/signature';
test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dictation is enabled', async ({
page,
}) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const thirdSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [firstSigner, secondSigner, thirdSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 3 }],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
update: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
},
},
},
});
const firstRecipient = recipients[0];
const { token, fields } = firstRecipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete signing and update next recipient
await page.getByRole('button', { name: 'Complete' }).click();
// Verify next recipient info is shown
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
// Update next recipient
await page.locator('button').filter({ hasText: 'Update Recipient' }).click();
await page.waitForTimeout(1000);
// Use dialog context to ensure we're targeting the correct form fields
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Name').fill('New Recipient');
await dialog.getByLabel('Email').fill('new.recipient@example.com');
// Submit and verify completion
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
// Verify document and recipient states
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should still be pending as there are more recipients
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
// First recipient should be completed
const updatedFirstRecipient = updatedDocument.recipients[0];
expect(updatedFirstRecipient.signingStatus).toBe(SigningStatus.SIGNED);
// Second recipient should be the new recipient
const updatedSecondRecipient = updatedDocument.recipients[1];
expect(updatedSecondRecipient.name).toBe('New Recipient');
expect(updatedSecondRecipient.email).toBe('new.recipient@example.com');
expect(updatedSecondRecipient.signingOrder).toBe(2);
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
});
test('[NEXT_RECIPIENT_DICTATION]: should not show dictation UI when disabled', async ({ page }) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [firstSigner, secondSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
update: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
},
},
},
});
const firstRecipient = recipients[0];
const { token, fields } = firstRecipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete signing
await page.getByRole('button', { name: 'Complete' }).click();
// Verify next recipient UI is not shown
await expect(
page.getByText('The next recipient to sign this document will be'),
).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
// Submit and verify completion
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
// Verify document and recipient states
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should still be pending as there are more recipients
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
// First recipient should be completed
const updatedFirstRecipient = updatedDocument.recipients[0];
expect(updatedFirstRecipient.signingStatus).toBe(SigningStatus.SIGNED);
// Second recipient should remain unchanged
const updatedSecondRecipient = updatedDocument.recipients[1];
expect(updatedSecondRecipient.email).toBe(secondSigner.email);
expect(updatedSecondRecipient.signingOrder).toBe(2);
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
});
test('[NEXT_RECIPIENT_DICTATION]: should work with parallel signing flow', async ({ page }) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [firstSigner, secondSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.PARALLEL,
},
update: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.PARALLEL,
},
},
},
},
});
// Test both recipients can sign in parallel
for (const recipient of recipients) {
const { token, fields } = recipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete signing
await page.getByRole('button', { name: 'Complete' }).click();
// Verify next recipient UI is not shown in parallel flow
await expect(
page.getByText('The next recipient to sign this document will be'),
).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
// Submit and verify completion
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
}
// Verify final document and recipient states
await expect(async () => {
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should be completed since all recipients have signed
expect(updatedDocument.status).toBe(DocumentStatus.COMPLETED);
// All recipients should be completed
for (const recipient of updatedDocument.recipients) {
expect(recipient.signingStatus).toBe(SigningStatus.SIGNED);
}
}).toPass();
});
test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer', async ({
page,
}) => {
const user = await seedUser();
const assistant = await seedUser();
const signer = await seedUser();
const thirdSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [assistant, signer, thirdSigner],
recipientsCreateOptions: [
{ signingOrder: 1, role: RecipientRole.ASSISTANT },
{ signingOrder: 2, role: RecipientRole.SIGNER },
{ signingOrder: 3, role: RecipientRole.SIGNER },
],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
update: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
},
},
},
});
const assistantRecipient = recipients[0];
const { token, fields } = assistantRecipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Assist Document' })).toBeVisible();
await page.waitForTimeout(1000);
await page.getByRole('radio', { name: assistantRecipient.name }).click();
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.SIGNATURE) {
await signDirectSignaturePad(page);
await page.getByRole('button', { name: 'Sign', exact: true }).click();
}
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete assisting and update next recipient
await page.getByRole('button', { name: 'Continue' }).click();
// Verify next recipient info is shown
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
// Update next recipient
await page.locator('button').filter({ hasText: 'Update Recipient' }).click();
await page.waitForTimeout(1000);
// Use dialog context to ensure we're targeting the correct form fields
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Name').fill('New Recipient');
await dialog.getByLabel('Email').fill('new.recipient@example.com');
// Submit and verify completion
await page.getByRole('button', { name: /Continue|Proceed/i }).click();
await page.waitForURL(`${signUrl}/complete`);
// Verify document and recipient states
await expect(async () => {
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should still be pending as there are more recipients
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
// Assistant should be completed
const updatedAssistant = updatedDocument.recipients[0];
expect(updatedAssistant.signingStatus).toBe(SigningStatus.SIGNED);
expect(updatedAssistant.role).toBe(RecipientRole.ASSISTANT);
// Second recipient should be the new signer
const updatedSigner = updatedDocument.recipients[1];
expect(updatedSigner.name).toBe('New Recipient');
expect(updatedSigner.email).toBe('new.recipient@example.com');
expect(updatedSigner.signingOrder).toBe(2);
expect(updatedSigner.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(updatedSigner.role).toBe(RecipientRole.SIGNER);
// Third recipient should remain unchanged
const thirdRecipient = updatedDocument.recipients[2];
expect(thirdRecipient.email).toBe(thirdSigner.email);
expect(thirdRecipient.signingOrder).toBe(3);
expect(thirdRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(thirdRecipient.role).toBe(RecipientRole.SIGNER);
}).toPass();
});
@@ -11,8 +11,9 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
// eslint-disable-next-line turbo/no-undeclared-env-vars
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
@@ -6,8 +6,9 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
// eslint-disable-next-line turbo/no-undeclared-env-vars
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
@@ -57,8 +57,6 @@ test.describe('Signing Certificate Tests', () => {
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass();
await page.waitForTimeout(2500);
// Get the completed document
const completedDocument = await prisma.document.findFirstOrThrow({
where: { id: document.id },
@@ -129,8 +127,6 @@ test.describe('Signing Certificate Tests', () => {
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass();
await page.waitForTimeout(2500);
// Get the completed document
const completedDocument = await prisma.document.findFirstOrThrow({
where: { id: document.id },
@@ -201,8 +197,6 @@ test.describe('Signing Certificate Tests', () => {
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass();
await page.waitForTimeout(2500);
// Get the completed document
const completedDocument = await prisma.document.findFirstOrThrow({
where: { id: document.id },
@@ -1,8 +1,5 @@
import type { Page } from '@playwright/test';
/**
* Will open the signature pad dialog and sign it.
*/
export const signSignaturePad = async (page: Page) => {
await page.waitForTimeout(200);
@@ -15,14 +12,3 @@ export const signSignaturePad = async (page: Page) => {
// Click Next button
await page.getByRole('button', { name: 'Next' }).click();
};
/**
* For when the signature pad is already open.
*/
export const signDirectSignaturePad = async (page: Page) => {
await page.waitForTimeout(200);
// Click type tab
await page.getByRole('tab', { name: 'Type' }).click();
await page.getByTestId('signature-pad-type-input').fill('Signature');
};
@@ -7,6 +7,8 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[PUBLIC_PROFILE]: create profile', async ({ page }) => {
const user = await seedUser();
@@ -54,7 +56,6 @@ test('[PUBLIC_PROFILE]: create profile', async ({ page }) => {
// Go back to public profile page.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/public-profile`);
await page.getByRole('switch').click();
await page.waitForTimeout(1000);
// Assert values.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
@@ -126,7 +127,6 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
// Go back to public profile page.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`);
await page.getByRole('switch').click();
await page.waitForTimeout(1000);
// Assert values.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
@@ -6,6 +6,8 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: create team', async ({ page }) => {
const user = await seedUser();
@@ -9,6 +9,8 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: check team documents count', async ({ page }) => {
const { team, teamMember2 } = await seedTeamDocuments();
@@ -6,6 +6,8 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: send team email request', async ({ page }) => {
const team = await seedTeam();
@@ -4,6 +4,8 @@ import { seedTeam } from '@documenso/prisma/seed/teams';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: update the default document visibility in the team global settings', async ({
page,
}) => {
@@ -6,6 +6,8 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: update team member role', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
@@ -9,6 +9,8 @@ import {
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: check that default team signature settings are all enabled', async ({ page }) => {
const { team } = await seedTeamDocuments();
@@ -146,11 +148,6 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
await page.getByRole('button', { name: 'Update' }).first().click();
// Wait for finish
const toast = page.locator('li[role="status"][data-state="open"]').first();
await expect(toast).toBeVisible();
await expect(toast.getByText('Document preferences updated', { exact: true })).toBeVisible();
const template = await seedTeamTemplateWithMeta(team);
await page.goto(`/t/${team.url}/templates/${template.id}`);
@@ -5,6 +5,8 @@ import { seedTeam, seedTeamTransfer } from '@documenso/prisma/seed/teams';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
@@ -9,6 +9,8 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = '';
@@ -6,8 +6,9 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
// eslint-disable-next-line turbo/no-undeclared-env-vars
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {

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