Compare commits

..

15 Commits

52 changed files with 647 additions and 2327 deletions

View File

@ -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&#0045;platform&#0045;plan" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=670576&theme=light" alt="Documenso&#0032;Platform&#0032;Plan - Whitelabeled&#0032;signing&#0032;flows&#0032;in&#0032;your&#0032;product | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>

View File

@ -14,4 +14,4 @@
"public-api": "Public API",
"embedding": "Embedding",
"webhooks": "Webhooks"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.9.1-rc.8",
"version": "1.9.1-rc.1",
"private": true,
"license": "AGPL-3.0",
"scripts": {

View File

@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useSession } from 'next-auth/react';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import {
@ -55,6 +56,7 @@ export const EditDocumentForm = ({
const router = useRouter();
const searchParams = useSearchParams();
const team = useOptionalCurrentTeam();
const { data: session } = useSession();
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
@ -134,6 +136,18 @@ export const EditDocumentForm = ({
},
});
const { mutateAsync: selfSignDocument } = trpc.document.selfSignDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData }),
);
},
});
const { mutateAsync: setPasswordForDocument } =
trpc.document.setPasswordForDocument.useMutation();
@ -269,10 +283,22 @@ export const EditDocumentForm = ({
}
}
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
const hasSameOwnerAsRecipient =
recipients.length === 1 && recipients[0].email === session?.user?.email;
setStep('subject');
if (hasSameOwnerAsRecipient) {
await selfSignDocument({
documentId: document.id,
teamId: team?.id,
});
router.push(`/sign/${recipients[0].token}`);
} else {
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
setStep('subject');
}
} catch (err) {
console.error(err);

View File

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

View File

@ -311,11 +311,7 @@ export const SigningForm = ({
<>
<form onSubmit={handleSubmit(onFormSubmit)}>
<p className="text-muted-foreground mt-2 text-sm">
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
<Trans>Please review the document before approving.</Trans>
) : (
<Trans>Please review the document before signing.</Trans>
)}
<Trans>Please review the document before signing.</Trans>
</p>
<hr className="border-border mb-8 mt-4" />
@ -339,40 +335,38 @@ export const SigningForm = ({
/>
</div>
{hasSignatureField && (
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isSubmitting}
defaultValue={signature ?? undefined}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
onChange={(value) => {
if (signatureValid) {
setSignature(value);
}
}}
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
/>
</CardContent>
</Card>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isSubmitting}
defaultValue={signature ?? undefined}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
onChange={(value) => {
if (signatureValid) {
setSignature(value);
}
}}
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
/>
</CardContent>
</Card>
{!signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
</div>
)}
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row">

View File

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

View File

@ -1,7 +1,6 @@
import { useMemo, useState } from 'react';
import { Trans } from '@lingui/macro';
import { match } from 'ts-pattern';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import type { Field } from '@documenso/prisma/client';
@ -59,88 +58,62 @@ export const SignDialog = ({
loading={isSubmitting}
disabled={disabled}
>
{match({ isComplete, role })
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
<Trans>Mark as viewed</Trans>
))
.with({ isComplete: true }, () => <Trans>Complete</Trans>)
.exhaustive()}
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{match(role)
.with(RecipientRole.VIEWER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
{role === RecipientRole.VIEWER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.SIGNER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.SIGNER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.APPROVER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.APPROVER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
<br /> Are you sure?
</Trans>
</span>
))
.otherwise(() => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))}
.
</span>
<br /> Are you sure?
</Trans>
</span>
)}
</div>
<SigningDisclosure className="mt-4" />
@ -165,13 +138,9 @@ export const SignDialog = ({
loading={isSubmitting}
onClick={onSignatureComplete}
>
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()}
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
</Button>
</div>
</DialogFooter>

View File

@ -182,23 +182,6 @@ export const SigningFieldContainer = ({
</button>
)}
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
{
'bg-foreground/5 border-border border': !field.inserted,
},
{
'bg-documenso-200 border-primary border': field.inserted,
},
)}
>
{field.fieldMeta.label}
</div>
)}
{children}
</FieldRootContainer>
</div>

View File

@ -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>
@ -432,42 +417,40 @@ export const EmbedDirectTemplateClientPage = ({
/>
</div>
{hasSignatureField && (
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
allowTypedSignature={Boolean(
metadata &&
'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled,
)}
/>
</CardContent>
</Card>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
allowTypedSignature={Boolean(
metadata &&
'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled,
)}
/>
</CardContent>
</Card>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
</div>
)}
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
</div>
</div>
</div>

View File

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

View File

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

View File

@ -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>
@ -436,42 +372,40 @@ export const EmbedSignDocumentClientPage = ({
/>
</div>
{hasSignatureField && (
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
allowTypedSignature={Boolean(
metadata &&
'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled,
)}
/>
</CardContent>
</Card>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
allowTypedSignature={Boolean(
metadata &&
'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled,
)}
/>
</CardContent>
</Card>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
</div>
)}
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
</div>
</>
)}
</div>
@ -486,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)
}

View File

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

View File

@ -13,5 +13,4 @@ export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
.optional()
.transform((value) => value || undefined),
lockName: z.boolean().optional().default(false),
allowDocumentRejection: z.boolean().optional(),
});

View File

