Compare commits

..

15 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan
ce94cf0051 fix: build errors 2025-02-06 11:55:29 +00:00
Ephraim Atta-Duncan
bccf4cd368 fix: merge conflicts 2025-02-06 11:47:44 +00:00
Ephraim Atta-Duncan
c13988bb8f chore: remove conflicting translations 2025-02-06 11:41:29 +00:00
Ephraim Duncan
9f1831afcb Merge branch 'main' into experiment/self-sign 2024-11-21 17:50:07 +00:00
Ephraim Atta-Duncan
574a7449fa chore: merge with main 2024-11-18 07:30:44 +00:00
Ephraim Duncan
1aee1bb4cd Merge branch 'main' into experiment/self-sign 2024-11-15 20:33:34 +00:00
Ephraim Atta-Duncan
634dc2afd0 fix: self sign team documents 2024-11-15 20:25:09 +00:00
Ephraim Atta-Duncan
21d68f3275 fix: merge conflicts 2024-11-15 11:03:52 +00:00
Ephraim Atta-Duncan
63c98949bb chore: merge main 2024-10-25 14:20:29 +00:00
Ephraim Atta-Duncan
4348a949dd chore: changes based on review 2024-10-18 01:12:43 +00:00
Ephraim Atta-Duncan
2a098f89fa chore: add tests for self signing 2024-10-17 13:17:44 +00:00
Ephraim Atta-Duncan
bb805ea93b fix: audit logs 2024-10-16 23:29:40 +00:00
Ephraim Atta-Duncan
cc8b972fbc fix: recipient status stuck on uncompleted 2024-10-16 19:32:46 +00:00
Ephraim Atta-Duncan
b55c419074 chore: minor changes 2024-10-16 19:03:14 +00:00
Ephraim Atta-Duncan
f9e3993519 feat: avoid sending document if the owner is the only recipient 2024-10-16 17:10:01 +00:00
36 changed files with 484 additions and 550 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> <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", "public-api": "Public API",
"embedding": "Embedding", "embedding": "Embedding",
"webhooks": "Webhooks" "webhooks": "Webhooks"
} }

View File

