Compare commits
24 Commits
v1.9.1-rc.
...
feat/allow
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e9c7f1b11 | ||
|
|
bc2ec9a2d7 | ||
|
|
763b7f82c9 | ||
|
|
c670f64b1f | ||
|
|
369e16afab | ||
|
|
4a5f565591 | ||
|
|
f544eae2a6 | ||
|
|
a2ffd75c17 | ||
|
|
8619eec67a | ||
|
|
f325a04cb5 | ||
|
|
6a47b3a6e5 | ||
|
|
a7adb77e47 | ||
|
|
bfcbaea3a9 | ||
|
|
64964f420a | ||
|
|
2896673a23 | ||
|
|
b684b9574d | ||
|
|
12803d1a5e | ||
|
|
c41002313a | ||
|
|
516435fa2a | ||
|
|
0216af4ae8 | ||
|
|
3cde3cb7b2 | ||
|
|
071f5c546d | ||
|
|
9f9f6701c8 | ||
|
|
b01eaceeb8 |
@@ -1,4 +1,4 @@
|
||||
> 🚨 We are live on Product Hunt 🎉 Check out our latest launch: <a href="https://documen.so/sign-everywhere">The Platform Plan</a>!
|
||||
> 🚨 We are live on Product Hunt 🎉 Check out our latest launch: <a href="documen.so/sign-everywhere">The Platform Plan</a>!
|
||||
|
||||
<a href="https://www.producthunt.com/posts/documenso-platform-plan?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-documenso-platform-plan" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=670576&theme=light" alt="Documenso Platform Plan - Whitelabeled signing flows in your product | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
"public-api": "Public API",
|
||||
"embedding": "Embedding",
|
||||
"webhooks": "Webhooks"
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,5 @@
|
||||
"solid": "Solid Integration",
|
||||
"preact": "Preact Integration",
|
||||
"angular": "Angular Integration",
|
||||
"css-variables": "CSS Variables",
|
||||
"web-components": "Web Components"
|
||||
"css-variables": "CSS Variables"
|
||||
}
|
||||
|
||||
@@ -73,15 +73,14 @@ These customization options are available for both Direct Templates and Signing
|
||||
|
||||
We support embedding across a range of popular JavaScript frameworks, including:
|
||||
|
||||
| Framework | Package |
|
||||
| --------- | ---------------------------------------------------------------------------------- |
|
||||
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
|
||||
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
|
||||
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
|
||||
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
|
||||
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
|
||||
| Angular | [@documenso/embed-angular](https://www.npmjs.com/package/@documenso/embed-angular) |
|
||||
| Web Components | [@documenso/embed-webcomponent](https://www.npmjs.com/package/@documenso/embed-webcomponent) |
|
||||
| Framework | Package |
|
||||
| --------- | ---------------------------------------------------------------------------------- |
|
||||
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
|
||||
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
|
||||
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
|
||||
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
|
||||
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
|
||||
| Angular | [@documenso/embed-angular](https://www.npmjs.com/package/@documenso/embed-angular) |
|
||||
|
||||
Additionally, we provide **web components** for more generalized use. However, please note that web components are still in their early stages and haven't been extensively tested.
|
||||
|
||||
@@ -167,7 +166,6 @@ Once you've obtained the appropriate tokens, you can integrate the signing exper
|
||||
- [Svelte](/developers/embedding/svelte)
|
||||
- [Solid](/developers/embedding/solid)
|
||||
- [Angular](/developers/embedding/angular)
|
||||
- [Web Components](/developers/embedding/web-components)
|
||||
|
||||
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.
|
||||
|
||||
@@ -179,5 +177,4 @@ If you're using **web components**, the integration process is slightly differen
|
||||
- [Solid Integration](/developers/embedding/solid)
|
||||
- [Preact Integration](/developers/embedding/preact)
|
||||
- [Angular Integration](/developers/embedding/angular)
|
||||
- [Web Components](/developers/embedding/web-components)
|
||||
- [CSS Variables](/developers/embedding/css-variables)
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
title: Web Components Integration
|
||||
description: Learn how to use our embedding SDK via Web Components on a framework-less web application.
|
||||
---
|
||||
|
||||
# Web Components Integration
|
||||
|
||||
Our Web Components SDK provides a simple way to embed a signing experience within your framework-less web application. It supports both direct link templates and signing tokens.
|
||||
|
||||
## Installation
|
||||
|
||||
To install the SDK, run the following command:
|
||||
|
||||
```bash
|
||||
npm install @documenso/embed-webcomponent
|
||||
```
|
||||
|
||||
Then in your html file, add the following to add the script, replacing the path with the proper path to the web component script.
|
||||
|
||||
```html
|
||||
<script src="YOUR_PATH_HERE"></script>
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
|
||||
|
||||
### Direct Link Template
|
||||
|
||||
If you have a direct link template, you can simply provide the token for the template to the `documenso-embed-direct-template` tag.
|
||||
|
||||
```html
|
||||
<documenso-embed-direct-template
|
||||
token="YOUR_TOKEN_HERE"
|
||||
</documenso-embed-direct-template>
|
||||
```
|
||||
|
||||
#### Attributes
|
||||
|
||||
| Attribute | Type | Description |
|
||||
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| token | string | The token for the document you want to embed |
|
||||
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
||||
| name | string (optional) | The name the signer that will be used by default for signing |
|
||||
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
||||
| email | string (optional) | The email the signer that will be used by default for signing |
|
||||
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
|
||||
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
||||
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
||||
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
||||
| onFieldSigned | function (optional) | A callback function that will be called when a field is signed |
|
||||
| onFieldUnsigned | function (optional) | A callback function that will be called when a field is unsigned |
|
||||
|
||||
### Signing Token
|
||||
|
||||
If you have a signing token, you can provide it to the `documenso-embed-sign-document` tag.
|
||||
|
||||
```html
|
||||
<documenso-embed-sign-document
|
||||
token="YOUR_TOKEN_HERE"
|
||||
</documenso-embed-sign-document>
|
||||
```
|
||||
|
||||
#### Attributes
|
||||
|
||||
| Attribute | Type | Description |
|
||||
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| token | string | The token for the document you want to embed |
|
||||
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
||||
| name | string (optional) | The name the signer that will be used by default for signing |
|
||||
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
||||
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
||||
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
||||
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
||||
|
||||
### Creating via JavaScript
|
||||
|
||||
You can also create the tag element using javascript, for dynamic generation of either modes. For example, this would add the sign document embed to the DOM.
|
||||
|
||||
```javascript
|
||||
document.getElementById('my-wrapper-here').innerHTML = '';
|
||||
|
||||
const tag = document.createElement('documenso-embed-sign-document');
|
||||
tag.setAttribute('token', data.token);
|
||||
tag.style.width = '100%';
|
||||
tag.style.height = '100%';
|
||||
|
||||
document.getElementById('my-wrapper-here').appendChild(tag);
|
||||
```
|
||||
@@ -21,25 +21,14 @@ Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) f
|
||||
|
||||
## API V2 - Beta
|
||||
|
||||
<Callout type="warning">API V2 is currently beta, and will be subject to breaking changes</Callout>
|
||||
Our new API V2 is currently in Beta. The new API features typed SDKs for TypeScript, Python and Go and example code for many more.
|
||||
|
||||
Check out the [API V2 documentation](https://documen.so/api-v2-docs) for details about the API endpoints, request parameters, response formats, and authentication methods.
|
||||
|
||||
Our new API V2 supports the following typed SDKs:
|
||||
|
||||
- [TypeScript](https://github.com/documenso/sdk-typescript)
|
||||
- [Python](https://github.com/documenso/sdk-python)
|
||||
- [Go](https://github.com/documenso/sdk-go)
|
||||
|
||||
<Callout type="info">
|
||||
For the staging API, please use the following base URL:
|
||||
`https://stg-app.documenso.dev/api/v2-beta/`
|
||||
<Callout type="warning">
|
||||
NOW IN BETA: [API V2 Documentation](https://documen.so/api-v2-docs)
|
||||
</Callout>
|
||||
|
||||
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
||||
|
||||
📖 [Documentation](https://documen.so/api-v2-docs)
|
||||
|
||||
💬 [Leave Feedback](https://documen.so/sdk-feedback)
|
||||
|
||||
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
|
||||
|
||||
@@ -85,13 +85,12 @@ You can also set the recipient's role, which determines their actions and permis
|
||||
|
||||
Documenso has 4 roles for recipients with different permissions and actions.
|
||||
|
||||
| Role | Function | Action required | Signature |
|
||||
| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
|
||||
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
|
||||
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
|
||||
| Viewer | Needs to confirm they viewed the document. | Yes | No |
|
||||
| Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
|
||||
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
|
||||
| Role | Function | Action required | Signature |
|
||||
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
|
||||
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
|
||||
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
|
||||
| Viewer | Needs to confirm they viewed the document. | Yes | No |
|
||||
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No |
|
||||
|
||||
### Fields
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/web",
|
||||
"version": "1.9.1-rc.7",
|
||||
"version": "1.9.1-rc.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -47,50 +47,22 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
||||
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z
|
||||
.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.any()
|
||||
.refine((data) => data instanceof File || data === undefined)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
// Display exactly which rows are duplicates.
|
||||
.superRefine((items, ctx) => {
|
||||
const uniqueEmails = new Map<string, number>();
|
||||
|
||||
for (const [index, recipients] of items.recipients.entries()) {
|
||||
const email = recipients.email.toLowerCase();
|
||||
|
||||
const firstFoundIndex = uniqueEmails.get(email);
|
||||
|
||||
if (firstFoundIndex === undefined) {
|
||||
uniqueEmails.set(email, index);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Emails must be unique',
|
||||
path: ['recipients', index, 'email'],
|
||||
});
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Emails must be unique',
|
||||
path: ['recipients', firstFoundIndex, 'email'],
|
||||
});
|
||||
}
|
||||
});
|
||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.any()
|
||||
.refine((data) => data instanceof File || data === undefined)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||
|
||||
|
||||
@@ -44,12 +44,7 @@ export const CheckboxField = ({ field, onSignField, onUnsignField }: CheckboxFie
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||
|
||||
const parsedFieldMeta = ZCheckboxFieldMeta.parse(
|
||||
field.fieldMeta ?? {
|
||||
type: 'checkbox',
|
||||
values: [{ id: 1, checked: false, value: '' }],
|
||||
},
|
||||
);
|
||||
const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
const values = parsedFieldMeta.values?.map((item) => ({
|
||||
...item,
|
||||
|
||||
@@ -43,10 +43,9 @@ type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
||||
export interface RejectDocumentDialogProps {
|
||||
document: Pick<Document, 'id'>;
|
||||
token: string;
|
||||
onRejected?: (reason: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function RejectDocumentDialog({ document, token, onRejected }: RejectDocumentDialogProps) {
|
||||
export function RejectDocumentDialog({ document, token }: RejectDocumentDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -80,11 +79,7 @@ export function RejectDocumentDialog({ document, token, onRejected }: RejectDocu
|
||||
|
||||
setIsOpen(false);
|
||||
|
||||
if (onRejected) {
|
||||
await onRejected(reason);
|
||||
} else {
|
||||
router.push(`/sign/${token}/rejected`);
|
||||
}
|
||||
router.push(`/sign/${token}/rejected`);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
|
||||
@@ -49,7 +49,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhiteLabelling?: boolean;
|
||||
isPlatformOrEnterprise?: boolean;
|
||||
};
|
||||
|
||||
export const EmbedDirectTemplateClientPage = ({
|
||||
@@ -60,7 +60,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
fields,
|
||||
metadata,
|
||||
hidePoweredBy = false,
|
||||
allowWhiteLabelling = false,
|
||||
isPlatformOrEnterprise = false,
|
||||
}: EmbedDirectTemplateClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@@ -288,7 +288,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (allowWhiteLabelling) {
|
||||
if (isPlatformOrEnterprise) {
|
||||
injectCss({
|
||||
css: data.css,
|
||||
cssVars: data.cssVars,
|
||||
@@ -349,7 +349,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
{/* Widget */}
|
||||
<div
|
||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
|
||||
@@ -360,34 +360,19 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
<Trans>Sign document</Trans>
|
||||
</h3>
|
||||
|
||||
{isExpanded ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground h-5 w-5" />
|
||||
</Button>
|
||||
) : pendingFields.length > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
@@ -56,16 +55,12 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
||||
documentAuth: template.authOptions,
|
||||
});
|
||||
|
||||
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([
|
||||
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
|
||||
isDocumentPlatform(template),
|
||||
isUserEnterprise({
|
||||
userId: template.userId,
|
||||
teamId: template.teamId ?? undefined,
|
||||
}),
|
||||
isUserCommunityPlan({
|
||||
userId: template.userId,
|
||||
teamId: template.teamId ?? undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
const isAccessAuthValid = match(derivedRecipientAccessAuth)
|
||||
@@ -110,10 +105,8 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
metadata={template.templateMeta}
|
||||
hidePoweredBy={
|
||||
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
||||
}
|
||||
allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
|
||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||
/>
|
||||
</RecipientProvider>
|
||||
</DocumentAuthProvider>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { XCircle } from 'lucide-react';
|
||||
|
||||
import type { Signature } from '@documenso/prisma/client';
|
||||
|
||||
export type EmbedDocumentRejectedPageProps = {
|
||||
name?: string;
|
||||
signature?: Signature;
|
||||
};
|
||||
|
||||
export const EmbedDocumentRejected = ({ name }: EmbedDocumentRejectedPageProps) => {
|
||||
return (
|
||||
<div className="embed--DocumentRejected relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<XCircle className="text-destructive h-10 w-10" />
|
||||
|
||||
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Document Rejected</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="text-destructive mt-4 flex items-center text-center text-sm">
|
||||
<Trans>You have rejected this document</Trans>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
|
||||
<Trans>
|
||||
The document owner has been notified of your decision. They may contact you with further
|
||||
instructions if necessary.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
|
||||
<Trans>No further action is required from you at this time.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,13 +10,7 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
|
||||
import {
|
||||
type DocumentData,
|
||||
type Field,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
import { type DocumentData, type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
@@ -32,13 +26,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
|
||||
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
|
||||
import { RejectDocumentDialog } from '~/app/(signing)/sign/[token]/reject-document-dialog';
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
|
||||
import { EmbedClientLoading } from '../../client-loading';
|
||||
import { EmbedDocumentCompleted } from '../../completed';
|
||||
import { EmbedDocumentFields } from '../../document-fields';
|
||||
import { EmbedDocumentRejected } from '../../rejected';
|
||||
import { injectCss } from '../../util';
|
||||
import { ZSignDocumentEmbedDataSchema } from './schema';
|
||||
|
||||
@@ -51,7 +43,7 @@ export type EmbedSignDocumentClientPageProps = {
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
isCompleted?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhitelabelling?: boolean;
|
||||
isPlatformOrEnterprise?: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
};
|
||||
|
||||
@@ -64,7 +56,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
metadata,
|
||||
isCompleted,
|
||||
hidePoweredBy = false,
|
||||
allowWhitelabelling = false,
|
||||
isPlatformOrEnterprise = false,
|
||||
allRecipients = [],
|
||||
}: EmbedSignDocumentClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
@@ -83,9 +75,6 @@ export const EmbedSignDocumentClientPage = ({
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
||||
const [hasRejectedDocument, setHasRejectedDocument] = useState(
|
||||
recipient.signingStatus === SigningStatus.REJECTED,
|
||||
);
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
|
||||
allRecipients.length > 0 ? allRecipients[0].id : null,
|
||||
);
|
||||
@@ -94,8 +83,6 @@ export const EmbedSignDocumentClientPage = ({
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
|
||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||
|
||||
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
|
||||
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
|
||||
|
||||
@@ -174,25 +161,6 @@ export const EmbedSignDocumentClientPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentRejected = (reason: string) => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-rejected',
|
||||
data: {
|
||||
token,
|
||||
documentId,
|
||||
recipientId: recipient.id,
|
||||
reason,
|
||||
},
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setHasRejectedDocument(true);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
@@ -206,13 +174,12 @@ export const EmbedSignDocumentClientPage = ({
|
||||
// Since a recipient can be provided a name we can lock it without requiring
|
||||
// a to be provided by the parent application, unlike direct templates.
|
||||
setIsNameLocked(!!data.lockName);
|
||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||
|
||||
if (data.darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (allowWhitelabelling) {
|
||||
if (isPlatformOrEnterprise) {
|
||||
injectCss({
|
||||
css: data.css,
|
||||
cssVars: data.cssVars,
|
||||
@@ -241,10 +208,6 @@ export const EmbedSignDocumentClientPage = ({
|
||||
}
|
||||
}, [hasFinishedInit, hasDocumentLoaded]);
|
||||
|
||||
if (hasRejectedDocument) {
|
||||
return <EmbedDocumentRejected name={fullName} />;
|
||||
}
|
||||
|
||||
if (hasCompletedDocument) {
|
||||
return (
|
||||
<EmbedDocumentCompleted
|
||||
@@ -266,16 +229,6 @@ export const EmbedSignDocumentClientPage = ({
|
||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
|
||||
{allowDocumentRejection && (
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<RejectDocumentDialog
|
||||
document={{ id: documentId }}
|
||||
token={token}
|
||||
onRejected={onDocumentRejected}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="embed--DocumentViewer flex-1">
|
||||
@@ -288,7 +241,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
{/* Widget */}
|
||||
<div
|
||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
@@ -303,36 +256,19 @@ export const EmbedSignDocumentClientPage = ({
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{isExpanded ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
) : pendingFields.length > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
disabled={
|
||||
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||
}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -484,7 +420,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
|
||||
className="col-start-2"
|
||||
disabled={
|
||||
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
@@ -63,16 +62,12 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
||||
return <EmbedPaywall />;
|
||||
}
|
||||
|
||||
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([
|
||||
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
|
||||
isDocumentPlatform(document),
|
||||
isUserEnterprise({
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
}),
|
||||
isUserCommunityPlan({
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
@@ -131,10 +126,8 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
||||
fields={fields}
|
||||
metadata={document.documentMeta}
|
||||
isCompleted={document.status === DocumentStatus.COMPLETED}
|
||||
hidePoweredBy={
|
||||
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
||||
}
|
||||
allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
|
||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||
allRecipients={allRecipients}
|
||||
/>
|
||||
</DocumentAuthProvider>
|
||||
|
||||
@@ -13,5 +13,4 @@ export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
|
||||
.optional()
|
||||
.transform((value) => value || undefined),
|
||||
lockName: z.boolean().optional().default(false),
|
||||
allowDocumentRejection: z.boolean().optional(),
|
||||
});
|
||||
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.9.1-rc.7",
|
||||
"version": "1.9.1-rc.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.9.1-rc.7",
|
||||
"version": "1.9.1-rc.1",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@@ -106,7 +106,7 @@
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@documenso/web",
|
||||
"version": "1.9.1-rc.7",
|
||||
"version": "1.9.1-rc.1",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
@@ -35722,6 +35722,21 @@
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"packages/trpc/node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "14.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.6.tgz",
|
||||
"integrity": "sha512-hNukAxq7hu4o5/UjPp5jqoBEtrpCbOmnUqZSKNJG8GrUVzfq0ucdhQFVrHcLRMvQcwqqDh1a5AJN9ORnNDpgBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.9.1-rc.7",
|
||||
"version": "1.9.1-rc.1",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"build:web": "turbo run build --filter=@documenso/web",
|
||||
|
||||
@@ -270,12 +270,11 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.map((signer) => signer.email.toLowerCase());
|
||||
const ids = schema.map((signer) => signer.id);
|
||||
|
||||
return new Set(emails).size === emails.length && new Set(ids).size === ids.length;
|
||||
return new Set(ids).size === ids.length;
|
||||
},
|
||||
{ message: 'Recipient IDs and emails must be unique' },
|
||||
{ message: 'Recipient IDs must be unique' },
|
||||
),
|
||||
meta: z
|
||||
.object({
|
||||
|
||||
304
packages/app-tests/e2e/document-flow/fields-step.spec.ts
Normal file
304
packages/app-tests/e2e/document-flow/fields-step.spec.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[DOCUMENT_FLOW]: add signature fields for unique recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Add 2 signers.
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(1).fill('recipient2@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Recipient 2');
|
||||
|
||||
// Advanced settings should not be visible for non EE users.
|
||||
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('**/documents');
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: add signature fields for duplicate recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for each recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(1).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(2).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 300,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('**/documents');
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: add signature fields for recipients with different roles', async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Add a signer
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add an approver
|
||||
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add a viewer
|
||||
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add a CC
|
||||
await page.getByLabel('Email').nth(3).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(3).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for signer and approver
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page
|
||||
.getByRole('option', { name: 'Documenso Recipient (recipient@documenso.com)' })
|
||||
.nth(1)
|
||||
.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('**/documents');
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: add signature fields for mixed recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// First recipient (unique)
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Second recipient (duplicate of first)
|
||||
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Third recipient (unique)
|
||||
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Second Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Fourth recipient (duplicate of first)
|
||||
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
|
||||
await page.getByLabel('Name').nth(3).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Fifth recipient (unique)
|
||||
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
|
||||
await page.getByLabel('Name').nth(4).fill('Third Recipient');
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for approver and signers
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Second Recipient (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Third Recipient (recipient3@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 300,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('**/documents');
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
@@ -91,3 +91,191 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: add only recipients with the same email address', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: duplicate email recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Add a signer
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add an approver
|
||||
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add a viewer
|
||||
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add a CC
|
||||
await page.getByLabel('Email').nth(3).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(3).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: same email with different roles', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// First recipient (unique)
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Second recipient (duplicate of first)
|
||||
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Third recipient (unique)
|
||||
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Second Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Fourth recipient (duplicate of first)
|
||||
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
|
||||
await page.getByLabel('Name').nth(3).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Fifth recipient (unique)
|
||||
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
|
||||
await page.getByLabel('Name').nth(4).fill('Third Recipient');
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: mixed unique and duplicate recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// First recipient (unique)
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Second recipient (duplicate of first)
|
||||
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Third recipient (unique)
|
||||
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Second Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Fourth recipient (duplicate of first)
|
||||
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
|
||||
await page.getByLabel('Name').nth(3).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Fifth recipient (unique)
|
||||
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
|
||||
await page.getByLabel('Name').nth(4).fill('Third Recipient');
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[TEMPLATE_FLOW]: add signature fields for unique recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||
|
||||
// Add 2 placeholder recipients.
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
|
||||
|
||||
// Advanced settings should not be visible for non EE users.
|
||||
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
||||
await page.waitForURL('**/templates');
|
||||
|
||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW]: add signature fields for duplicate recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for each recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(1).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(2).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 300,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
||||
await page.waitForURL('**/templates');
|
||||
|
||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW]: add signature fields for recipients with different roles', async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||
|
||||
// Add a placeholder recipient
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Add an approver
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Add a viewer
|
||||
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Add a CC
|
||||
await page.getByPlaceholder('Email').nth(3).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(3).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for signer and approver
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page
|
||||
.getByRole('option', { name: 'Documenso Recipient (recipient@documenso.com)' })
|
||||
.nth(1)
|
||||
.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
||||
await page.waitForURL('**/templates');
|
||||
|
||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW]: add signature fields for mixed recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||
|
||||
// First placeholder recipient (unique)
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Second placeholder recipient (duplicate of first)
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Third placeholder recipient (unique)
|
||||
await page.getByPlaceholder('Email').nth(2).fill('recipient2@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Second Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Fourth placeholder recipient (duplicate of first)
|
||||
await page.getByPlaceholder('Email').nth(3).fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(3).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Fifth placeholder recipient (unique)
|
||||
await page.getByPlaceholder('Email').nth(4).fill('recipient3@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(4).fill('Third Recipient');
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for approver and signers
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Second Recipient (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Third Recipient (recipient3@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 300,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
||||
await page.waitForURL('**/templates');
|
||||
|
||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||
});
|
||||
@@ -98,3 +98,135 @@ test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
|
||||
// Advanced settings should not be visible for non EE users.
|
||||
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW]: duplicate recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Recipient');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW]: same email different roles', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
// Add a signer
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Add an approver
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Add a viewer
|
||||
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Add a CC
|
||||
await page.getByPlaceholder('Email').nth(3).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(3).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW]: mixed recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
// First recipient (unique)
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Second recipient (duplicate of first)
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Third recipient (unique)
|
||||
await page.getByPlaceholder('Email').nth(2).fill('recipient2@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Second Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Fourth recipient (duplicate of first)
|
||||
await page.getByPlaceholder('Email').nth(3).fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(3).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Fifth recipient (unique)
|
||||
await page.getByPlaceholder('Email').nth(4).fill('recipient3@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(4).fill('Third Recipient');
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -110,14 +110,13 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
|
||||
await page.getByRole('button', { name: 'Save template' }).click();
|
||||
|
||||
// Use template
|
||||
await page.waitForURL('/templates');
|
||||
await page.waitForURL('**/templates');
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct values.
|
||||
await page.waitForURL(/documents/);
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -250,9 +249,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct values.
|
||||
await page.waitForURL(/documents/);
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -353,9 +351,8 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the custom document data
|
||||
await page.waitForURL(/documents/);
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -434,9 +431,8 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the custom document data
|
||||
await page.waitForURL(/documents/);
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -500,9 +496,8 @@ test('[TEMPLATE]: should create a document from a template using template docume
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the template's document data
|
||||
await page.waitForURL(/documents/);
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -591,9 +586,8 @@ test('[TEMPLATE]: should persist document visibility when creating from template
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct visibility
|
||||
await page.waitForURL(/documents/);
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -616,3 +610,368 @@ test('[TEMPLATE]: should persist document visibility when creating from template
|
||||
// Template should not be visible to regular member
|
||||
await expect(page.getByRole('button', { name: 'Use Template' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* This test verifies that we can create a document from a template with duplicate recipients
|
||||
**/
|
||||
test('[TEMPLATE]: should create a document from a template with duplicate recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
const isBillingEnabled =
|
||||
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
|
||||
|
||||
await seedUserSubscription({
|
||||
userId: user.id,
|
||||
priceId: enterprisePriceId,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Set template title.
|
||||
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
|
||||
|
||||
// Set template document access.
|
||||
await page.getByTestId('documentAccessSelectValue').click();
|
||||
await page.getByLabel('Require account').getByText('Require account').click();
|
||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
|
||||
// Set EE action auth.
|
||||
if (isBillingEnabled) {
|
||||
await page.getByTestId('documentActionSelectValue').click();
|
||||
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||
}
|
||||
|
||||
// Set email options.
|
||||
await page.getByRole('button', { name: 'Email Options' }).click();
|
||||
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
|
||||
await page.getByLabel('Message (Optional)').fill('MESSAGE');
|
||||
|
||||
// Set advanced options.
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||
await page.getByLabel('DD/MM/YYYY').click();
|
||||
|
||||
await page.locator('.time-zone-field').click();
|
||||
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||
await page.getByLabel('Redirect URL').fill('https://documenso.com');
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
// Add 2 signers.
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Recipient 1');
|
||||
|
||||
// Apply require passkey for Recipient 1.
|
||||
if (isBillingEnabled) {
|
||||
await page.getByLabel('Show advanced settings').check();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByLabel('Require passkey').click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for each recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).nth(1).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Save template' }).click();
|
||||
|
||||
// Use template
|
||||
await page.waitForURL('**/templates');
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct values.
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
const documentAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_TITLE');
|
||||
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
|
||||
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
|
||||
isBillingEnabled ? 'PASSKEY' : null,
|
||||
);
|
||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
|
||||
|
||||
const recipientOne = document.recipients[0];
|
||||
const recipientTwo = document.recipients[1];
|
||||
|
||||
const recipientOneAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipientOne.authOptions,
|
||||
});
|
||||
|
||||
const recipientTwoAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipientTwo.authOptions,
|
||||
});
|
||||
|
||||
if (isBillingEnabled) {
|
||||
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
|
||||
}
|
||||
|
||||
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||
|
||||
await page.getByRole('link', { name: 'Edit' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await expect(page.getByText('SignatureRE').first()).toBeVisible();
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'recipient1@documenso.com' }).nth(1).click();
|
||||
|
||||
await expect(page.getByText('SignatureRE').nth(1)).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* This test verifies that we can create a document from a template with a mix of duplicate and unique recipients
|
||||
**/
|
||||
test('[TEMPLATE]: should create a document from a template with mixed duplicate and unique recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
const isBillingEnabled =
|
||||
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
|
||||
|
||||
await seedUserSubscription({
|
||||
userId: user.id,
|
||||
priceId: enterprisePriceId,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Set template title.
|
||||
await page.getByLabel('Title').fill('TEMPLATE_MIXED_RECIPIENTS');
|
||||
|
||||
// Set template document access.
|
||||
await page.getByTestId('documentAccessSelectValue').click();
|
||||
await page.getByLabel('Require account').getByText('Require account').click();
|
||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
|
||||
// Set EE action auth.
|
||||
if (isBillingEnabled) {
|
||||
await page.getByTestId('documentActionSelectValue').click();
|
||||
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||
}
|
||||
|
||||
// Set email options.
|
||||
await page.getByRole('button', { name: 'Email Options' }).click();
|
||||
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
|
||||
await page.getByLabel('Message (Optional)').fill('MESSAGE');
|
||||
|
||||
// Set advanced options.
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||
await page.getByLabel('DD/MM/YYYY').click();
|
||||
|
||||
await page.locator('.time-zone-field').click();
|
||||
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||
await page.getByLabel('Redirect URL').fill('https://documenso.com');
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
// Add 4 signers: 2 duplicates of recipient1 and 2 unique recipients
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(2).fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Recipient 1');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(3).fill('recipient3@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(3).fill('Recipient 3');
|
||||
|
||||
// Apply require passkey for first instance of Recipient 1
|
||||
if (isBillingEnabled) {
|
||||
await page.getByLabel('Show advanced settings').check();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByLabel('Require passkey').click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for each recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).nth(1).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 300,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 3 (recipient3@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 400,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Save template' }).click();
|
||||
|
||||
// Use template
|
||||
await page.waitForURL('**/templates');
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct values.
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
orderBy: {
|
||||
email: 'asc',
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
const documentAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_MIXED_RECIPIENTS');
|
||||
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
|
||||
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
|
||||
isBillingEnabled ? 'PASSKEY' : null,
|
||||
);
|
||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
|
||||
|
||||
// Check auth settings for first instance of recipient1
|
||||
const firstRecipientOne = document.recipients[0];
|
||||
const firstRecipientOneAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: firstRecipientOne.authOptions,
|
||||
});
|
||||
|
||||
if (isBillingEnabled) {
|
||||
expect(firstRecipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
|
||||
}
|
||||
|
||||
expect(firstRecipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||
|
||||
await page.getByRole('link', { name: 'Edit' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await expect(page.getByText('SignatureRE').first()).toBeVisible();
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'recipient2@documenso.com' }).click();
|
||||
await expect(page.getByText('SignatureRE').nth(1)).toBeVisible();
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'recipient1@documenso.com' }).nth(1).click();
|
||||
await expect(page.getByText('SignatureRE').nth(2)).toBeVisible();
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'recipient3@documenso.com' }).click();
|
||||
await expect(page.getByText('SignatureRE').nth(3)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -4,14 +4,15 @@ import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
|
||||
|
||||
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
|
||||
const planTypes: string[] = typeof plan === 'string' ? [plan] : plan;
|
||||
const planTypes = typeof plan === 'string' ? [plan] : plan;
|
||||
|
||||
const prices = await stripe.prices.list({
|
||||
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR ');
|
||||
|
||||
const { data: prices } = await stripe.prices.search({
|
||||
query,
|
||||
expand: ['data.product'],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return prices.data.filter(
|
||||
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
|
||||
);
|
||||
return prices.filter((price) => price.type === 'recurring');
|
||||
};
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Subscription } from '@documenso/prisma/client';
|
||||
|
||||
import { getCommunityPlanPriceIds } from '../stripe/get-community-plan-prices';
|
||||
|
||||
export type IsCommunityPlanOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the user or team is on the community plan.
|
||||
*/
|
||||
export const isCommunityPlan = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: IsCommunityPlanOptions): Promise<boolean> => {
|
||||
let subscriptions: Subscription[] = [];
|
||||
|
||||
if (teamId) {
|
||||
subscriptions = await prisma.team
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
select: {
|
||||
owner: {
|
||||
include: {
|
||||
subscriptions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((team) => team.owner.subscriptions);
|
||||
} else {
|
||||
subscriptions = await prisma.user
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
subscriptions: true,
|
||||
},
|
||||
})
|
||||
.then((user) => user.subscriptions);
|
||||
}
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const communityPlanPriceIds = await getCommunityPlanPriceIds();
|
||||
|
||||
return subscriptionsContainsActivePlan(subscriptions, communityPlanPriceIds);
|
||||
};
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
WebhookTriggerEvents,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import {
|
||||
@@ -73,13 +72,6 @@ export const completeDocumentWithToken = async ({
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.REJECTED) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Recipient has already rejected the document',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
|
||||
|
||||
|
||||
@@ -88,7 +88,6 @@ export const findDocuments = async ({
|
||||
const searchFilter: Prisma.DocumentWhereInput = {
|
||||
OR: [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ externalId: { contains: query, mode: 'insensitive' } },
|
||||
{ recipients: { some: { name: { contains: query, mode: 'insensitive' } } } },
|
||||
{ recipients: { some: { email: { contains: query, mode: 'insensitive' } } } },
|
||||
],
|
||||
|
||||
@@ -34,14 +34,6 @@ export const searchDocumentsWithKeyword = async ({
|
||||
userId: userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
externalId: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
userId: userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
@@ -96,23 +88,6 @@ export const searchDocumentsWithKeyword = async ({
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
externalId: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
teamId: {
|
||||
not: null,
|
||||
},
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -72,6 +72,22 @@ export const setFieldsForDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// Check that every signer has a signature field
|
||||
const signers = document.recipients.filter((recipient) => recipient.role === 'SIGNER');
|
||||
const hasEverySignerSignature = signers.every((signer) =>
|
||||
fields.some(
|
||||
(field) =>
|
||||
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
|
||||
field.recipientId === signer.id,
|
||||
),
|
||||
);
|
||||
|
||||
if (!hasEverySignerSignature) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Every signer must have at least one signature field',
|
||||
});
|
||||
}
|
||||
|
||||
if (document.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document already complete',
|
||||
@@ -94,9 +110,7 @@ export const setFieldsForDocument = async ({
|
||||
const linkedFields = fields.map((field) => {
|
||||
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
||||
|
||||
const recipient = document.recipients.find(
|
||||
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
|
||||
);
|
||||
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
// Each field MUST have a recipient associated with it.
|
||||
if (!recipient) {
|
||||
@@ -236,10 +250,8 @@ export const setFieldsForDocument = async ({
|
||||
},
|
||||
recipient: {
|
||||
connect: {
|
||||
documentId_email: {
|
||||
documentId,
|
||||
email: fieldSignerEmail,
|
||||
},
|
||||
documentId,
|
||||
id: field.recipientId,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -340,6 +352,7 @@ type FieldData = {
|
||||
id?: number | null;
|
||||
type: FieldType;
|
||||
signerEmail: string;
|
||||
recipientId: number;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
|
||||
@@ -22,6 +22,7 @@ export type SetFieldsForTemplateOptions = {
|
||||
fields: {
|
||||
id?: number | null;
|
||||
type: FieldType;
|
||||
signerId: number;
|
||||
signerEmail: string;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
@@ -57,12 +58,29 @@ export const setFieldsForTemplate = async ({
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
// Check that every signer has a signature field
|
||||
const signers = template.recipients.filter((recipient) => recipient.role === 'SIGNER');
|
||||
const hasEverySignerSignature = signers.every((signer) =>
|
||||
fields.some(
|
||||
(field) =>
|
||||
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
|
||||
field.signerId === signer.id,
|
||||
),
|
||||
);
|
||||
|
||||
if (!hasEverySignerSignature) {
|
||||
throw new Error('Every signer must have at least one signature field');
|
||||
}
|
||||
|
||||
const existingFields = await prisma.field.findMany({
|
||||
where: {
|
||||
templateId,
|
||||
@@ -180,10 +198,8 @@ export const setFieldsForTemplate = async ({
|
||||
},
|
||||
recipient: {
|
||||
connect: {
|
||||
templateId_email: {
|
||||
templateId,
|
||||
email: field.signerEmail.toLowerCase(),
|
||||
},
|
||||
templateId,
|
||||
id: field.signerId,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -125,16 +125,12 @@ export const setDocumentRecipients = async ({
|
||||
|
||||
const removedRecipients = existingRecipients.filter(
|
||||
(existingRecipient) =>
|
||||
!normalizedRecipients.find(
|
||||
(recipient) =>
|
||||
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
||||
),
|
||||
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
||||
);
|
||||
|
||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
(existingRecipient) => existingRecipient.id === recipient.id,
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -103,10 +103,7 @@ export const setTemplateRecipients = async ({
|
||||
|
||||
const removedRecipients = existingRecipients.filter(
|
||||
(existingRecipient) =>
|
||||
!normalizedRecipients.find(
|
||||
(recipient) =>
|
||||
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
||||
),
|
||||
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
||||
);
|
||||
|
||||
if (template.directLink !== null) {
|
||||
@@ -133,14 +130,10 @@ export const setTemplateRecipients = async ({
|
||||
|
||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
(existingRecipient) => existingRecipient.id === recipient.id,
|
||||
);
|
||||
|
||||
return {
|
||||
...recipient,
|
||||
_persisted: existing,
|
||||
};
|
||||
return { ...recipient, _persisted: existing };
|
||||
});
|
||||
|
||||
const persistedRecipients = await prisma.$transaction(async (tx) => {
|
||||
|
||||
@@ -141,10 +141,8 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
|
||||
return await prisma.recipient.upsert({
|
||||
where: {
|
||||
documentId_email: {
|
||||
documentId: document.id,
|
||||
email: existingRecipient?.email ?? recipient.email,
|
||||
},
|
||||
documentId: document.id,
|
||||
id: existingRecipient?.id,
|
||||
},
|
||||
update: {
|
||||
name: recipient.name,
|
||||
|
||||
@@ -256,10 +256,21 @@ export const createDocumentFromTemplate = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const recipientMapping = new Map<number, number>();
|
||||
|
||||
template.recipients.forEach((templateRecipient, index) => {
|
||||
const documentRecipient = document.recipients[index];
|
||||
|
||||
if (documentRecipient) {
|
||||
recipientMapping.set(templateRecipient.id, documentRecipient.id);
|
||||
}
|
||||
});
|
||||
|
||||
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
|
||||
|
||||
Object.values(finalRecipients).forEach(({ email, fields }) => {
|
||||
const recipient = document.recipients.find((recipient) => recipient.email === email);
|
||||
finalRecipients.forEach(({ templateRecipientId, fields }) => {
|
||||
const documentRecipientId = recipientMapping.get(templateRecipientId);
|
||||
const recipient = document.recipients.find((r) => r.id === documentRecipientId);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error('Recipient not found.');
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_documentId_email_key";
|
||||
@@ -0,0 +1,5 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_templateId_email_key";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Recipient_email_idx" ON "Recipient"("email");
|
||||
@@ -1,18 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `expires` on the `Session` table. All the data in the column will be lost.
|
||||
- Added the required column `expiresAt` to the `Session` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `updatedAt` to the `Session` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Account" ADD COLUMN "password" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Session" DROP COLUMN "expires",
|
||||
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
ADD COLUMN "ipAddress" TEXT,
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
ADD COLUMN "userAgent" TEXT;
|
||||
@@ -270,25 +270,18 @@ model Account {
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
password String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId Int
|
||||
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
expires DateTime
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum DocumentStatus {
|
||||
@@ -450,11 +443,10 @@ model Recipient {
|
||||
fields Field[]
|
||||
signatures Signature[]
|
||||
|
||||
@@unique([documentId, email])
|
||||
@@unique([templateId, email])
|
||||
@@index([documentId])
|
||||
@@index([templateId])
|
||||
@@index([token])
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
enum FieldType {
|
||||
|
||||
@@ -192,14 +192,6 @@ export const ZCreateDocumentV2RequestSchema = z.object({
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{ message: 'Recipients must have unique emails' },
|
||||
)
|
||||
.optional(),
|
||||
meta: z
|
||||
.object({
|
||||
|
||||
@@ -232,8 +232,9 @@ export const fieldRouter = router({
|
||||
teamId,
|
||||
fields: fields.map((field) => ({
|
||||
id: field.nativeId,
|
||||
signerEmail: field.signerEmail,
|
||||
recipientId: field.recipientId,
|
||||
type: field.type,
|
||||
signerEmail: field.signerEmail,
|
||||
pageNumber: field.pageNumber,
|
||||
pageX: field.pageX,
|
||||
pageY: field.pageY,
|
||||
@@ -429,6 +430,7 @@ export const fieldRouter = router({
|
||||
teamId,
|
||||
fields: fields.map((field) => ({
|
||||
id: field.nativeId,
|
||||
signerId: field.recipientId,
|
||||
signerEmail: field.signerEmail,
|
||||
type: field.type,
|
||||
pageNumber: field.pageNumber,
|
||||
|
||||
@@ -112,13 +112,15 @@ export const ZSetDocumentFieldsRequestSchema = z.object({
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
id: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
recipientId: z.number(),
|
||||
signerEmail: z.string().min(1),
|
||||
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),
|
||||
pageNumber: ZFieldPageNumberSchema,
|
||||
pageX: ZFieldPageXSchema,
|
||||
pageY: ZFieldPageYSchema,
|
||||
pageWidth: ZFieldWidthSchema,
|
||||
pageHeight: ZFieldHeightSchema,
|
||||
fieldMeta: ZFieldMetaSchema,
|
||||
}),
|
||||
),
|
||||
@@ -136,6 +138,8 @@ export const ZSetFieldsForTemplateRequestSchema = z.object({
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
recipientId: z.number().min(1),
|
||||
signerId: z.number().min(1),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
|
||||
@@ -49,16 +49,7 @@ export const ZCreateDocumentRecipientResponseSchema = ZRecipientLiteSchema;
|
||||
|
||||
export const ZCreateDocumentRecipientsRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(ZCreateRecipientSchema).refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{
|
||||
message: 'Recipients must have unique emails',
|
||||
},
|
||||
),
|
||||
recipients: z.array(ZCreateRecipientSchema),
|
||||
});
|
||||
|
||||
export const ZCreateDocumentRecipientsResponseSchema = z.object({
|
||||
@@ -74,18 +65,7 @@ export const ZUpdateDocumentRecipientResponseSchema = ZRecipientSchema;
|
||||
|
||||
export const ZUpdateDocumentRecipientsRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(ZUpdateRecipientSchema).refine(
|
||||
(recipients) => {
|
||||
const emails = recipients
|
||||
.filter((recipient) => recipient.email !== undefined)
|
||||
.map((recipient) => recipient.email?.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{
|
||||
message: 'Recipients must have unique emails',
|
||||
},
|
||||
),
|
||||
recipients: z.array(ZUpdateRecipientSchema),
|
||||
});
|
||||
|
||||
export const ZUpdateDocumentRecipientsResponseSchema = z.object({
|
||||
@@ -96,29 +76,19 @@ export const ZDeleteDocumentRecipientRequestSchema = z.object({
|
||||
recipientId: z.number(),
|
||||
});
|
||||
|
||||
export const ZSetDocumentRecipientsRequestSchema = z
|
||||
.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.recipients.map((recipient) => recipient.email.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
|
||||
);
|
||||
export const ZSetDocumentRecipientsRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const ZSetDocumentRecipientsResponseSchema = z.object({
|
||||
recipients: ZRecipientLiteSchema.array(),
|
||||
@@ -133,16 +103,7 @@ export const ZCreateTemplateRecipientResponseSchema = ZRecipientLiteSchema;
|
||||
|
||||
export const ZCreateTemplateRecipientsRequestSchema = z.object({
|
||||
templateId: z.number(),
|
||||
recipients: z.array(ZCreateRecipientSchema).refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{
|
||||
message: 'Recipients must have unique emails',
|
||||
},
|
||||
),
|
||||
recipients: z.array(ZCreateRecipientSchema),
|
||||
});
|
||||
|
||||
export const ZCreateTemplateRecipientsResponseSchema = z.object({
|
||||
@@ -158,18 +119,7 @@ export const ZUpdateTemplateRecipientResponseSchema = ZRecipientSchema;
|
||||
|
||||
export const ZUpdateTemplateRecipientsRequestSchema = z.object({
|
||||
templateId: z.number(),
|
||||
recipients: z.array(ZUpdateRecipientSchema).refine(
|
||||
(recipients) => {
|
||||
const emails = recipients
|
||||
.filter((recipient) => recipient.email !== undefined)
|
||||
.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{
|
||||
message: 'Recipients must have unique emails',
|
||||
},
|
||||
),
|
||||
recipients: z.array(ZUpdateRecipientSchema),
|
||||
});
|
||||
|
||||
export const ZUpdateTemplateRecipientsResponseSchema = z.object({
|
||||
@@ -180,29 +130,19 @@ export const ZDeleteTemplateRecipientRequestSchema = z.object({
|
||||
recipientId: z.number(),
|
||||
});
|
||||
|
||||
export const ZSetTemplateRecipientsRequestSchema = z
|
||||
.object({
|
||||
templateId: z.number(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
|
||||
);
|
||||
export const ZSetTemplateRecipientsRequestSchema = z.object({
|
||||
templateId: z.number(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const ZSetTemplateRecipientsResponseSchema = z.object({
|
||||
recipients: ZRecipientLiteSchema.array(),
|
||||
|
||||
@@ -51,12 +51,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.describe('The information of the recipients to create the document with.')
|
||||
.refine((recipients) => {
|
||||
const emails = recipients.map((signer) => signer.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
}, 'Recipients must have unique emails'),
|
||||
.describe('The information of the recipients to create the document with.'),
|
||||
distributeDocument: z
|
||||
.boolean()
|
||||
.describe('Whether to create the document as pending and distribute it to recipients.')
|
||||
|
||||
@@ -92,6 +92,7 @@ export type FieldFormType = {
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
recipientId: number;
|
||||
signerEmail: string;
|
||||
fieldMeta?: FieldMeta;
|
||||
};
|
||||
@@ -143,6 +144,7 @@ export const AddFieldsFormPartial = ({
|
||||
pageY: Number(field.positionY),
|
||||
pageWidth: Number(field.width),
|
||||
pageHeight: Number(field.height),
|
||||
recipientId: field.recipientId,
|
||||
signerEmail:
|
||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||
@@ -348,6 +350,7 @@ export const AddFieldsFormPartial = ({
|
||||
pageY,
|
||||
pageWidth: fieldPageWidth,
|
||||
pageHeight: fieldPageHeight,
|
||||
recipientId: selectedSigner.id,
|
||||
signerEmail: selectedSigner.email,
|
||||
fieldMeta: undefined,
|
||||
};
|
||||
@@ -441,6 +444,7 @@ export const AddFieldsFormPartial = ({
|
||||
const newField: TAddFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
formId: nanoid(12),
|
||||
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
@@ -449,7 +453,7 @@ export const AddFieldsFormPartial = ({
|
||||
append(newField);
|
||||
}
|
||||
},
|
||||
[append, lastActiveField, selectedSigner?.email, toast],
|
||||
[append, lastActiveField, selectedSigner?.id, selectedSigner?.email, toast],
|
||||
);
|
||||
|
||||
const onFieldPaste = useCallback(
|
||||
@@ -462,13 +466,15 @@ export const AddFieldsFormPartial = ({
|
||||
append({
|
||||
...copiedField,
|
||||
formId: nanoid(12),
|
||||
recipientId: selectedSigner?.id ?? copiedField.recipientId,
|
||||
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
||||
|
||||
pageX: copiedField.pageX + 3,
|
||||
pageY: copiedField.pageY + 3,
|
||||
});
|
||||
}
|
||||
},
|
||||
[append, fieldClipboard, selectedSigner?.email],
|
||||
[append, fieldClipboard, selectedSigner?.id, selectedSigner?.email],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -567,7 +573,7 @@ export const AddFieldsFormPartial = ({
|
||||
localFields.some(
|
||||
(field) =>
|
||||
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
|
||||
field.signerEmail === signer.email,
|
||||
field.recipientId === signer.id,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -637,7 +643,7 @@ export const AddFieldsFormPartial = ({
|
||||
|
||||
{isDocumentPdfLoaded &&
|
||||
localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
||||
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
||||
const hasFieldError =
|
||||
emptyCheckboxFields.find((f) => f.formId === field.formId) ||
|
||||
emptyRadioFields.find((f) => f.formId === field.formId) ||
|
||||
@@ -649,7 +655,7 @@ export const AddFieldsFormPartial = ({
|
||||
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
|
||||
field={field}
|
||||
disabled={
|
||||
selectedSigner?.email !== field.signerEmail ||
|
||||
selectedSigner?.id !== field.recipientId ||
|
||||
!canRecipientBeModified(selectedSigner, fields)
|
||||
}
|
||||
minHeight={MIN_HEIGHT_PX}
|
||||
|
||||
@@ -10,6 +10,7 @@ export const ZAddFieldsFormSchema = z.object({
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
recipientId: z.number(),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
|
||||
@@ -6,34 +6,22 @@ import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-a
|
||||
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
|
||||
import { DocumentSigningOrder, RecipientRole } from '.prisma/client';
|
||||
|
||||
export const ZAddSignersFormSchema = z
|
||||
.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: msg`Invalid email`.id })
|
||||
.min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
|
||||
ZRecipientActionAuthTypesSchema.optional(),
|
||||
),
|
||||
}),
|
||||
),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: msg`Signers must have unique emails`.id, path: ['signers__root'] },
|
||||
);
|
||||
export const ZAddSignersFormSchema = z.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: msg`Invalid email`.id })
|
||||
.min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(ZRecipientActionAuthTypesSchema.optional()),
|
||||
}),
|
||||
),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
});
|
||||
|
||||
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
|
||||
|
||||
@@ -8,19 +8,14 @@ import { FieldType } from '@documenso/prisma/client';
|
||||
export const ZDocumentFlowFormSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
|
||||
signers: z
|
||||
.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
.refine((signers) => {
|
||||
const emails = signers.map((signer) => signer.email);
|
||||
return new Set(emails).size === emails.length;
|
||||
}, 'Signers must have unique emails'),
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
|
||||
fields: z.array(
|
||||
z.object({
|
||||
|
||||
@@ -60,6 +60,7 @@ import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors';
|
||||
import { Checkbox } from '../checkbox';
|
||||
import type { FieldFormType } from '../document-flow/add-fields';
|
||||
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
|
||||
import { MissingSignatureFieldDialog } from '../document-flow/missing-signature-field-dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel } from '../form/form';
|
||||
import { useStep } from '../stepper';
|
||||
import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
|
||||
@@ -110,6 +111,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
const [fieldClipboard, setFieldClipboard] = useState<
|
||||
TAddTemplateFieldsFormSchema['fields'][0] | null
|
||||
>(null);
|
||||
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
|
||||
|
||||
const form = useForm<TAddTemplateFieldsFormSchema>({
|
||||
defaultValues: {
|
||||
@@ -122,6 +124,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
pageY: Number(field.positionY),
|
||||
pageWidth: Number(field.width),
|
||||
pageHeight: Number(field.height),
|
||||
recipientId: field.recipientId ?? -1,
|
||||
signerId: field.recipientId ?? -1,
|
||||
signerEmail:
|
||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
@@ -177,6 +180,8 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||
recipientId:
|
||||
selectedSigner?.id || lastActiveField.recipientId || lastActiveField.signerId || 0,
|
||||
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
@@ -201,19 +206,29 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
event.preventDefault();
|
||||
|
||||
const copiedField = structuredClone(fieldClipboard);
|
||||
const signerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
|
||||
|
||||
append({
|
||||
...copiedField,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
||||
signerId: selectedSigner?.id ?? copiedField.signerId,
|
||||
recipientId: selectedSigner?.id || copiedField.recipientId || copiedField.signerId || 0,
|
||||
signerToken: selectedSigner?.token ?? copiedField.signerToken,
|
||||
signerIndex: signerIndex >= 0 ? signerIndex : 0,
|
||||
pageX: copiedField.pageX + 3,
|
||||
pageY: copiedField.pageY + 3,
|
||||
});
|
||||
}
|
||||
},
|
||||
[append, fieldClipboard, selectedSigner?.email, selectedSigner?.id, selectedSigner?.token],
|
||||
[
|
||||
append,
|
||||
fieldClipboard,
|
||||
selectedSigner?.email,
|
||||
selectedSigner?.id,
|
||||
selectedSigner?.token,
|
||||
recipients,
|
||||
],
|
||||
);
|
||||
|
||||
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
|
||||
@@ -319,6 +334,8 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
pageX -= fieldPageWidth / 2;
|
||||
pageY -= fieldPageHeight / 2;
|
||||
|
||||
const signerIndex = recipients.findIndex((r) => r.id === selectedSigner.id);
|
||||
|
||||
append({
|
||||
formId: nanoid(12),
|
||||
type: selectedField,
|
||||
@@ -329,14 +346,17 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
pageHeight: fieldPageHeight,
|
||||
signerEmail: selectedSigner.email,
|
||||
signerId: selectedSigner.id,
|
||||
recipientId:
|
||||
selectedSigner.id || lastActiveField?.recipientId || lastActiveField?.signerId || 0,
|
||||
signerToken: selectedSigner.token ?? '',
|
||||
signerIndex: signerIndex >= 0 ? signerIndex : 0,
|
||||
fieldMeta: undefined,
|
||||
});
|
||||
|
||||
setIsFieldWithinBounds(false);
|
||||
setSelectedField(null);
|
||||
},
|
||||
[append, isWithinPageBounds, selectedField, selectedSigner, getPage],
|
||||
[append, isWithinPageBounds, selectedField, selectedSigner, getPage, recipients],
|
||||
);
|
||||
|
||||
const onFieldResize = useCallback(
|
||||
@@ -499,6 +519,23 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
form.setValue('typedSignatureEnabled', value, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const handleGoNextClick = () => {
|
||||
const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) =>
|
||||
localFields.some(
|
||||
(field) =>
|
||||
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
|
||||
field.recipientId === signer.id,
|
||||
),
|
||||
);
|
||||
|
||||
if (!everySignerHasSignature) {
|
||||
setIsMissingSignatureDialogVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
void onFormSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showAdvancedSettings && currentField ? (
|
||||
@@ -546,14 +583,15 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
)}
|
||||
|
||||
{localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
||||
const recipientIndex =
|
||||
field.signerIndex ?? recipients.findIndex((r) => r.id === field.signerId);
|
||||
|
||||
return (
|
||||
<FieldItem
|
||||
key={index}
|
||||
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
|
||||
recipientIndex={recipientIndex >= 0 ? recipientIndex : 0}
|
||||
field={field}
|
||||
disabled={selectedSigner?.email !== field.signerEmail}
|
||||
disabled={selectedSigner?.id !== field.signerId}
|
||||
minHeight={MIN_HEIGHT_PX}
|
||||
minWidth={MIN_WIDTH_PX}
|
||||
defaultHeight={DEFAULT_HEIGHT_PX}
|
||||
@@ -993,9 +1031,14 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
previousStep();
|
||||
remove();
|
||||
}}
|
||||
onGoNextClick={() => void onFormSubmit()}
|
||||
onGoNextClick={handleGoNextClick}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
|
||||
<MissingSignatureFieldDialog
|
||||
isOpen={isMissingSignatureDialogVisible}
|
||||
onOpenChange={(value) => setIsMissingSignatureDialogVisible(value)}
|
||||
/>
|
||||
</div>
|
||||
</DocumentFlowFormContainerContent>
|
||||
</>
|
||||
|
||||
@@ -11,12 +11,14 @@ export const ZAddTemplateFieldsFormSchema = z.object({
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
signerToken: z.string(),
|
||||
signerId: z.number().optional(),
|
||||
signerId: z.number(),
|
||||
recipientId: z.number(),
|
||||
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),
|
||||
signerIndex: z.number().min(0),
|
||||
fieldMeta: ZFieldMetaSchema,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -92,6 +92,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
actionAuth: undefined,
|
||||
...generateRecipientPlaceholder(1),
|
||||
signingOrder: 1,
|
||||
signerIndex: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -104,6 +105,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
role: recipient.role,
|
||||
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
||||
signingOrder: recipient.signingOrder ?? index + 1,
|
||||
signerIndex: index,
|
||||
}));
|
||||
|
||||
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
@@ -174,21 +176,35 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
});
|
||||
|
||||
const onAddPlaceholderSelfRecipient = () => {
|
||||
const currentSigners = form.getValues('signers');
|
||||
const nextSignerIndex = currentSigners.length;
|
||||
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
||||
signingOrder:
|
||||
currentSigners.length > 0
|
||||
? (currentSigners[currentSigners.length - 1]?.signingOrder ?? 0) + 1
|
||||
: 1,
|
||||
signerIndex: nextSignerIndex,
|
||||
});
|
||||
};
|
||||
|
||||
const onAddPlaceholderRecipient = () => {
|
||||
const currentSigners = form.getValues('signers');
|
||||
const nextSignerIndex = currentSigners.length;
|
||||
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
role: RecipientRole.SIGNER,
|
||||
...generateRecipientPlaceholder(placeholderRecipientCount),
|
||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
||||
signingOrder:
|
||||
currentSigners.length > 0
|
||||
? (currentSigners[currentSigners.length - 1]?.signingOrder ?? 0) + 1
|
||||
: 1,
|
||||
signerIndex: nextSignerIndex,
|
||||
});
|
||||
|
||||
setPlaceholderRecipientCount((count) => count + 1);
|
||||
|
||||
@@ -5,32 +5,21 @@ import { DocumentSigningOrder, RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
|
||||
|
||||
export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
||||
.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
|
||||
ZRecipientActionAuthTypesSchema.optional(),
|
||||
),
|
||||
}),
|
||||
),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: 'Signers must have unique emails', path: ['signers__root'] },
|
||||
);
|
||||
export const ZAddTemplatePlacholderRecipientsFormSchema = z.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
signerIndex: z.number().min(0),
|
||||
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(ZRecipientActionAuthTypesSchema.optional()),
|
||||
}),
|
||||
),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
});
|
||||
|
||||
export type TAddTemplatePlacholderRecipientsFormSchema = z.infer<
|
||||
typeof ZAddTemplatePlacholderRecipientsFormSchema
|
||||
|
||||
Reference in New Issue
Block a user