@ -336,6 +336,16 @@ export const DocumentHistorySheet = ({
]}
/>
))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.SELF_SIGN }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Signed by',
value: data.recipientEmail,
},
]}
/>
))
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED },
({ data }) => (

21
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.9.1-rc.8",
"version": "1.9.1-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.9.1-rc.8",
"version": "1.9.1-rc.1",
"workspaces": [
"apps/*",
"packages/*"
@ -106,7 +106,7 @@
},
"apps/web": {
"name": "@documenso/web",
"version": "1.9.1-rc.8",
"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"
}
}
}
}

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "1.9.1-rc.8",
"version": "1.9.1-rc.1",
"scripts": {
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",

View File

@ -586,7 +586,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
userId: user.id,
teamId: team?.id,
recipients: body.recipients,
prefillFields: body.prefillFields,
override: {
title: body.title,
...body.meta,

View File

@ -11,7 +11,7 @@ import {
ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import {
DocumentDataType,
DocumentDistributionMethod,
@ -299,7 +299,6 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
})
.optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(),
});
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<

View File

@ -1,612 +0,0 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../../fixtures/authentication';
test.describe('Template Field Prefill API v1', () => {
test('should create a document from template with prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Advanced Fields',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Number Field',
},
},
});
// Add RADIO field
const radioField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 5,
positionY: 25,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// Add CHECKBOX field
const checkboxField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.CHECKBOX,
page: 1,
positionX: 5,
positionY: 35,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'checkbox',
label: 'Checkbox Field',
values: [
{ id: 1, value: 'Check A', checked: false },
{ id: 2, value: 'Check B', checked: false },
],
},
},
});
// Add DROPDOWN field
const dropdownField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.DROPDOWN,
page: 1,
positionX: 5,
positionY: 45,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'dropdown',
label: 'Dropdown Field',
values: [{ value: 'Select A' }, { value: 'Select B' }],
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template with prefilled fields
const response = await request.post(
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
title: 'Document with Prefilled Fields',
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: 'SIGNER',
},
],
prefillFields: [
{
id: textField.id,
type: 'text',
label: 'Prefilled Text',
value: 'This is prefilled text',
},
{
id: numberField.id,
type: 'number',
label: 'Prefilled Number',
value: '42',
},
{
id: radioField.id,
type: 'radio',
label: 'Prefilled Radio',
value: 'Option A',
},
{
id: checkboxField.id,
type: 'checkbox',
label: 'Prefilled Checkbox',
value: ['Check A', 'Check B'],
},
{
id: dropdownField.id,
type: 'dropdown',
label: 'Prefilled Dropdown',
value: 'Select B',
},
],
},
},
);
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.documentId).toBeDefined();
// 9. Verify the document was created with prefilled fields
const document = await prisma.document.findUnique({
where: {
id: responseData.documentId,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify each field has the correct prefilled values
const documentTextField = document?.fields.find(
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Prefilled Text',
text: 'This is prefilled text',
});
const documentNumberField = document?.fields.find(
(field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number',
);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Prefilled Number',
value: '42',
});
const documentRadioField = document?.fields.find(
(field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio',
);
expect(documentRadioField?.fieldMeta).toMatchObject({
type: 'radio',
label: 'Prefilled Radio',
});
// Check that the correct radio option is selected
const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || [];
const selectedRadioOption = radioValues.find((option) => option.checked);
expect(selectedRadioOption?.value).toBe('Option A');
const documentCheckboxField = document?.fields.find(
(field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox',
);
expect(documentCheckboxField?.fieldMeta).toMatchObject({
type: 'checkbox',
label: 'Prefilled Checkbox',
});
// Check that the correct checkbox options are selected
const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || [];
const checkedOptions = checkboxValues.filter((option) => option.checked);
expect(checkedOptions.length).toBe(2);
expect(checkedOptions.map((option) => option.value)).toContain('Check A');
expect(checkedOptions.map((option) => option.value)).toContain('Check B');
const documentDropdownField = document?.fields.find(
(field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown',
);
expect(documentDropdownField?.fieldMeta).toMatchObject({
type: 'dropdown',
label: 'Prefilled Dropdown',
defaultValue: 'Select B',
});
// 11. Sign in as the recipient and verify the prefilled fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
// Send the document to the recipient
const sendResponse = await request.post(
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
sendEmail: false,
},
},
);
expect(sendResponse.ok()).toBeTruthy();
expect(sendResponse.status()).toBe(200);
expect(documentRecipient).not.toBeNull();
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
// Verify the prefilled fields are visible with correct values
// Text field
await expect(page.getByText('This is prefilled')).toBeVisible();
// Number field
await expect(page.getByText('42')).toBeVisible();
// Radio field
await expect(page.getByText('Option A')).toBeVisible();
await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked();
// Checkbox field
await expect(page.getByText('Check A')).toBeVisible();
await expect(page.getByText('Check B')).toBeVisible();
await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked();
// Dropdown field
await expect(page.getByText('Select B')).toBeVisible();
});
test('should create a document from template without prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Default Fields',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Default Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Default Number Field',
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template without prefilled fields
const response = await request.post(
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
title: 'Document with Default Fields',
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: 'SIGNER',
},
],
},
},
);
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.documentId).toBeDefined();
// 9. Verify the document was created with default fields
const document = await prisma.document.findUnique({
where: {
id: responseData.documentId,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify fields have their default values
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Default Text Field',
});
const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Default Number Field',
});
// 11. Sign in as the recipient and verify the default fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
expect(documentRecipient).not.toBeNull();
const sendResponse = await request.post(
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
sendEmail: false,
},
},
);
expect(sendResponse.ok()).toBeTruthy();
expect(sendResponse.status()).toBe(200);
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
// Verify the default fields are visible with correct labels
await expect(page.getByText('Default Text Field')).toBeVisible();
await expect(page.getByText('Default Number Field')).toBeVisible();
});
test('should handle invalid field prefill values', async ({ request }) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template using seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template for Invalid Test',
visibility: 'EVERYONE',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add a field to the template
const field = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 100,
positionY: 100,
width: 100,
height: 50,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// 6. Try to create a document with invalid prefill value
const response = await request.post(
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
title: 'Document with Invalid Prefill',
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: 'SIGNER',
},
],
prefillFields: [
{
id: field.id,
type: 'radio',
label: 'Invalid Radio',
value: 'Non-existent Option', // This option doesn't exist
},
],
},
},
);
// 7. Verify the request fails with appropriate error
expect(response.ok()).toBeFalsy();
expect(response.status()).toBe(400);
const errorData = await response.json();
expect(errorData.message).toContain('not found in options for RADIO field');
});
});