@@ -6,6 +6,5 @@
"solid": "Solid Integration", "solid": "Solid Integration",
"preact": "Preact Integration", "preact": "Preact Integration",
"angular": "Angular Integration", "angular": "Angular Integration",
"css-variables": "CSS Variables", "css-variables": "CSS Variables"
"web-components": "Web Components"
} }

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: We support embedding across a range of popular JavaScript frameworks, including:
| Framework | Package | | Framework | Package |
| --------- | ---------------------------------------------------------------------------------- | | --------- | ---------------------------------------------------------------------------------- |
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) | | React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) | | Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) | | Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) | | Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) | | Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
| Angular | [@documenso/embed-angular](https://www.npmjs.com/package/@documenso/embed-angular) | | 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) |
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. 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) - [Svelte](/developers/embedding/svelte)
- [Solid](/developers/embedding/solid) - [Solid](/developers/embedding/solid)
- [Angular](/developers/embedding/angular) - [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. 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) - [Solid Integration](/developers/embedding/solid)
- [Preact Integration](/developers/embedding/preact) - [Preact Integration](/developers/embedding/preact)
- [Angular Integration](/developers/embedding/angular) - [Angular Integration](/developers/embedding/angular)
- [Web Components](/developers/embedding/web-components)
- [CSS Variables](/developers/embedding/css-variables) - [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 ## 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. <Callout type="warning">
NOW IN BETA: [API V2 Documentation](https://documen.so/api-v2-docs)
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> </Callout>
🚀 [V2 Announcement](https://documen.so/sdk-blog) 🚀 [V2 Announcement](https://documen.so/sdk-blog)
📖 [Documentation](https://documen.so/api-v2-docs)
💬 [Leave Feedback](https://documen.so/sdk-feedback) 💬 [Leave Feedback](https://documen.so/sdk-feedback)
🔔 [Breaking Changes](https://documen.so/sdk-breaking) 🔔 [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. Documenso has 4 roles for recipients with different permissions and actions.
| Role | Function | Action required | Signature | | Role | Function | Action required | Signature |
| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: | | :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes | | Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional | | Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
| Viewer | Needs to confirm they viewed the document. | Yes | No | | 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 | | BCC | Receives a copy of the signed document after completion. No action is required. | No | No |
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
### Fields ### Fields

View File

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

View File

@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro'; import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { useSession } from 'next-auth/react';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n'; import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import { import {
@@ -55,6 +56,7 @@ export const EditDocumentForm = ({
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const team = useOptionalCurrentTeam(); const team = useOptionalCurrentTeam();
const { data: session } = useSession();
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); 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 } = const { mutateAsync: setPasswordForDocument } =
trpc.document.setPasswordForDocument.useMutation(); 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. const hasSameOwnerAsRecipient =
router.refresh(); 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) { } catch (err) {
console.error(err); console.error(err);

View File

@@ -44,12 +44,7 @@ export const CheckboxField = ({ field, onSignField, onUnsignField }: CheckboxFie
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const parsedFieldMeta = ZCheckboxFieldMeta.parse( const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
field.fieldMeta ?? {
type: 'checkbox',
values: [{ id: 1, checked: false, value: '' }],
},
);
const values = parsedFieldMeta.values?.map((item) => ({ const values = parsedFieldMeta.values?.map((item) => ({
...item, ...item,

View File

@@ -43,10 +43,9 @@ type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
export interface RejectDocumentDialogProps { export interface RejectDocumentDialogProps {
document: Pick<Document, 'id'>; document: Pick<Document, 'id'>;
token: string; 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 { toast } = useToast();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -80,11 +79,7 @@ export function RejectDocumentDialog({ document, token, onRejected }: RejectDocu
setIsOpen(false); setIsOpen(false);
if (onRejected) { router.push(`/sign/${token}/rejected`);
await onRejected(reason);
} else {
router.push(`/sign/${token}/rejected`);
}
} catch (err) { } catch (err) {
toast({ toast({
title: 'Error', title: 'Error',

View File

@@ -49,7 +49,7 @@ export type EmbedDirectTemplateClientPageProps = {
fields: Field[]; fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null; metadata?: DocumentMeta | TemplateMeta | null;
hidePoweredBy?: boolean; hidePoweredBy?: boolean;
allowWhiteLabelling?: boolean; isPlatformOrEnterprise?: boolean;
}; };
export const EmbedDirectTemplateClientPage = ({ export const EmbedDirectTemplateClientPage = ({
@@ -60,7 +60,7 @@ export const EmbedDirectTemplateClientPage = ({
fields, fields,
metadata, metadata,
hidePoweredBy = false, hidePoweredBy = false,
allowWhiteLabelling = false, isPlatformOrEnterprise = false,
}: EmbedDirectTemplateClientPageProps) => { }: EmbedDirectTemplateClientPageProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
@@ -288,7 +288,7 @@ export const EmbedDirectTemplateClientPage = ({
document.documentElement.classList.add('dark-mode-disabled'); document.documentElement.classList.add('dark-mode-disabled');
} }
if (allowWhiteLabelling) { if (isPlatformOrEnterprise) {
injectCss({ injectCss({
css: data.css, css: data.css,
cssVars: data.cssVars, cssVars: data.cssVars,
@@ -349,7 +349,7 @@ export const EmbedDirectTemplateClientPage = ({
{/* Widget */} {/* Widget */}
<div <div
key={isExpanded ? 'expanded' : 'collapsed'} 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} 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"> <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> <Trans>Sign document</Trans>
</h3> </h3>
{isExpanded ? ( <Button variant="outline" className="h-8 w-8 p-0 md:hidden">
<Button {isExpanded ? (
variant="outline" <LucideChevronDown
className="h-8 w-8 p-0 md:hidden" className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(false)} onClick={() => setIsExpanded(false)}
> />
<LucideChevronDown className="text-muted-foreground h-5 w-5" /> ) : (
</Button> <LucideChevronUp
) : pendingFields.length > 0 ? ( className="text-muted-foreground h-5 w-5"
<Button onClick={() => setIsExpanded(true)}
variant="outline" />
className="h-8 w-8 p-0 md:hidden" )}
onClick={() => setIsExpanded(true)} </Button>
>
<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>
)}
</div> </div>
</div> </div>

View File

@@ -2,7 +2,6 @@ import { notFound } from 'next/navigation';
import { match } from 'ts-pattern'; 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 { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform'; import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@@ -56,16 +55,12 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
documentAuth: template.authOptions, documentAuth: template.authOptions,
}); });
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([ const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
isDocumentPlatform(template), isDocumentPlatform(template),
isUserEnterprise({ isUserEnterprise({
userId: template.userId, userId: template.userId,
teamId: template.teamId ?? undefined, teamId: template.teamId ?? undefined,
}), }),
isUserCommunityPlan({
userId: template.userId,
teamId: template.teamId ?? undefined,
}),
]); ]);
const isAccessAuthValid = match(derivedRecipientAccessAuth) const isAccessAuthValid = match(derivedRecipientAccessAuth)
@@ -110,10 +105,8 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
recipient={recipient} recipient={recipient}
fields={fields} fields={fields}
metadata={template.templateMeta} metadata={template.templateMeta}
hidePoweredBy={ hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
}
allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
/> />
</RecipientProvider> </RecipientProvider>
</DocumentAuthProvider> </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 { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client'; import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
import { import { type DocumentData, type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
type DocumentData,
type Field,
FieldType,
RecipientRole,
SigningStatus,
} from '@documenso/prisma/client';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
@@ -32,13 +26,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context'; 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 { Logo } from '~/components/branding/logo';
import { EmbedClientLoading } from '../../client-loading'; import { EmbedClientLoading } from '../../client-loading';
import { EmbedDocumentCompleted } from '../../completed'; import { EmbedDocumentCompleted } from '../../completed';
import { EmbedDocumentFields } from '../../document-fields'; import { EmbedDocumentFields } from '../../document-fields';
import { EmbedDocumentRejected } from '../../rejected';
import { injectCss } from '../../util'; import { injectCss } from '../../util';
import { ZSignDocumentEmbedDataSchema } from './schema'; import { ZSignDocumentEmbedDataSchema } from './schema';
@@ -51,7 +43,7 @@ export type EmbedSignDocumentClientPageProps = {
metadata?: DocumentMeta | TemplateMeta | null; metadata?: DocumentMeta | TemplateMeta | null;
isCompleted?: boolean; isCompleted?: boolean;
hidePoweredBy?: boolean; hidePoweredBy?: boolean;
allowWhitelabelling?: boolean; isPlatformOrEnterprise?: boolean;
allRecipients?: RecipientWithFields[]; allRecipients?: RecipientWithFields[];
}; };
@@ -64,7 +56,7 @@ export const EmbedSignDocumentClientPage = ({
metadata, metadata,
isCompleted, isCompleted,
hidePoweredBy = false, hidePoweredBy = false,
allowWhitelabelling = false, isPlatformOrEnterprise = false,
allRecipients = [], allRecipients = [],
}: EmbedSignDocumentClientPageProps) => { }: EmbedSignDocumentClientPageProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
@@ -83,9 +75,6 @@ export const EmbedSignDocumentClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted); const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [hasRejectedDocument, setHasRejectedDocument] = useState(
recipient.signingStatus === SigningStatus.REJECTED,
);
const [selectedSignerId, setSelectedSignerId] = useState<number | null>( const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
allRecipients.length > 0 ? allRecipients[0].id : null, allRecipients.length > 0 ? allRecipients[0].id : null,
); );
@@ -94,8 +83,6 @@ export const EmbedSignDocumentClientPage = ({
const [isNameLocked, setIsNameLocked] = useState(false); const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId); const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT; 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(() => { useLayoutEffect(() => {
const hash = window.location.hash.slice(1); 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 // 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. // a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName); setIsNameLocked(!!data.lockName);
setAllowDocumentRejection(!!data.allowDocumentRejection);
if (data.darkModeDisabled) { if (data.darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled'); document.documentElement.classList.add('dark-mode-disabled');
} }
if (allowWhitelabelling) { if (isPlatformOrEnterprise) {
injectCss({ injectCss({
css: data.css, css: data.css,
cssVars: data.cssVars, cssVars: data.cssVars,
@@ -241,10 +208,6 @@ export const EmbedSignDocumentClientPage = ({
} }
}, [hasFinishedInit, hasDocumentLoaded]); }, [hasFinishedInit, hasDocumentLoaded]);
if (hasRejectedDocument) {
return <EmbedDocumentRejected name={fullName} />;
}
if (hasCompletedDocument) { if (hasCompletedDocument) {
return ( return (
<EmbedDocumentCompleted <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"> <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 />} {(!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"> <div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}
<div className="embed--DocumentViewer flex-1"> <div className="embed--DocumentViewer flex-1">
@@ -288,7 +241,7 @@ export const EmbedSignDocumentClientPage = ({
{/* Widget */} {/* Widget */}
<div <div
key={isExpanded ? 'expanded' : 'collapsed'} 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} 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"> <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> </h3>
{isExpanded ? ( <Button variant="outline" className="h-8 w-8 p-0 md:hidden">
<Button {isExpanded ? (
variant="outline" <LucideChevronDown
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden" className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(false)} onClick={() => setIsExpanded(false)}
> />
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" /> ) : (
</Button> <LucideChevronUp
) : pendingFields.length > 0 ? ( className="text-muted-foreground h-5 w-5"
<Button onClick={() => setIsExpanded(true)}
variant="outline" />
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden" )}
onClick={() => setIsExpanded(true)} </Button>
>
<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>
)}
</div> </div>
</div> </div>
@@ -484,7 +420,7 @@ export const EmbedSignDocumentClientPage = ({
</Button> </Button>
) : ( ) : (
<Button <Button
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'} className="col-start-2"
disabled={ disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid) isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
} }

View File

@@ -2,7 +2,6 @@ import { notFound } from 'next/navigation';
import { match } from 'ts-pattern'; 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 { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform'; import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@@ -63,16 +62,12 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
return <EmbedPaywall />; return <EmbedPaywall />;
} }
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([ const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
isDocumentPlatform(document), isDocumentPlatform(document),
isUserEnterprise({ isUserEnterprise({
userId: document.userId, userId: document.userId,
teamId: document.teamId ?? undefined, teamId: document.teamId ?? undefined,
}), }),
isUserCommunityPlan({
userId: document.userId,
teamId: document.teamId ?? undefined,
}),
]); ]);
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
@@ -131,10 +126,8 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
fields={fields} fields={fields}
metadata={document.documentMeta} metadata={document.documentMeta}
isCompleted={document.status === DocumentStatus.COMPLETED} isCompleted={document.status === DocumentStatus.COMPLETED}
hidePoweredBy={ hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
}
allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
allRecipients={allRecipients} allRecipients={allRecipients}
/> />
</DocumentAuthProvider> </DocumentAuthProvider>

View File

@@ -13,5 +13,4 @@ export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
.optional() .optional()
.transform((value) => value || undefined), .transform((value) => value || undefined),
lockName: z.boolean().optional().default(false), 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( .with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED },
({ data }) => ( ({ data }) => (

21
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.9.1-rc.7", "version": "1.9.1-rc.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.9.1-rc.7", "version": "1.9.1-rc.1",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@@ -106,7 +106,7 @@
}, },
"apps/web": { "apps/web": {
"name": "@documenso/web", "name": "@documenso/web",
"version": "1.9.1-rc.7", "version": "1.9.1-rc.1",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@documenso/api": "*", "@documenso/api": "*",
@@ -35722,6 +35722,21 @@
"engines": { "engines": {
"node": ">=6" "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, "private": true,
"version": "1.9.1-rc.7", "version": "1.9.1-rc.1",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web", "build:web": "turbo run build --filter=@documenso/web",

View File

@@ -2,7 +2,9 @@ import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import path from 'node:path'; 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 { 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 { prisma } from '@documenso/prisma';
import { import {
DocumentSigningOrder, DocumentSigningOrder,
@@ -612,7 +614,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
expect(previousRecipient?.signingStatus).toBe(SigningStatus.SIGNED); 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 expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.locator(`#field-${recipientField.id}`).getByRole('button').click(); await page.locator(`#field-${recipientField.id}`).getByRole('button').click();
@@ -630,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: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).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(); await expect(page.getByText('Document Signed')).toBeVisible();
const updatedRecipient = await prisma.recipient.findFirst({ const updatedRecipient = await getRecipientById({
where: { id: recipient?.id }, documentId: document.id,
id: recipient!.id,
}); });
expect(updatedRecipient?.signingStatus).toBe(SigningStatus.SIGNED); expect(updatedRecipient?.signingStatus).toBe(SigningStatus.SIGNED);
} }
// Wait for the document to be signed. // Wait for the document to be signed.
await page.waitForTimeout(5000); await expect(async () => {
const signedDocument = await getDocumentById({ id: document.id, userId: user.id });
const finalDocument = await prisma.document.findFirst({ expect(signedDocument?.status).toBe(DocumentStatus.COMPLETED);
where: { id: createdDocument?.id }, }).toPass();
});
expect(finalDocument?.status).toBe(DocumentStatus.COMPLETED);
}); });
test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode', async ({ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode', async ({
@@ -655,7 +655,7 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
}) => { }) => {
const user = await seedUser(); const user = await seedUser();
const { document, recipients } = await seedPendingDocumentWithFullFields({ const { recipients } = await seedPendingDocumentWithFullFields({
owner: user, owner: user,
recipients: ['user1@example.com', 'user2@example.com', 'user3@example.com'], recipients: ['user1@example.com', 'user2@example.com', 'user3@example.com'],
fields: [FieldType.SIGNATURE], fields: [FieldType.SIGNATURE],
@@ -682,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).not.toHaveURL(`/sign/${activeRecipient?.token}/waiting`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); 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]; type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => { 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'], expand: ['data.product'],
limit: 100, limit: 100,
}); });
return prices.data.filter( return prices.filter((price) => price.type === 'recurring');
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
);
}; };

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, WebhookTriggerEvents,
} from '@documenso/prisma/client'; } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client'; import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth'; import type { TRecipientActionAuth } from '../../types/document-auth';
import { import {
@@ -73,13 +72,6 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has already signed`); 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) { if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token }); const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });

View File

@@ -88,7 +88,6 @@ export const findDocuments = async ({
const searchFilter: Prisma.DocumentWhereInput = { const searchFilter: Prisma.DocumentWhereInput = {
OR: [ OR: [
{ title: { contains: query, mode: 'insensitive' } }, { title: { contains: query, mode: 'insensitive' } },
{ externalId: { contains: query, mode: 'insensitive' } },
{ recipients: { some: { name: { contains: query, mode: 'insensitive' } } } }, { recipients: { some: { name: { contains: query, mode: 'insensitive' } } } },
{ recipients: { some: { email: { contains: query, mode: 'insensitive' } } } }, { recipients: { some: { email: { contains: query, mode: 'insensitive' } } } },
], ],

View File

@@ -34,14 +34,6 @@ export const searchDocumentsWithKeyword = async ({
userId: userId, userId: userId,
deletedAt: null, deletedAt: null,
}, },
{
externalId: {
contains: query,
mode: 'insensitive',
},
userId: userId,
deletedAt: null,
},
{ {
recipients: { recipients: {
some: { some: {
@@ -96,23 +88,6 @@ export const searchDocumentsWithKeyword = async ({
}, },
deletedAt: null, deletedAt: null,
}, },
{
externalId: {
contains: query,
mode: 'insensitive',
},
teamId: {
not: null,
},
team: {
members: {
some: {
userId: userId,
},
},
},
deletedAt: null,
},
], ],
}, },
include: { 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; const { documentData } = document;
if (!documentData.data) { if (!documentData || !documentData.data) {
throw new Error('Document data not found'); throw new Error('Document data not found');
} }

View File

@@ -13,6 +13,7 @@ import { ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthTypesSchema } from
export const ZDocumentAuditLogTypeSchema = z.enum([ export const ZDocumentAuditLogTypeSchema = z.enum([
// Document actions. // Document actions.
'EMAIL_SENT', 'EMAIL_SENT',
'SELF_SIGN',
// Document modification events. // Document modification events.
'FIELD_CREATED', '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. * Event: Document completed.
*/ */
@@ -566,6 +575,7 @@ export const ZDocumentAuditLogBaseSchema = z.object({
export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
z.union([ z.union([
ZDocumentAuditLogEventEmailSentSchema, ZDocumentAuditLogEventEmailSentSchema,
ZDocumentAuditLogSelfSignSchema,
ZDocumentAuditLogEventDocumentCompletedSchema, ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema, ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentDeletedSchema, ZDocumentAuditLogEventDocumentDeletedSchema,

View File

@@ -369,16 +369,6 @@ export const formatDocumentAuditLogAction = (
identified: result, 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 }) => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`, anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
identified: data.isResending identified: data.isResending
@@ -389,6 +379,14 @@ export const formatDocumentAuditLogAction = (
anonymous: msg`Document completed`, anonymous: msg`Document completed`,
identified: 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(); .exhaustive();
return { 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? scope String?
id_token String? @db.Text id_token String? @db.Text
session_state String? 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]) @@unique([provider, providerAccountId])
} }
model Session { model Session {
id String @id @default(cuid()) id String @id @default(cuid())
sessionToken String @unique sessionToken String @unique
userId Int userId Int
expires DateTime
ipAddress String? user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userAgent String?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
enum DocumentStatus { enum DocumentStatus {

View File

@@ -5,18 +5,6 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..'; import { prisma } from '..';
import { DocumentDataType, DocumentSource, Role, TeamMemberRole } from '../client'; 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 () => { export const seedDatabase = async () => {
const examplePdf = fs const examplePdf = fs
@@ -51,80 +39,35 @@ export const seedDatabase = async () => {
update: {}, update: {},
}); });
for (let i = 1; i <= 4; i++) { const examplePdfData = await prisma.documentData.upsert({
const documentData = await createDocumentData({ documentData: examplePdf }); where: {
id: 'clmn0kv5k0000pe04vcqg5zla',
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',
}, },
create: {
id: 'clmn0kv5k0000pe04vcqg5zla',
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
update: {},
}); });
await seedPendingDocument(adminUser, [exampleUser], { await prisma.document.create({
key: 'admin-pending', data: {
createDocumentOptions: { source: DocumentSource.DOCUMENT,
title: 'Pending 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 = [ const testUsers = [
'test@documenso.com', 'test@documenso.com',
'test2@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 { moveDocumentToTeam } from '@documenso/lib/server-only/document/move-document-to-team';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword'; 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 { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document'; import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
@@ -50,6 +51,7 @@ import {
ZMoveDocumentToTeamSchema, ZMoveDocumentToTeamSchema,
ZResendDocumentMutationSchema, ZResendDocumentMutationSchema,
ZSearchDocumentsMutationSchema, ZSearchDocumentsMutationSchema,
ZSelfSignDocumentMutationSchema,
ZSetPasswordForDocumentMutationSchema, ZSetPasswordForDocumentMutationSchema,
ZSetSigningOrderForDocumentMutationSchema, ZSetSigningOrderForDocumentMutationSchema,
ZSuccessResponseSchema, 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 * @public
* *

View File

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

View File

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