View File

@ -1,600 +0,0 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../../fixtures/authentication';
test.describe('Template Field Prefill API v2', () => {
test('should create a document from template with prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Advanced Fields V2',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Number Field',
},
},
});
// Add RADIO field
const radioField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 5,
positionY: 25,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// Add CHECKBOX field
const checkboxField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.CHECKBOX,
page: 1,
positionX: 5,
positionY: 35,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'checkbox',
label: 'Checkbox Field',
values: [
{ id: 1, value: 'Check A', checked: false },
{ id: 2, value: 'Check B', checked: false },
],
},
},
});
// Add DROPDOWN field
const dropdownField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.DROPDOWN,
page: 1,
positionX: 5,
positionY: 45,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'dropdown',
label: 'Dropdown Field',
values: [{ value: 'Select A' }, { value: 'Select B' }],
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template with prefilled fields using v2 API
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: template.id,
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
prefillFields: [
{
id: textField.id,
type: 'text',
label: 'Prefilled Text',
value: 'This is prefilled text',
},
{
id: numberField.id,
type: 'number',
label: 'Prefilled Number',
value: '42',
},
{
id: radioField.id,
type: 'radio',
label: 'Prefilled Radio',
value: 'Option A',
},
{
id: checkboxField.id,
type: 'checkbox',
label: 'Prefilled Checkbox',
value: ['Check A', 'Check B'],
},
{
id: dropdownField.id,
type: 'dropdown',
label: 'Prefilled Dropdown',
value: 'Select B',
},
],
},
});
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.id).toBeDefined();
// 9. Verify the document was created with prefilled fields
const document = await prisma.document.findUnique({
where: {
id: responseData.id,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify each field has the correct prefilled values
const documentTextField = document?.fields.find(
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Prefilled Text',
text: 'This is prefilled text',
});
const documentNumberField = document?.fields.find(
(field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number',
);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Prefilled Number',
value: '42',
});
const documentRadioField = document?.fields.find(
(field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio',
);
expect(documentRadioField?.fieldMeta).toMatchObject({
type: 'radio',
label: 'Prefilled Radio',
});
// Check that the correct radio option is selected
const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || [];
const selectedRadioOption = radioValues.find((option) => option.checked);
expect(selectedRadioOption?.value).toBe('Option A');
const documentCheckboxField = document?.fields.find(
(field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox',
);
expect(documentCheckboxField?.fieldMeta).toMatchObject({
type: 'checkbox',
label: 'Prefilled Checkbox',
});
// Check that the correct checkbox options are selected
const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || [];
const checkedOptions = checkboxValues.filter((option) => option.checked);
expect(checkedOptions.length).toBe(2);
expect(checkedOptions.map((option) => option.value)).toContain('Check A');
expect(checkedOptions.map((option) => option.value)).toContain('Check B');
const documentDropdownField = document?.fields.find(
(field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown',
);
expect(documentDropdownField?.fieldMeta).toMatchObject({
type: 'dropdown',
label: 'Prefilled Dropdown',
defaultValue: 'Select B',
});
const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
documentId: document?.id,
meta: {
subject: 'Test Subject',
message: 'Test Message',
},
},
});
await expect(sendResponse.ok()).toBeTruthy();
await expect(sendResponse.status()).toBe(200);
// 11. Sign in as the recipient and verify the prefilled fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
expect(documentRecipient).not.toBeNull();
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
// Verify the prefilled fields are visible with correct values
// Text field
await expect(page.getByText('This is prefilled')).toBeVisible();
// Number field
await expect(page.getByText('42')).toBeVisible();
// Radio field
await expect(page.getByText('Option A')).toBeVisible();
await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked();
// Checkbox field
await expect(page.getByText('Check A')).toBeVisible();
await expect(page.getByText('Check B')).toBeVisible();
await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked();
// Dropdown field
await expect(page.getByText('Select B')).toBeVisible();
});
test('should create a document from template without prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Default Fields V2',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Default Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Default Number Field',
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template without prefilled fields using v2 API
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: template.id,
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
},
});
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.id).toBeDefined();
// 9. Verify the document was created with default fields
const document = await prisma.document.findUnique({
where: {
id: responseData.id,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify fields have their default values
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Default Text Field',
});
const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Default Number Field',
});
const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
documentId: document?.id,
meta: {
subject: 'Test Subject',
message: 'Test Message',
},
},
});
await expect(sendResponse.ok()).toBeTruthy();
await expect(sendResponse.status()).toBe(200);
// 11. Sign in as the recipient and verify the default fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
expect(documentRecipient).not.toBeNull();
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
await expect(page.getByText('This is prefilled')).not.toBeVisible();
});
test('should handle invalid field prefill values', async ({ request }) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template using seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template for Invalid Test V2',
visibility: 'EVERYONE',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add a field to the template
const field = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 100,
positionY: 100,
width: 100,
height: 50,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// 7. Try to create a document with invalid prefill value
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: template.id,
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
prefillFields: [
{
id: field.id,
type: 'radio',
label: 'Invalid Radio',
value: 'Non-existent Option', // This option doesn't exist
},
],
},
});
// 8. Verify the request fails with appropriate error
expect(response.ok()).toBeFalsy();
expect(response.status()).toBe(400);
const errorData = await response.json();
expect(errorData.message).toContain('not found in options for RADIO field');
});
});

View File

@ -2,7 +2,9 @@ import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import path from 'node:path';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id';
import { prisma } from '@documenso/prisma';
import {
DocumentSigningOrder,
@ -384,9 +386,7 @@ test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) =
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
await page
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Complete' : 'Approve' })
.click();
await page.getByRole('button', { name: 'Complete' }).click();
await page
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
.click();
@ -456,7 +456,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Approve' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible();
await page.getByRole('button', { name: 'Approve' }).click();
@ -614,7 +614,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
expect(previousRecipient?.signingStatus).toBe(SigningStatus.SIGNED);
}
await page.goto(`/sign/${recipient?.token}`);
await page.goto(`/sign/${recipient!.token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.locator(`#field-${recipientField.id}`).getByRole('button').click();
@ -632,24 +632,22 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${recipient?.token}/complete`);
await page.waitForURL(`/sign/${recipient!.token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
const updatedRecipient = await prisma.recipient.findFirst({
where: { id: recipient?.id },
const updatedRecipient = await getRecipientById({
documentId: document.id,
id: recipient!.id,
});
expect(updatedRecipient?.signingStatus).toBe(SigningStatus.SIGNED);
}
// Wait for the document to be signed.
await page.waitForTimeout(5000);
const finalDocument = await prisma.document.findFirst({
where: { id: createdDocument?.id },
});
expect(finalDocument?.status).toBe(DocumentStatus.COMPLETED);
await expect(async () => {
const signedDocument = await getDocumentById({ id: document.id, userId: user.id });
expect(signedDocument?.status).toBe(DocumentStatus.COMPLETED);
}).toPass();
});
test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode', async ({
@ -657,7 +655,7 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
}) => {
const user = await seedUser();
const { document, recipients } = await seedPendingDocumentWithFullFields({
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['user1@example.com', 'user2@example.com', 'user3@example.com'],
fields: [FieldType.SIGNATURE],
@ -684,3 +682,85 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
await expect(page).not.toHaveURL(`/sign/${activeRecipient?.token}/waiting`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
});
test('[DOCUMENT_FLOW]: should be able to self sign a document', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
const documentTitle = `Self-Signing-${Date.now()}.pdf`;
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByRole('button', { name: 'Add myself' }).click();
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('button', { name: 'Sign', exact: true }).click();
const documentRecipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
});
const { token, email, id: recipientId } = documentRecipients[0];
expect(documentRecipients.length).toBe(1);
expect(email).toBe(user.email);
await page.waitForURL(`/sign/${token}`);
await expect(page.getByRole('heading', { name: documentTitle })).toBeVisible();
const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING);
const fields = await prisma.field.findMany({
where: { recipientId, documentId: document.id },
});
const recipientField = fields[0];
expect(recipientField).not.toBeNull();
await page.locator(`#field-${recipientField.id}`).getByRole('button').click();
const canvas = page.locator('canvas#signature');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
await page.getByRole('button', { name: 'Sign', exact: true }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
const updatedRecipient = await getRecipientById({ documentId: document.id, id: recipientId });
expect(updatedRecipient?.signingStatus).toBe(SigningStatus.SIGNED);
await expect(async () => {
const signedDocument = await getDocumentById({ id: document.id, userId: user.id });
expect(signedDocument?.status).toBe(DocumentStatus.COMPLETED);
}).toPass();
});

View File

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

View File

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

View File

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

View File

@ -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' } } } },
],

View File

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

View File

@ -0,0 +1,177 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
import { getFile } from '../../universal/upload/get-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
export type SelfSignDocumentOptions = {
documentId: number;
userId: number;
teamId?: number;
requestMetadata?: ApiRequestMetadata;
};
export const selfSignDocument = async ({
documentId,
userId,
teamId,
requestMetadata,
}: SelfSignDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
const document = await prisma.document.findUnique({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
recipients: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
},
documentMeta: true,
documentData: true,
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.recipients.length === 0) {
throw new Error('Document has no recipients');
}
if (document.recipients.length !== 1 || document.recipients[0].email !== user.email) {
throw new Error('Invalid document for self-signing');
}
if (document.status === DocumentStatus.COMPLETED) {
throw new Error('Can not sign completed document');
}
const { documentData } = document;
if (!documentData || !documentData.data) {
throw new Error('Document data not found');
}
if (document.formValues) {
const file = await getFile(documentData);
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(file),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
formValues: document.formValues as Record<string, string | number | boolean>,
});
const newDocumentData = await putPdfFile({
name: document.title,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
});
const result = await prisma.document.update({
where: {
id: document.id,
},
data: {
documentDataId: newDocumentData.id,
},
});
Object.assign(document, result);
}
const recipientHasNoActionToTake =
document.recipients[0].role === RecipientRole.CC ||
document.recipients[0].signingStatus === SigningStatus.SIGNED;
if (recipientHasNoActionToTake) {
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId,
requestMetadata: requestMetadata?.requestMetadata,
},
});
return await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
recipients: true,
},
});
}
const updatedDocument = await prisma.$transaction(async (tx) => {
if (document.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.SELF_SIGN,
documentId: document.id,
requestMetadata: requestMetadata?.requestMetadata,
user,
data: {
recipientId: document.recipients[0].id,
recipientEmail: document.recipients[0].email,
recipientName: document.recipients[0].name,
recipientRole: document.recipients[0].role,
},
}),
});
}
await tx.recipient.update({
where: {
id: document.recipients[0].id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
return await tx.document.update({
where: {
id: documentId,
},
data: {
status: DocumentStatus.PENDING,
},
include: {
recipients: true,
},
});
});
return updatedDocument;
};

View File

@ -94,7 +94,7 @@ export const sendDocument = async ({
const { documentData } = document;
if (!documentData.data) {
if (!documentData || !documentData.data) {
throw new Error('Document data not found');
}

View File

@ -1,5 +1,3 @@
import { match } from 'ts-pattern';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { DocumentDistributionMethod } from '@documenso/prisma/client';
@ -19,20 +17,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import type { TDocumentEmailSettings } from '../../types/document-email';
import type {
TCheckboxFieldMeta,
TDropdownFieldMeta,
TFieldMetaPrefillFieldsSchema,
TNumberFieldMeta,
TRadioFieldMeta,
TTextFieldMeta,
} from '../../types/field-meta';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldMetaSchema,
ZRadioFieldMeta,
} from '../../types/field-meta';
import { ZFieldMetaSchema } from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
@ -65,7 +50,6 @@ export type CreateDocumentFromTemplateOptions = {
email: string;
signingOrder?: number | null;
}[];
prefillFields?: TFieldMetaPrefillFieldsSchema[];
customDocumentDataId?: string;
/**
@ -88,165 +72,6 @@ export type CreateDocumentFromTemplateOptions = {
requestMetadata: ApiRequestMetadata;
};
const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillFieldsSchema) => {
if (!prefillField) {
return field.fieldMeta;
}
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(field.type);
if (!advancedField) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Field ${field.id} is not an advanced field and cannot have field meta information. Allowed types: NUMBER, RADIO, CHECKBOX, DROPDOWN, TEXT.`,
});
}
// We've already validated that the field types match at a higher level
// Start with the existing field meta or an empty object
const existingMeta = field.fieldMeta || {};
// Apply type-specific updates based on the prefill field type using ts-pattern
return match(prefillField)
.with({ type: 'text' }, (field) => {
if (typeof field.value !== 'string') {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid value for TEXT field ${field.id}: expected string, got ${typeof field.value}`,
});
}
const meta: TTextFieldMeta = {
...existingMeta,
type: 'text',
label: field.label,
text: field.value,
};
return meta;
})
.with({ type: 'number' }, (field) => {
if (typeof field.value !== 'string') {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid value for NUMBER field ${field.id}: expected string, got ${typeof field.value}`,
});
}
const meta: TNumberFieldMeta = {
...existingMeta,
type: 'number',
label: field.label,
value: field.value,
};
return meta;
})
.with({ type: 'radio' }, (field) => {
if (typeof field.value !== 'string') {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid value for RADIO field ${field.id}: expected string, got ${typeof field.value}`,
});
}
const result = ZRadioFieldMeta.safeParse(existingMeta);
if (!result.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field meta for RADIO field ${field.id}`,
});
}
const radioMeta = result.data;
// Validate that the value exists in the options
const valueExists = radioMeta.values?.some((option) => option.value === field.value);
if (!valueExists) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Value "${field.value}" not found in options for RADIO field ${field.id}`,
});
}
const newValues = radioMeta.values?.map((option) => ({
...option,
checked: option.value === field.value,
}));
const meta: TRadioFieldMeta = {
...existingMeta,
type: 'radio',
label: field.label,
values: newValues,
};
return meta;
})
.with({ type: 'checkbox' }, (field) => {
const result = ZCheckboxFieldMeta.safeParse(existingMeta);
if (!result.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field meta for CHECKBOX field ${field.id}`,
});
}
const checkboxMeta = result.data;
// Validate that all values exist in the options
for (const value of field.value) {
const valueExists = checkboxMeta.values?.some((option) => option.value === value);
if (!valueExists) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Value "${value}" not found in options for CHECKBOX field ${field.id}`,
});
}
}
const newValues = checkboxMeta.values?.map((option) => ({
...option,
checked: field.value.includes(option.value),
}));
const meta: TCheckboxFieldMeta = {
...existingMeta,
type: 'checkbox',
label: field.label,
values: newValues,
};
return meta;
})
.with({ type: 'dropdown' }, (field) => {
const result = ZDropdownFieldMeta.safeParse(existingMeta);
if (!result.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field meta for DROPDOWN field ${field.id}`,
});
}
const dropdownMeta = result.data;
// Validate that the value exists in the options if values are defined
const valueExists = dropdownMeta.values?.some((option) => option.value === field.value);
if (!valueExists) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Value "${field.value}" not found in options for DROPDOWN field ${field.id}`,
});
}
const meta: TDropdownFieldMeta = {
...existingMeta,
type: 'dropdown',
label: field.label,
defaultValue: field.value,
};
return meta;
})
.otherwise(() => field.fieldMeta);
};
export const createDocumentFromTemplate = async ({
templateId,
externalId,
@ -256,7 +81,6 @@ export const createDocumentFromTemplate = async ({
customDocumentDataId,
override,
requestMetadata,
prefillFields,
}: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({
where: {
@ -434,47 +258,6 @@ export const createDocumentFromTemplate = async ({
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
// Get all template field IDs first so we can validate later
const allTemplateFieldIds = finalRecipients.flatMap((recipient) =>
recipient.fields.map((field) => field.id),
);
if (prefillFields?.length) {
// Validate that all prefill field IDs exist in the template
const invalidFieldIds = prefillFields
.map((prefillField) => prefillField.id)
.filter((id) => !allTemplateFieldIds.includes(id));
if (invalidFieldIds.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `The following field IDs do not exist in the template: ${invalidFieldIds.join(', ')}`,
});
}
// Validate that all prefill fields have the correct type
for (const prefillField of prefillFields) {
const templateField = finalRecipients
.flatMap((recipient) => recipient.fields)
.find((field) => field.id === prefillField.id);
if (!templateField) {
// This should never happen due to the previous validation, but just in case
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Field with ID ${prefillField.id} not found in the template`,
});
}
const expectedType = templateField.type.toLowerCase();
const actualType = prefillField.type;
if (expectedType !== actualType) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Field type mismatch for field ${prefillField.id}: expected ${expectedType}, got ${actualType}`,
});
}
}
}
Object.values(finalRecipients).forEach(({ email, fields }) => {
const recipient = document.recipients.find((recipient) => recipient.email === email);
@ -483,25 +266,19 @@ export const createDocumentFromTemplate = async ({
}
fieldsToCreate = fieldsToCreate.concat(
fields.map((field) => {
const prefillField = prefillFields?.find((value) => value.id === field.id);
// Use type assertion to help TypeScript understand the structure
const updatedFieldMeta = getUpdatedFieldMeta(field, prefillField);
return {
documentId: document.id,
recipientId: recipient.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: updatedFieldMeta,
};
}),
fields.map((field) => ({
documentId: document.id,
recipientId: recipient.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
})),
);
});

View File

@ -13,6 +13,7 @@ import { ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthTypesSchema } from
export const ZDocumentAuditLogTypeSchema = z.enum([
// Document actions.
'EMAIL_SENT',
'SELF_SIGN',
// Document modification events.
'FIELD_CREATED',
@ -181,6 +182,14 @@ export const ZDocumentAuditLogEventEmailSentSchema = z.object({
}),
});
/**
* Event: Self sign
*/
export const ZDocumentAuditLogSelfSignSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.SELF_SIGN),
data: ZBaseRecipientDataSchema,
});
/**
* Event: Document completed.
*/
@ -566,6 +575,7 @@ export const ZDocumentAuditLogBaseSchema = z.object({
export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
z.union([
ZDocumentAuditLogEventEmailSentSchema,
ZDocumentAuditLogSelfSignSchema,
ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentDeletedSchema,

View File

@ -123,42 +123,6 @@ export const ZFieldMetaNotOptionalSchema = z.discriminatedUnion('type', [
export type TFieldMetaNotOptionalSchema = z.infer<typeof ZFieldMetaNotOptionalSchema>;
export const ZFieldMetaPrefillFieldsSchema = z
.object({
id: z.number(),
})
.and(
z.discriminatedUnion('type', [
z.object({
type: z.literal('text'),
label: z.string(),
value: z.string(),
}),
z.object({
type: z.literal('number'),
label: z.string(),
value: z.string(),
}),
z.object({
type: z.literal('radio'),
label: z.string(),
value: z.string(),
}),
z.object({
type: z.literal('checkbox'),
label: z.string(),
value: z.array(z.string()),
}),
z.object({
type: z.literal('dropdown'),
label: z.string(),
value: z.string(),
}),
]),
);
export type TFieldMetaPrefillFieldsSchema = z.infer<typeof ZFieldMetaPrefillFieldsSchema>;
export const ZFieldMetaSchema = z
.union([
// Handles an empty object being provided as fieldMeta.

View File

@ -369,16 +369,6 @@ export const formatDocumentAuditLogAction = (
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} rejected the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
identified: data.isResending
@ -389,6 +379,14 @@ export const formatDocumentAuditLogAction = (
anonymous: msg`Document completed`,
identified: msg`Document completed`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.SELF_SIGN }, () => ({
anonymous: msg`Self-signed document`,
identified: msg`${prefix} self-signed the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => ({
anonymous: msg`Document rejected`,
identified: msg`${prefix} rejected the document: ${data.reason}`,
}))
.exhaustive();
return {

View File

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

View File

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

View File

@ -5,18 +5,6 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
import { DocumentDataType, DocumentSource, Role, TeamMemberRole } from '../client';
import { seedPendingDocument } from './documents';
import { seedDirectTemplate, seedTemplate } from './templates';
const createDocumentData = async ({ documentData }: { documentData: string }) => {
return prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: documentData,
initialData: documentData,
},
});
};
export const seedDatabase = async () => {
const examplePdf = fs
@ -51,80 +39,35 @@ export const seedDatabase = async () => {
update: {},
});
for (let i = 1; i <= 4; i++) {
const documentData = await createDocumentData({ documentData: examplePdf });
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: `Example Document ${i}`,
documentDataId: documentData.id,
userId: exampleUser.id,
recipients: {
create: {
name: String(adminUser.name),
email: adminUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
});
}
for (let i = 1; i <= 4; i++) {
const documentData = await createDocumentData({ documentData: examplePdf });
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: `Document ${i}`,
documentDataId: documentData.id,
userId: adminUser.id,
recipients: {
create: {
name: String(exampleUser.name),
email: exampleUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
});
}
await seedPendingDocument(exampleUser, [adminUser], {
key: 'example-pending',
createDocumentOptions: {
title: 'Pending Document',
const examplePdfData = await prisma.documentData.upsert({
where: {
id: 'clmn0kv5k0000pe04vcqg5zla',
},
create: {
id: 'clmn0kv5k0000pe04vcqg5zla',
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
update: {},
});
await seedPendingDocument(adminUser, [exampleUser], {
key: 'admin-pending',
createDocumentOptions: {
title: 'Pending Document',
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: 'Example Document',
documentDataId: examplePdfData.id,
userId: exampleUser.id,
recipients: {
create: {
name: String(adminUser.name),
email: adminUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
});
await Promise.all([
seedTemplate({
title: 'Template 1',
userId: exampleUser.id,
}),
seedDirectTemplate({
title: 'Direct Template 1',
userId: exampleUser.id,
}),
seedTemplate({
title: 'Template 1',
userId: adminUser.id,
}),
seedDirectTemplate({
title: 'Direct Template 1',
userId: adminUser.id,
}),
]);
const testUsers = [
'test@documenso.com',
'test2@documenso.com',

View File

@ -20,6 +20,7 @@ import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/
import { moveDocumentToTeam } from '@documenso/lib/server-only/document/move-document-to-team';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
import { selfSignDocument } from '@documenso/lib/server-only/document/self-sign-document';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
@ -50,6 +51,7 @@ import {
ZMoveDocumentToTeamSchema,
ZResendDocumentMutationSchema,
ZSearchDocumentsMutationSchema,
ZSelfSignDocumentMutationSchema,
ZSetPasswordForDocumentMutationSchema,
ZSetSigningOrderForDocumentMutationSchema,
ZSuccessResponseSchema,
@ -467,6 +469,28 @@ export const documentRouter = router({
});
}),
selfSignDocument: authenticatedProcedure
.input(ZSelfSignDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, teamId } = input;
return await selfSignDocument({
userId: ctx.user.id,
documentId,
teamId,
requestMetadata: ctx.metadata,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to self sign this document. Please try again later.',
});
}
}),
/**
* @public
*

View File

@ -293,6 +293,11 @@ export const ZDistributeDocumentRequestSchema = z.object({
.optional(),
});
export const ZSelfSignDocumentMutationSchema = z.object({
documentId: z.number(),
teamId: z.number().optional(),
});
export const ZDistributeDocumentResponseSchema = ZDocumentLiteSchema;
export const ZSetPasswordForDocumentMutationSchema = z.object({

View File

@ -227,8 +227,7 @@ export const templateRouter = router({
.output(ZCreateDocumentFromTemplateResponseSchema)
.mutation(async ({ ctx, input }) => {
const { teamId } = ctx;
const { templateId, recipients, distributeDocument, customDocumentDataId, prefillFields } =
input;
const { templateId, recipients, distributeDocument, customDocumentDataId } = input;
const limits = await getServerLimits({ email: ctx.user.email, teamId });
@ -243,7 +242,6 @@ export const templateRouter = router({
recipients,
customDocumentDataId,
requestMetadata: ctx.metadata,
prefillFields,
});
if (distributeDocument) {

View File

@ -6,7 +6,6 @@ import {
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import {
ZTemplateLiteSchema,
@ -68,7 +67,6 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
'The data ID of an alternative PDF to use when creating the document. If not provided, the PDF attached to the template will be used.',
)
.optional(),
prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(),
});
export const ZCreateDocumentFromTemplateResponseSchema = ZDocumentSchema;

View File

@ -21,6 +21,7 @@ import {
Type,
User,
} from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { prop, sortBy } from 'remeda';
@ -120,6 +121,7 @@ export const AddFieldsFormPartial = ({
teamId,
}: AddFieldsFormProps) => {
const { toast } = useToast();
const { data: session } = useSession();
const { _ } = useLingui();
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
@ -558,6 +560,10 @@ export const AddFieldsFormPartial = ({
);
}, [recipientsByRole]);
const hasSameOwnerAsRecipient =
recipientsByRole.SIGNER.length === 1 &&
recipientsByRole.SIGNER[0].email === session?.user?.email;
const handleAdvancedSettings = () => {
setShowAdvancedSettings((prev) => !prev);
};
@ -1132,6 +1138,7 @@ export const AddFieldsFormPartial = ({
documentFlow.onBackStep?.();
}}
goBackLabel={canRenderBackButtonAsRemove ? msg`Remove` : undefined}
goNextLabel={hasSameOwnerAsRecipient ? msg`Sign` : undefined}
onGoNextClick={handleGoNextClick}
/>
</DocumentFlowFormContainerFooter>

View File

@ -12,7 +12,6 @@ import { match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
import { useSignerColors } from '../../lib/signer-colors';
import { cn } from '../../lib/utils';
@ -186,35 +185,11 @@ export const FieldItem = ({
() => hasFieldMetaValues('CHECKBOX', field.fieldMeta, ZCheckboxFieldMeta),
[field.fieldMeta],
);
const radioHasValues = useMemo(
() => hasFieldMetaValues('RADIO', field.fieldMeta, ZRadioFieldMeta),
[field.fieldMeta],
);
const hasCheckedValues = (fieldMeta: TFieldMetaSchema, type: FieldType) => {
if (!fieldMeta || (type !== FieldType.RADIO && type !== FieldType.CHECKBOX)) {
return false;
}
if (type === FieldType.RADIO) {
const parsed = ZRadioFieldMeta.parse(fieldMeta);
return parsed.values?.some((value) => value.checked) ?? false;
}
if (type === FieldType.CHECKBOX) {
const parsed = ZCheckboxFieldMeta.parse(fieldMeta);
return parsed.values?.some((value) => value.checked) ?? false;
}
return false;
};
const fieldHasCheckedValues = useMemo(
() => hasCheckedValues(field.fieldMeta, field.type),
[field.fieldMeta, field.type],
);
const fixedSize = checkBoxHasValues || radioHasValues;
return createPortal(
@ -254,21 +229,6 @@ export const FieldItem = ({
onMove?.(d.node);
}}
>
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
{
'bg-foreground/5 border-primary border': !fieldHasCheckedValues,
'bg-documenso-200 border-primary border': fieldHasCheckedValues,
},
)}
>
{field.fieldMeta.label}
</div>
)}
<div
className={cn(
'relative flex h-full w-full items-center justify-center bg-white',

View File

@ -126,18 +126,6 @@ export const CheckboxFieldAdvancedSettings = ({
return (
<div className="flex flex-col gap-4">
<div className="mb-2">
<Label>
<Trans>Label</Trans>
</Label>
<Input
id="label"
className="bg-background mt-2"
placeholder={_(msg`Field label`)}
value={fieldState.label}
onChange={(e) => handleFieldChange('label', e.target.value)}
/>
</div>
<div className="flex flex-row items-center gap-x-4">
<div className="flex w-2/3 flex-col">
<Label>

View File

@ -105,12 +105,8 @@ export const DropdownFieldAdvancedSettings = ({
<Trans>Select default option</Trans>
</Label>
<Select
value={defaultValue}
defaultValue={defaultValue}
onValueChange={(val) => {
if (!val) {
return;
}
setDefaultValue(val);
handleFieldChange('defaultValue', val);
}}
@ -176,7 +172,7 @@ export const DropdownFieldAdvancedSettings = ({
/>
<button
type="button"
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>
<Trash className="h-5 w-5" />

View File

@ -2,8 +2,7 @@
import { useEffect, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/macro';
import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
@ -28,8 +27,6 @@ export const RadioFieldAdvancedSettings = ({
handleFieldChange,
handleErrors,
}: RadioFieldAdvancedSettingsProps) => {
const { _ } = useLingui();
const [showValidation, setShowValidation] = useState(false);
const [values, setValues] = useState(
fieldState.values ?? [{ id: 1, checked: false, value: 'Default value' }],
@ -105,18 +102,6 @@ export const RadioFieldAdvancedSettings = ({
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div>
<Label>
<Trans>Label</Trans>
</Label>
<Input
id="label"
className="bg-background mt-2"
placeholder={_(msg`Field label`)}
value={fieldState.label}
onChange={(e) => handleFieldChange('label', e.target.value)}
/>
</div>
<div className="flex flex-row items-center gap-2">
<Switch
className="bg-background"

View File

@ -126,13 +126,7 @@ export const TextFieldAdvancedSettings = ({
<Select
value={fieldState.textAlign}
onValueChange={(value) => {
if (!value) {
return;
}
handleInput('textAlign', value);
}}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue placeholder="Select text align" />