Compare commits

..

61 Commits

Author SHA1 Message Date
63182f9587 packages/lib/translations/de/web.po aktualisiert 2025-04-19 08:25:59 +00:00
a9d7c3315d packages/lib/translations/de/web.po aktualisiert 2025-04-19 08:19:01 +00:00
de4197fba1 packages/lib/translations/de/web.po aktualisiert 2025-04-19 07:26:31 +00:00
1b304b4f44 packages/lib/translations/de/web.po aktualisiert 2025-04-18 12:01:13 +00:00
5c1a0c683f packages/lib/translations/de/web.po aktualisiert 2025-04-18 11:41:15 +00:00
3af68e9e49 Standartsprache (für Dokumente) - DE 2025-03-24 18:29:28 +01:00
ff712de07c Standartsprache für Dokumente - DE 2025-03-24 18:26:16 +01:00
ce9bd6bb80 Übersetzung: Anpassungen 2025-03-24 18:25:56 +01:00
1447f03456 Standartsprache für Dokumente - DE 2025-03-24 18:10:32 +01:00
335e833170 Übersetzungen und Wording anpassung 2025-03-24 18:10:14 +01:00
e20153c9c5 Anpassen der Standart Support E-Mail 2025-03-24 18:09:58 +01:00
23c6c7935e Übersetzungen 2025-03-24 18:09:40 +01:00
01232624cc Share Button auskommentiert 2025-03-24 18:09:17 +01:00
3045bcefc2 Dokumentenablehnung - Übersetzung 2025-03-24 18:09:00 +01:00
cde5a43410 Übersetung: Anpassung Branding und Ansprechweise 2025-03-24 15:58:02 +01:00
bdf4db2c30 Öffentliches Profil: Button auf Webseite anstatt auf /signup 2025-03-24 15:55:52 +01:00
5747a0d52d Änderung an security.txt 2025-03-24 15:55:23 +01:00
d2b83af9ea Änderungen an Branding - App Name 2025-03-24 15:55:08 +01:00
David Nguyen
063fd32f18 feat: add signature configurations (#1710)
Add ability to enable or disable allowed signature types:
- Drawn
- Typed
- Uploaded

**Tabbed style signature dialog**

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

**Document settings**

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

**Team preferences**

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

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

Added E2E tests to check settings are applied correctly for documents
and templates
2025-03-24 17:13:11 +11:00
Mythie
231f51bd1f v1.10.0-rc.1 2025-03-22 17:34:33 +11:00
Mythie
a8de8368a2 fix: hide powered by on certificate for platform documents 2025-03-22 12:04:08 +11:00
Mythie
7dd331addf fix: allow blank rejection reasons 2025-03-22 12:01:18 +11:00
Mythie
c6743a7cec v1.10.0-rc.0 2025-03-22 03:23:23 +11:00
Mythie
efbc097191 fix: unblock last signer when using dictation 2025-03-22 02:34:12 +11:00
Lucas Smith
f1525991dc feat: dictate next signer (#1719)
Adds next recipient dictation functionality to document signing flow,
allowing assistants and signers to update the next recipient's
information during the signing process.

## Related Issue

N/A

## Changes Made

- Added form handling for next recipient dictation in signing dialogs
- Implemented UI for updating next recipient information
- Added e2e tests covering dictation scenarios:
  - Regular signing with dictation enabled
  - Assistant role with dictation
  - Parallel signing flow
  - Disabled dictation state

## Testing Performed

- Added comprehensive e2e tests covering:
  - Sequential signing with dictation
  - Assistant role dictation
  - Parallel signing without dictation
  - Form validation and state management
- Tested on Chrome and Firefox
- Verified recipient state updates in database
2025-03-21 13:27:04 +11:00
Mythie
fb173e4d0e chore: update docker build scripts 2025-03-20 10:52:33 +11:00
Catalin Pit
d422ffa873 chore: add terms and privacy policy link (#1707) 2025-03-19 19:29:09 +11:00
Catalin Pit
55b7697316 chore: update d script in package.json (#1703)
Add the `translate:compile` command to the `d` script.
2025-03-14 16:15:57 +11:00
Catalin Pit
67bbb6c6f4 fix: autosigning fields with direct links (#1696)
## Description

The changes in `sign-direct-template.tsx` automatically fill in field
values for text, number, and dropdown fields when default values are
present or if the fields are read-only. In `checkbox-field.tsx`, the
changes fix the checkbox signing by checking if the validation is met
and improving how it saves or removes checkbox choices.

## Testing Performed

I tested the code locally with a variety of documents/fields.

## Checklist

<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.
2025-03-13 11:18:01 +02:00
Lucas Smith
63a4bab0fe feat: better document rejection (#1702)
Improves the existing document rejection process by actually marking a
document as completed cancelling further actions.

## Related Issue

N/A

## Changes Made

- Added a new rejection status for documents
- Updated a million areas that check for document completion
- Updated email sending, so rejection is confirmed for the rejecting
recipient while other recipients are notified that the document is now
cancelled.

## Testing Performed

- Ran the testing suite to ensure there are no regressions.
- Performed manual testing of current core flows.
2025-03-13 15:08:57 +11:00
Jenil Savani
9f17c1e48e fix: adjust desktop nav search button width and spacing (#1699) 2025-03-13 10:52:01 +11:00
Catalin Pit
91ae818213 fix: missing prefillfields property from the api v2 documentation (#1700) 2025-03-12 22:54:58 +11:00
David Nguyen
a0ace803cf fix: admin signing page crash 2025-03-12 16:53:09 +11:00
Ephraim Duncan
b3db3be8e9 fix: signing field disabled when pointer is out of canvas (#1652) 2025-03-12 16:44:21 +11:00
Jenil Savani
44cdbeecb4 fix: improve layout and truncate document information in logs page (#1656) 2025-03-12 16:31:03 +11:00
Tom
7214965c0c chore: update French translations (#1679) 2025-03-12 16:22:06 +11:00
Ephraim Duncan
8d6bf91d12 fix: persist theme cookie for a much longer time (#1693) 2025-03-12 16:09:37 +11:00
Ephraim Duncan
fec078081b fix: correct signer deletion (#1596) 2025-03-12 16:05:45 +11:00
David Nguyen
c646afcd97 fix: tests 2025-03-09 15:10:19 +11:00
Catalin Pit
63d990ce8d fix: optional fields in embeds (#1691) 2025-03-09 14:41:17 +11:00
eddielu
aa7d6b28a4 docs: Update documentation to match reality. colorPrimary, colorBackground,… (#1666)
Update documentation to match reality. colorPrimary, colorBackground,
and borderRadius do not exist according to the schema:
280251cfdd/packages/react/src/css-vars.ts
2025-03-09 14:38:51 +11:00
David Nguyen
b990532633 fix: remove refresh on focus 2025-03-08 15:30:13 +11:00
Catalin Pit
65be37514f fix: prefill fields (#1689)
Users can now selectively choose which properties to pre-fill for each
field - from just a label to all available properties.
2025-03-07 09:09:15 +11:00
Catalin Pit
0df29fce36 fix: invalid request body (#1686)
Fix the invalid request body so the webhooks work again.
2025-03-06 19:47:24 +11:00
Ephraim Duncan
ba5b7ce480 feat: hide signature ui when theres no signature field (#1676) 2025-03-06 19:47:02 +11:00
Catalin Pit
422770a8c7 feat: allow fields prefill when generating a document from a template (#1615)
This change allows API users to pre-fill fields with values by
passing the data in the request body. Example body for V2 API endpoint
`/api/v2-beta/template/use`:

```json
{
    "templateId": 1,
    "recipients": [
        {
            "id": 1,
            "email": "signer1@mail.com",
            "name": "Signer 1"
        },
        {
            "id": 2,
            "email": "signer2@mail.com",
            "name": "Signer 2"
        }
    ],
    "prefillValues": [
        {
            "id": 14,
            "fieldMeta": {
                "type": "text",
                "label": "my label",
                "placeholder": "text placeholder test",
                "text": "auto-sign value",
                "characterLimit": 25,
                "textAlign": "right",
                "fontSize": 94,
                "required": true
            }
        },
        {
            "id": 15,
            "fieldMeta": {
                "type": "radio",
                "label": "radio label",
                "placeholder": "new radio placeholder",
                "required": false,
                "readOnly": true,
                "values": [
                    {
                        "id": 2,
                        "checked": true,
                        "value": "radio val 1"
                    },
                    {
                        "id": 3,
                        "checked": false,
                        "value": "radio val 2"
                    }
                ]
            }
        },
        {
            "id": 16,
            "fieldMeta": {
                "type": "dropdown",
                "label": "dropdown label",
                "placeholder": "DD placeholder",
                "required": false,
                "readOnly": false,
                "values": [
                    {
                        "value": "option 1"
                    },
                    {
                        "value": "option 2"
                    },
                    {
                        "value": "option 3"
                    }
                ],
                "defaultValue": "option 2"
            }
        }
    ],
    "distributeDocument": false,
    "customDocumentDataId": ""
}
```
2025-03-06 19:45:33 +11:00
David Nguyen
083a706373 fix: duplex and 2fa refresh 2025-03-04 11:41:38 +11:00
David Nguyen
db326cb4a9 fix: posthog reverse proxy 2025-03-04 10:48:19 +11:00
David Nguyen
d664f571d6 fix: posthog reverse proxy 2025-03-04 10:46:59 +11:00
David Nguyen
7c38970ee8 fix: update error logging 2025-03-04 01:41:39 +11:00
David Nguyen
e08d62c844 fix: remove invalid prisma zod schemas 2025-03-04 01:20:13 +11:00
David Nguyen
25bb6ffe77 fix: imports 2025-03-03 14:49:28 +11:00
Catalin Pit
e79d762710 chore: add label for checkbox and radio fields (#1607) 2025-03-03 13:46:29 +11:00
Mythie
d970976299 fix: remove auto-expand in embeddding 2025-02-28 14:46:15 +11:00
David Nguyen
3dce814ab2 fix: stripe price fetch (#1677)
Currently Stripe prices search is omitting a price for an unknown
reason.

Changed our fetch logic to use `list` instead of `search` allows us to
work around the issue.

It's unknown on the performance impact of using `list` vs `search`
2025-02-28 14:44:06 +11:00
David Nguyen
ad520bb032 fix: remove oauth from embeds 2025-02-27 14:08:59 +11:00
David Nguyen
596d30e2e5 fix: remove lazy pdf loader 2025-02-26 21:48:06 +11:00
David Nguyen
6474b4a524 fix: add preferred team middleware 2025-02-26 19:42:42 +11:00
David Nguyen
5b4db51051 fix: react-pdf canvas build 2025-02-26 18:39:21 +11:00
Mythie
cf58c80e31 fix: handle empty field meta for checkboxes 2025-02-26 15:30:51 +11:00
Catalin Pit
11dbb8873e docs: add the v2 api staging base url (#1671) 2025-02-26 15:30:32 +11:00
285 changed files with 22353 additions and 17349 deletions

View File

@@ -1,7 +1,4 @@
# General Issues
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
# Report critical issues privately to let us take appropriate action before publishing. # Report critical issues privately to let us take appropriate action before publishing.
Contact: mailto:security@documenso.com Contact: mailto:hello@bls-media.de
Preferred-Languages: en Preferred-Languages: de
Canonical: https://documenso.com/.well-known/security.txt Canonical: https://bls.media/.well-known/security.txt

View File

@@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe
<EmbedDirectTemplate <EmbedDirectTemplate
token={token} token={token}
cssVars={{ cssVars={{
colorPrimary: '#0000FF', primary: '#0000FF',
colorBackground: '#F5F5F5', background: '#F5F5F5',
borderRadius: '8px', radius: '8px',
}} }}
/> />
``` ```

View File

@@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
} }
`; `;
const cssVars = { const cssVars = {
colorPrimary: '#0000FF', primary: '#0000FF',
colorBackground: '#F5F5F5', background: '#F5F5F5',
borderRadius: '8px', radius: '8px',
}; };
return ( return (

View File

@@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => {
`} `}
// CSS Variables // CSS Variables
cssVars={{ cssVars={{
colorPrimary: '#0000FF', primary: '#0000FF',
colorBackground: '#F5F5F5', background: '#F5F5F5',
borderRadius: '8px', radius: '8px',
}} }}
// Dark Mode Control // Dark Mode Control
darkModeDisabled={true} darkModeDisabled={true}

View File

@@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
} }
`; `;
const cssVars = { const cssVars = {
colorPrimary: '#0000FF', primary: '#0000FF',
colorBackground: '#F5F5F5', background: '#F5F5F5',
borderRadius: '8px', radius: '8px',
}; };
return ( return (

View File

@@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
} }
`; `;
const cssVars = { const cssVars = {
colorPrimary: '#0000FF', primary: '#0000FF',
colorBackground: '#F5F5F5', background: '#F5F5F5',
borderRadius: '8px', radius: '8px',
}; };
</script> </script>

View File

@@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
} }
`; `;
const cssVars = { const cssVars = {
colorPrimary: '#0000FF', primary: '#0000FF',
colorBackground: '#F5F5F5', background: '#F5F5F5',
borderRadius: '8px', radius: '8px',
}; };
</script> </script>

View File

@@ -31,6 +31,11 @@ Our new API V2 supports the following typed SDKs:
- [Python](https://github.com/documenso/sdk-python) - [Python](https://github.com/documenso/sdk-python)
- [Go](https://github.com/documenso/sdk-go) - [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>
🚀 [V2 Announcement](https://documen.so/sdk-blog) 🚀 [V2 Announcement](https://documen.so/sdk-blog)
📖 [Documentation](https://documen.so/api-v2-docs) 📖 [Documentation](https://documen.so/api-v2-docs)

View File

@@ -150,7 +150,7 @@ Example payload for the `document.created` event:
"id": 52, "id": 52,
"documentId": 10, "documentId": 10,
"templateId": null, "templateId": null,
"email": "signer@documenso.com", "email": "signer@sign.bls.media",
"name": "John Doe", "name": "John Doe",
"token": "vbT8hi3jKQmrFP_LN1WcS", "token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null, "documentDeletedAt": null,
@@ -212,7 +212,7 @@ Example payload for the `document.sent` event:
"id": 52, "id": 52,
"documentId": 10, "documentId": 10,
"templateId": null, "templateId": null,
"email": "signer2@documenso.com", "email": "signer2@sign.bls.media",
"name": "Signer 2", "name": "Signer 2",
"token": "vbT8hi3jKQmrFP_LN1WcS", "token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null, "documentDeletedAt": null,
@@ -230,7 +230,7 @@ Example payload for the `document.sent` event:
"id": 53, "id": 53,
"documentId": 10, "documentId": 10,
"templateId": null, "templateId": null,
"email": "signer1@documenso.com", "email": "signer1@sign.bls.media",
"name": "Signer 1", "name": "Signer 1",
"token": "HkrptwS42ZBXdRKj1TyUo", "token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null, "documentDeletedAt": null,
@@ -292,7 +292,7 @@ Example payload for the `document.opened` event:
"id": 52, "id": 52,
"documentId": 10, "documentId": 10,
"templateId": null, "templateId": null,
"email": "signer2@documenso.com", "email": "signer2@sign.bls.media",
"name": "Signer 2", "name": "Signer 2",
"token": "vbT8hi3jKQmrFP_LN1WcS", "token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null, "documentDeletedAt": null,
@@ -354,7 +354,7 @@ Example payload for the `document.signed` event:
"id": 51, "id": 51,
"documentId": 10, "documentId": 10,
"templateId": null, "templateId": null,
"email": "signer1@documenso.com", "email": "signer1@sign.bls.media",
"name": "Signer 1", "name": "Signer 1",
"token": "HkrptwS42ZBXdRKj1TyUo", "token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null, "documentDeletedAt": null,
@@ -419,7 +419,7 @@ Example payload for the `document.completed` event:
"id": 50, "id": 50,
"documentId": 10, "documentId": 10,
"templateId": null, "templateId": null,
"email": "signer2@documenso.com", "email": "signer2@sign.bls.media",
"name": "Signer 2", "name": "Signer 2",
"token": "vbT8hi3jKQmrFP_LN1WcS", "token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null, "documentDeletedAt": null,
@@ -440,7 +440,7 @@ Example payload for the `document.completed` event:
"id": 51, "id": 51,
"documentId": 10, "documentId": 10,
"templateId": null, "templateId": null,
"email": "signer1@documenso.com", "email": "signer1@sign.bls.media",
"name": "Signer 1", "name": "Signer 1",
"token": "HkrptwS42ZBXdRKj1TyUo", "token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null, "documentDeletedAt": null,
@@ -505,7 +505,7 @@ Example payload for the `document.rejected` event:
"id": 52, "id": 52,
"documentId": 10, "documentId": 10,
"templateId": null, "templateId": null,
"email": "signer@documenso.com", "email": "signer@sign.bls.media",
"name": "Signer", "name": "Signer",
"token": "vbT8hi3jKQmrFP_LN1WcS", "token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null, "documentDeletedAt": null,
@@ -598,7 +598,7 @@ Example payload for the `document.rejected` event:
"id": 7, "id": 7,
"documentId": 7, "documentId": 7,
"templateId": null, "templateId": null,
"email": "signer@documenso.com", "email": "signer@sign.bls.media",
"name": "Signer", "name": "Signer",
"token": "XkKx1HCs6Znm2UBJA2j6o", "token": "XkKx1HCs6Znm2UBJA2j6o",
"documentDeletedAt": null, "documentDeletedAt": null,

View File

@@ -2,7 +2,7 @@ import type { DocsThemeConfig } from 'nextra-theme-docs';
import { useConfig } from 'nextra-theme-docs'; import { useConfig } from 'nextra-theme-docs';
const themeConfig: DocsThemeConfig = { const themeConfig: DocsThemeConfig = {
logo: <span>Documenso</span>, logo: <span>BLS sign</span>,
head: function useHead() { head: function useHead() {
const config = useConfig<{ title?: string; description?: string }>(); const config = useConfig<{ title?: string; description?: string }>();

View File

@@ -1,7 +1,7 @@
import { DocumentStatus } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma'; import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => { export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
const qb = kyselyPrisma.$kysely const qb = kyselyPrisma.$kysely

View File

@@ -1,4 +1,9 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -9,64 +14,208 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure'; import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
export type NextSigner = {
name: string;
email: string;
};
type ConfirmationDialogProps = { type ConfirmationDialogProps = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: (nextSigner?: NextSigner) => void;
hasUninsertedFields: boolean; hasUninsertedFields: boolean;
isSubmitting: boolean; isSubmitting: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: NextSigner;
}; };
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
export function AssistantConfirmationDialog({ export function AssistantConfirmationDialog({
isOpen, isOpen,
onClose, onClose,
onConfirm, onConfirm,
hasUninsertedFields, hasUninsertedFields,
isSubmitting, isSubmitting,
allowDictateNextSigner = false,
defaultNextSigner,
}: ConfirmationDialogProps) { }: ConfirmationDialogProps) {
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
resolver: zodResolver(ZNextSignerFormSchema),
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
},
});
const onOpenChange = () => { const onOpenChange = () => {
if (isSubmitting) { if (form.formState.isSubmitting) {
return; return;
} }
form.reset({
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
});
setIsEditingNextSigner(false);
onClose(); onClose();
}; };
const onFormSubmit = async (data: TNextSignerFormSchema) => {
if (allowDictateNextSigner && data.name && data.email) {
await onConfirm({
name: data.name,
email: data.email,
});
} else {
await onConfirm();
}
};
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
return ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <Form {...form}>
<DialogTitle> <form onSubmit={form.handleSubmit(onFormSubmit)}>
<Trans>Complete Document</Trans> <fieldset
</DialogTitle> disabled={form.formState.isSubmitting || isSubmitting}
<DialogDescription> className="border-none p-0"
<Trans> >
Are you sure you want to complete the document? This action cannot be undone. Please <DialogHeader>
ensure that you have completed prefilling all relevant fields before proceeding. <DialogTitle>
</Trans> <Trans>Complete Document</Trans>
</DialogDescription> </DialogTitle>
</DialogHeader> <DialogDescription>
<Trans>
Are you sure you want to complete the document? This action cannot be undone.
Please ensure that you have completed prefilling all relevant fields before
proceeding.
</Trans>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4"> <div className="mt-4 flex flex-col gap-4">
<DocumentSigningDisclosure /> {allowDictateNextSigner && (
</div> <div className="space-y-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<DialogFooter className="mt-4"> <Button
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}> type="button"
Cancel className="mt-2"
</Button> variant="outline"
<Button size="sm"
variant={hasUninsertedFields ? 'destructive' : 'default'} onClick={() => setIsEditingNextSigner((prev) => !prev)}
onClick={onConfirm} >
disabled={isSubmitting} <Trans>Update Recipient</Trans>
loading={isSubmitting} </Button>
> </div>
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'} )}
</Button>
</DialogFooter> {isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
</div>
<DialogFooter className="mt-4">
<Button
type="button"
variant="secondary"
onClick={onClose}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant={hasUninsertedFields ? 'destructive' : 'default'}
disabled={form.formState.isSubmitting || !isNextSignerValid}
loading={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Trans>Submitting...</Trans>
) : hasUninsertedFields ? (
<Trans>Proceed</Trans>
) : (
<Trans>Continue</Trans>
)}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client'; import { DocumentStatus } from '@prisma/client';
import { match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
@@ -146,7 +146,7 @@ export const DocumentDeleteDialog = ({
</ul> </ul>
</AlertDescription> </AlertDescription>
)) ))
.with(DocumentStatus.COMPLETED, () => ( .with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
<AlertDescription> <AlertDescription>
<p> <p>
<Trans>By deleting this document, the following will occur:</Trans> <Trans>By deleting this document, the following will occur:</Trans>

View File

@@ -13,7 +13,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
@@ -97,7 +97,7 @@ export const DocumentDuplicateDialog = ({
</div> </div>
) : ( ) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll"> <div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<LazyPDFViewer key={document?.id} documentData={documentData} /> <PDFViewer key={document?.id} documentData={documentData} />
</div> </div>
)} )}

View File

@@ -216,9 +216,9 @@ export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDi
const downloadTemplate = () => { const downloadTemplate = () => {
const data = [ const data = [
{ email: 'admin@documenso.com', role: 'Admin' }, { email: 'admin@sign.bls.media', role: 'Admin' },
{ email: 'manager@documenso.com', role: 'Manager' }, { email: 'manager@sign.bls.media', role: 'Manager' },
{ email: 'member@documenso.com', role: 'Member' }, { email: 'member@sign.bls.media', role: 'Member' },
]; ];
const csvContent = const csvContent =

View File

@@ -16,9 +16,9 @@ export type EmbedAuthenticationRequiredProps = {
export const EmbedAuthenticationRequired = ({ export const EmbedAuthenticationRequired = ({
email, email,
returnTo, returnTo,
isGoogleSSOEnabled, // isGoogleSSOEnabled,
isOIDCSSOEnabled, // isOIDCSSOEnabled,
oidcProviderLabel, // oidcProviderLabel,
}: EmbedAuthenticationRequiredProps) => { }: EmbedAuthenticationRequiredProps) => {
return ( return (
<div className="flex min-h-[100dvh] w-full items-center justify-center"> <div className="flex min-h-[100dvh] w-full items-center justify-center">
@@ -35,9 +35,10 @@ export const EmbedAuthenticationRequired = ({
</Alert> </Alert>
<SignInForm <SignInForm
isGoogleSSOEnabled={isGoogleSSOEnabled} // Embed currently not supported.
isOIDCSSOEnabled={isOIDCSSOEnabled} // isGoogleSSOEnabled={isGoogleSSOEnabled}
oidcProviderLabel={oidcProviderLabel} // isOIDCSSOEnabled={isOIDCSSOEnabled}
// oidcProviderLabel={oidcProviderLabel}
className="mt-4" className="mt-4"
initialEmail={email} initialEmail={email}
returnTo={returnTo} returnTo={returnTo}

View File

@@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { type DocumentData, type Field, FieldType } from '@prisma/client';
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client'; import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
import { type DocumentData, type Field, FieldType } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
@@ -13,6 +13,10 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
isFieldUnsignedAndRequired,
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@@ -21,12 +25,11 @@ import type {
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogo } from '~/components/general/branding-logo';
@@ -65,16 +68,8 @@ export const EmbedDirectTemplateClientPage = ({
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { const { fullName, email, signature, setFullName, setEmail, setSignature } =
fullName, useRequiredDocumentSigningContext();
email,
signature,
signatureValid,
setFullName,
setEmail,
setSignature,
setSignatureValid,
} = useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
@@ -92,7 +87,7 @@ export const EmbedDirectTemplateClientPage = ({
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields); const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
const [pendingFields, _completedFields] = [ const [pendingFields, _completedFields] = [
localFields.filter((field) => !field.inserted), localFields.filter((field) => isFieldUnsignedAndRequired(field)),
localFields.filter((field) => field.inserted), localFields.filter((field) => field.inserted),
]; ];
@@ -110,7 +105,7 @@ export const EmbedDirectTemplateClientPage = ({
const newField: DirectTemplateLocalField = structuredClone({ const newField: DirectTemplateLocalField = structuredClone({
...field, ...field,
customText: payload.value, customText: payload.value ?? '',
inserted: true, inserted: true,
signedValue: payload, signedValue: payload,
}); });
@@ -121,8 +116,10 @@ export const EmbedDirectTemplateClientPage = ({
created: new Date(), created: new Date(),
recipientId: 1, recipientId: 1,
fieldId: 1, fieldId: 1,
signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null, signatureImageAsBase64:
typedSignature: payload.value.startsWith('data:') ? null : payload.value, payload.value && payload.value.startsWith('data:') ? payload.value : null,
typedSignature:
payload.value && !payload.value.startsWith('data:') ? payload.value : null,
} satisfies Signature; } satisfies Signature;
} }
@@ -180,7 +177,7 @@ export const EmbedDirectTemplateClientPage = ({
}; };
const onNextFieldClick = () => { const onNextFieldClick = () => {
validateFieldsInserted(localFields); validateFieldsInserted(pendingFields);
setShowPendingFieldTooltip(true); setShowPendingFieldTooltip(true);
setIsExpanded(false); setIsExpanded(false);
@@ -188,11 +185,7 @@ export const EmbedDirectTemplateClientPage = ({
const onCompleteClick = async () => { const onCompleteClick = async () => {
try { try {
if (hasSignatureField && !signatureValid) { const valid = validateFieldsInserted(pendingFields);
return;
}
const valid = validateFieldsInserted(localFields);
if (!valid) { if (!valid) {
setShowPendingFieldTooltip(true); setShowPendingFieldTooltip(true);
@@ -205,12 +198,6 @@ export const EmbedDirectTemplateClientPage = ({
directTemplateExternalId = decodeURIComponent(directTemplateExternalId); directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
} }
localFields.forEach((field) => {
if (!field.signedValue) {
throw new Error('Invalid configuration');
}
});
const { const {
documentId, documentId,
token: documentToken, token: documentToken,
@@ -221,13 +208,11 @@ export const EmbedDirectTemplateClientPage = ({
directRecipientName: fullName, directRecipientName: fullName,
directRecipientEmail: email, directRecipientEmail: email,
templateUpdatedAt: updatedAt, templateUpdatedAt: updatedAt,
signedFieldValues: localFields.map((field) => { signedFieldValues: localFields
if (!field.signedValue) { .filter((field) => {
throw new Error('Invalid configuration'); return field.signedValue && (isRequiredField(field) || field.inserted);
} })
.map((field) => field.signedValue!),
return field.signedValue;
}),
}); });
if (window.parent) { if (window.parent) {
@@ -338,7 +323,7 @@ export const EmbedDirectTemplateClientPage = ({
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row"> <div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}
<div className="flex-1"> <div className="flex-1">
<LazyPDFViewer <PDFViewer
documentData={documentData} documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)} onDocumentLoad={() => setHasDocumentLoaded(true)}
/> />
@@ -347,7 +332,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 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 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"
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">
@@ -415,40 +400,24 @@ export const EmbedDirectTemplateClientPage = ({
/> />
</div> </div>
<div> {hasSignatureField && (
<Label htmlFor="Signature"> <div>
<Trans>Signature</Trans> <Label htmlFor="Signature">
</Label> <Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}> <SignaturePadDialog
<CardContent className="p-0"> className="mt-2"
<SignaturePad disabled={isThrottled || isSubmitting}
className="h-44 w-full" disableAnimation
disabled={isThrottled || isSubmitting} value={signature ?? ''}
defaultValue={signature ?? undefined} onChange={(v) => setSignature(v ?? '')}
onChange={(value) => { typedSignatureEnabled={metadata?.typedSignatureEnabled}
setSignature(value); uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
}} drawSignatureEnabled={metadata?.drawSignatureEnabled}
onValidityChange={(isValid) => { />
setSignatureValid(isValid); </div>
}} )}
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>
</div> </div>
</div> </div>

View File

@@ -20,7 +20,7 @@ export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentComplet
<div className="mt-8 w-full max-w-md"> <div className="mt-8 w-full max-w-md">
<SigningCard3D <SigningCard3D
className="mx-auto w-full" className="mx-auto w-full"
name={name || 'Documenso'} name={name || 'BLS sign'}
signature={signature} signature={signature}
signingCelebrationImage={signingCelebration} signingCelebrationImage={signingCelebration}
/> />

View File

@@ -54,6 +54,8 @@ export const EmbedDocumentFields = ({
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
typedSignatureEnabled={metadata?.typedSignatureEnabled} typedSignatureEnabled={metadata?.typedSignatureEnabled}
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
drawSignatureEnabled={metadata?.drawSignatureEnabled}
/> />
)) ))
.with(FieldType.INITIALS, () => ( .with(FieldType.INITIALS, () => (

View File

@@ -1,4 +1,4 @@
import { useEffect, useId, useLayoutEffect, useState } from 'react'; import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
@@ -15,18 +15,18 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; 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 { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import { validateFieldsInserted } from '@documenso/lib/utils/fields';
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';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogo } from '~/components/general/branding-logo';
@@ -69,15 +69,8 @@ export const EmbedSignDocumentClientPage = ({
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { const { fullName, email, signature, setFullName, setSignature } =
fullName, useRequiredDocumentSigningContext();
email,
signature,
signatureValid,
setFullName,
setSignature,
setSignatureValid,
} = useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
@@ -101,19 +94,26 @@ export const EmbedSignDocumentClientPage = ({
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500); const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [pendingFields, _completedFields] = [ const [pendingFields, _completedFields] = [
fields.filter((field) => field.recipientId === recipient.id && !field.inserted), fields.filter(
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
),
fields.filter((field) => field.inserted), fields.filter((field) => field.inserted),
]; ];
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } = const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
trpc.recipient.completeDocumentWithToken.useMutation(); trpc.recipient.completeDocumentWithToken.useMutation();
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
[fields],
);
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const assistantSignersId = useId(); const assistantSignersId = useId();
const onNextFieldClick = () => { const onNextFieldClick = () => {
validateFieldsInserted(fields); validateFieldsInserted(fieldsRequiringValidation);
setShowPendingFieldTooltip(true); setShowPendingFieldTooltip(true);
setIsExpanded(false); setIsExpanded(false);
@@ -121,11 +121,7 @@ export const EmbedSignDocumentClientPage = ({
const onCompleteClick = async () => { const onCompleteClick = async () => {
try { try {
if (hasSignatureField && !signatureValid) { const valid = validateFieldsInserted(fieldsRequiringValidation);
return;
}
const valid = validateFieldsInserted(fields);
if (!valid) { if (!valid) {
setShowPendingFieldTooltip(true); setShowPendingFieldTooltip(true);
@@ -278,7 +274,7 @@ export const EmbedSignDocumentClientPage = ({
<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">
<LazyPDFViewer <PDFViewer
documentData={documentData} documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)} onDocumentLoad={() => setHasDocumentLoaded(true)}
/> />
@@ -287,7 +283,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 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 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"
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">
@@ -418,40 +414,24 @@ export const EmbedSignDocumentClientPage = ({
/> />
</div> </div>
<div> {hasSignatureField && (
<Label htmlFor="Signature"> <div>
<Trans>Signature</Trans> <Label htmlFor="Signature">
</Label> <Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}> <SignaturePadDialog
<CardContent className="p-0"> className="mt-2"
<SignaturePad disabled={isThrottled || isSubmitting}
className="h-44 w-full" disableAnimation
disabled={isThrottled || isSubmitting} value={signature ?? ''}
defaultValue={signature ?? undefined} onChange={(v) => setSignature(v ?? '')}
onChange={(value) => { typedSignatureEnabled={metadata?.typedSignatureEnabled}
setSignature(value); uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
}} drawSignatureEnabled={metadata?.drawSignatureEnabled}
onValidityChange={(isValid) => { />
setSignatureValid(isValid); </div>
}} )}
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>
</> </>
)} )}
</div> </div>
@@ -467,9 +447,7 @@ export const EmbedSignDocumentClientPage = ({
) : ( ) : (
<Button <Button
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'} className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
disabled={ disabled={isThrottled}
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
loading={isSubmitting} loading={isSubmitting}
onClick={() => throttledOnCompleteClick()} onClick={() => throttledOnCompleteClick()}
> >

View File

@@ -6,10 +6,10 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@@ -42,7 +42,7 @@ export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
export const DisableAuthenticatorAppDialog = () => { export const DisableAuthenticatorAppDialog = () => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { revalidate } = useRevalidator(); const { refreshSession } = useSession();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp'); const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp');
@@ -92,7 +92,7 @@ export const DisableAuthenticatorAppDialog = () => {
onCloseTwoFactorDisableDialog(); onCloseTwoFactorDisableDialog();
}); });
await revalidate(); await refreshSession();
} catch (_err) { } catch (_err) {
toast({ toast({
title: _(msg`Unable to disable two-factor authentication`), title: _(msg`Unable to disable two-factor authentication`),

View File

@@ -5,12 +5,12 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { renderSVG } from 'uqr'; import { renderSVG } from 'uqr';
import { z } from 'zod'; import { z } from 'zod';
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { downloadFile } from '@documenso/lib/client-only/download-file'; import { downloadFile } from '@documenso/lib/client-only/download-file';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@@ -48,7 +48,7 @@ export type EnableAuthenticatorAppDialogProps = {
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => { export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { revalidate } = useRevalidator(); const { refreshSession } = useSession();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null); const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
@@ -74,6 +74,7 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
try { try {
const data = await authClient.twoFactor.setup(); const data = await authClient.twoFactor.setup();
await refreshSession();
setSetup2FAData(data); setSetup2FAData(data);
} catch (err) { } catch (err) {
@@ -92,6 +93,7 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => { const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
try { try {
const data = await authClient.twoFactor.enable({ code: token }); const data = await authClient.twoFactor.enable({ code: token });
await refreshSession();
setRecoveryCodes(data.recoveryCodes); setRecoveryCodes(data.recoveryCodes);
onSuccess?.(); onSuccess?.();
@@ -139,7 +141,6 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) { if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
setRecoveryCodes(null); setRecoveryCodes(null);
void revalidate();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -19,12 +19,15 @@ import {
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZProfileFormSchema = z.object({ export const ZProfileFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), name: z
signature: z.string().min(1, 'Signature Pad cannot be empty'), .string()
.trim()
.min(1, { message: msg`Please enter a valid name.`.id }),
signature: z.string().min(1, { message: msg`Signature Pad cannot be empty.`.id }),
}); });
export const ZTwoFactorAuthTokenSchema = z.object({ export const ZTwoFactorAuthTokenSchema = z.object({
@@ -109,22 +112,20 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
</Label> </Label>
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled /> <Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
</div> </div>
<FormField <FormField
control={form.control} control={form.control}
name="signature" name="signature"
render={({ field: { onChange } }) => ( render={({ field: { onChange, value } }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>Signature</Trans> <Trans>Signature</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<SignaturePad <SignaturePadDialog
className="h-44 w-full"
disabled={isSubmitting} disabled={isSubmitting}
containerClassName={cn('rounded-lg border bg-background')} value={value}
defaultValue={user.signature ?? undefined}
onChange={(v) => onChange(v ?? '')} onChange={(v) => onChange(v ?? '')}
allowTypedSignature={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -134,7 +135,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
</fieldset> </fieldset>
<Button type="submit" loading={isSubmitting} className="self-end"> <Button type="submit" loading={isSubmitting} className="self-end">
{isSubmitting ? <Trans>Updating profile...</Trans> : <Trans>Update profile</Trans>} <Trans>Update profile</Trans>
</Button> </Button>
</form> </form>
</Form> </Form>

View File

@@ -30,7 +30,7 @@ import {
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton'; import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton';
@@ -353,16 +353,15 @@ export const SignUpForm = ({
<FormField <FormField
control={form.control} control={form.control}
name="signature" name="signature"
render={({ field: { onChange } }) => ( render={({ field: { onChange, value } }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>Sign Here</Trans> <Trans>Sign Here</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<SignaturePad <SignaturePadDialog
className="h-36 w-full"
disabled={isSubmitting} disabled={isSubmitting}
containerClassName="mt-2 rounded-lg border bg-background" value={value}
onChange={(v) => onChange(v ?? '')} onChange={(v) => onChange(v ?? '')}
/> />
</FormControl> </FormControl>
@@ -531,6 +530,27 @@ export const SignUpForm = ({
</div> </div>
</form> </form>
</Form> </Form>
<p className="text-muted-foreground mt-6 text-xs">
<Trans>
By proceeding, you agree to our{' '}
<Link
to="https://documen.so/terms"
target="_blank"
className="text-documenso-700 duration-200 hover:opacity-70"
>
Terms of Service
</Link>{' '}
and{' '}
<Link
to="https://documen.so/privacy"
target="_blank"
className="text-documenso-700 duration-200 hover:opacity-70"
>
Privacy Policy
</Link>
.
</Trans>
</p>
</div> </div>
</div> </div>
); );

View File

@@ -308,7 +308,7 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
<div className="flex flex-row justify-end space-x-4"> <div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}> <Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Save</Trans> <Trans>Update</Trans>
</Button> </Button>
</div> </div>
</fieldset> </fieldset>

View File

@@ -8,12 +8,15 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
import { import {
SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode, isValidLanguageCode,
} from '@documenso/lib/constants/i18n'; } from '@documenso/lib/constants/i18n';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { Alert } from '@documenso/ui/primitives/alert'; import { Alert } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -23,7 +26,9 @@ import {
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -38,8 +43,10 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
documentVisibility: z.nativeEnum(DocumentVisibility), documentVisibility: z.nativeEnum(DocumentVisibility),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES), documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
includeSenderDetails: z.boolean(), includeSenderDetails: z.boolean(),
typedSignatureEnabled: z.boolean(),
includeSigningCertificate: z.boolean(), includeSigningCertificate: z.boolean(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
}); });
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>; type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
@@ -69,8 +76,8 @@ export const TeamDocumentPreferencesForm = ({
? settings?.documentLanguage ? settings?.documentLanguage
: 'en', : 'en',
includeSenderDetails: settings?.includeSenderDetails ?? false, includeSenderDetails: settings?.includeSenderDetails ?? false,
typedSignatureEnabled: settings?.typedSignatureEnabled ?? true,
includeSigningCertificate: settings?.includeSigningCertificate ?? true, includeSigningCertificate: settings?.includeSigningCertificate ?? true,
signatureTypes: extractTeamSignatureSettings(settings),
}, },
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema), resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
}); });
@@ -84,7 +91,7 @@ export const TeamDocumentPreferencesForm = ({
documentLanguage, documentLanguage,
includeSenderDetails, includeSenderDetails,
includeSigningCertificate, includeSigningCertificate,
typedSignatureEnabled, signatureTypes,
} = data; } = data;
await updateTeamDocumentPreferences({ await updateTeamDocumentPreferences({
@@ -93,8 +100,10 @@ export const TeamDocumentPreferencesForm = ({
documentVisibility, documentVisibility,
documentLanguage, documentLanguage,
includeSenderDetails, includeSenderDetails,
typedSignatureEnabled,
includeSigningCertificate, includeSigningCertificate,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
}, },
}); });
@@ -190,6 +199,44 @@ export const TeamDocumentPreferencesForm = ({
)} )}
/> />
<FormField
control={form.control}
name="signatureTypes"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="flex flex-row items-center">
<Trans>Default Signature Settings</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: _(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
enableSearch={false}
emptySelectionPlaceholder="Select signature types"
testId="signature-types-combobox"
/>
</FormControl>
{form.formState.errors.signatureTypes ? (
<FormMessage />
) : (
<FormDescription>
<Trans>
Controls which signatures are allowed to be used when signing a document.
</Trans>
</FormDescription>
)}
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="includeSenderDetails" name="includeSenderDetails"
@@ -238,36 +285,6 @@ export const TeamDocumentPreferencesForm = ({
)} )}
/> />
<FormField
control={form.control}
name="typedSignatureEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enable Typed Signature</Trans>
</FormLabel>
<div>
<FormControl className="block">
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormDescription>
<Trans>
Controls whether the recipients can sign the documents using a typed signature.
Enable or disable the typed signature globally.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="includeSigningCertificate" name="includeSigningCertificate"
@@ -301,7 +318,7 @@ export const TeamDocumentPreferencesForm = ({
<div className="flex flex-row justify-end space-x-4"> <div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}> <Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Save</Trans> <Trans>Update</Trans>
</Button> </Button>
</div> </div>
</fieldset> </fieldset>

View File

@@ -76,7 +76,7 @@ export const AppNavDesktop = ({
<Button <Button
variant="outline" variant="outline"
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg" className="text-muted-foreground flex w-full max-w-96 items-center justify-between rounded-lg"
onClick={() => setIsCommandMenuOpen(true)} onClick={() => setIsCommandMenuOpen(true)}
> >
<div className="flex items-center"> <div className="flex items-center">

View File

@@ -50,7 +50,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
<Link to="/" onClick={handleMenuItemClick}> <Link to="/" onClick={handleMenuItemClick}>
<img <img
src={LogoImage} src={LogoImage}
alt="Documenso Logo" alt="BLS sign Logo"
className="dark:invert" className="dark:invert"
width={170} width={170}
height={25} height={25}
@@ -83,7 +83,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
</div> </div>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved. © {new Date().getFullYear()} Made by BLS media
</p> </p>
</div> </div>
</SheetContent> </SheetContent>

View File

@@ -128,7 +128,7 @@ export const DirectTemplateConfigureForm = ({
derivedRecipientAccessAuth !== null || derivedRecipientAccessAuth !== null ||
user?.email !== undefined user?.email !== undefined
} }
placeholder="recipient@documenso.com" placeholder="recipient@sign.bls.media"
/> />
</FormControl> </FormControl>

View File

@@ -12,7 +12,7 @@ import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper'; import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -136,7 +136,7 @@ export const DirectTemplatePageView = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer <PDFViewer
key={template.id} key={template.id}
documentData={template.templateDocumentData} documentData={template.templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field, Recipient, Signature } from '@prisma/client'; import type { Field, Recipient, Signature } from '@prisma/client';
@@ -24,7 +24,6 @@ import type {
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { import {
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter, DocumentFlowFormContainerFooter,
@@ -35,7 +34,7 @@ import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/ty
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useStep } from '@documenso/ui/primitives/stepper'; import { useStep } from '@documenso/ui/primitives/stepper';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
@@ -73,8 +72,7 @@ export const DirectTemplateSigningForm = ({
template, template,
onSubmit, onSubmit,
}: DirectTemplateSigningFormProps) => { }: DirectTemplateSigningFormProps) => {
const { fullName, signature, signatureValid, setFullName, setSignature } = const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
useRequiredDocumentSigningContext();
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(directRecipientFields); const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(directRecipientFields);
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
@@ -91,7 +89,7 @@ export const DirectTemplateSigningForm = ({
const tempField: DirectTemplateLocalField = { const tempField: DirectTemplateLocalField = {
...field, ...field,
customText: value.value, customText: value.value ?? '',
inserted: true, inserted: true,
signedValue: value, signedValue: value,
}; };
@@ -102,8 +100,8 @@ export const DirectTemplateSigningForm = ({
created: new Date(), created: new Date(),
recipientId: 1, recipientId: 1,
fieldId: 1, fieldId: 1,
signatureImageAsBase64: value.value.startsWith('data:') ? value.value : null, signatureImageAsBase64: value.value?.startsWith('data:') ? value.value : null,
typedSignature: value.value.startsWith('data:') ? null : value.value, typedSignature: value.value && !value.value.startsWith('data:') ? value.value : null,
} satisfies Signature; } satisfies Signature;
} }
@@ -135,8 +133,6 @@ export const DirectTemplateSigningForm = ({
); );
}; };
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
const uninsertedFields = useMemo(() => { const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(localFields.filter((field) => !field.inserted)); return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
}, [localFields]); }, [localFields]);
@@ -149,10 +145,6 @@ export const DirectTemplateSigningForm = ({
const handleSubmit = async () => { const handleSubmit = async () => {
setValidateUninsertedFields(true); setValidateUninsertedFields(true);
if (hasSignatureField && !signatureValid) {
return;
}
const isFieldsValid = validateFieldsInserted(localFields); const isFieldsValid = validateFieldsInserted(localFields);
if (!isFieldsValid) { if (!isFieldsValid) {
@@ -170,6 +162,55 @@ export const DirectTemplateSigningForm = ({
// Do not reset to false since we do a redirect. // Do not reset to false since we do a redirect.
}; };
useEffect(() => {
const updatedFields = [...localFields];
localFields.forEach((field) => {
const index = updatedFields.findIndex((f) => f.id === field.id);
let value = '';
match(field.type)
.with(FieldType.TEXT, () => {
const meta = field.fieldMeta ? ZTextFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.text ?? '';
}
})
.with(FieldType.NUMBER, () => {
const meta = field.fieldMeta ? ZNumberFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.value ?? '';
}
})
.with(FieldType.DROPDOWN, () => {
const meta = field.fieldMeta ? ZDropdownFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.defaultValue ?? '';
}
});
if (value) {
const signedValue = {
token: directRecipient.token,
fieldId: field.id,
value,
};
updatedFields[index] = {
...field,
customText: value,
inserted: true,
signedValue,
};
}
});
setLocalFields(updatedFields);
}, []);
return ( return (
<DocumentSigningRecipientProvider recipient={directRecipient}> <DocumentSigningRecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} /> <DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
@@ -191,6 +232,8 @@ export const DirectTemplateSigningForm = ({
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled} typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
/> />
)) ))
.with(FieldType.INITIALS, () => ( .with(FieldType.INITIALS, () => (
@@ -335,19 +378,15 @@ export const DirectTemplateSigningForm = ({
<Trans>Signature</Trans> <Trans>Signature</Trans>
</Label> </Label>
<Card className="mt-2" gradient degrees={-120}> <SignaturePadDialog
<CardContent className="p-0"> className="mt-2"
<SignaturePad disabled={isSubmitting}
className="h-44 w-full" value={signature ?? ''}
disabled={isSubmitting} onChange={(value) => setSignature(value)}
defaultValue={signature ?? undefined} typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
onChange={(value) => { uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
setSignature(value); drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
}} />
allowTypedSignature={template.templateMeta?.typedSignatureEnabled}
/>
</CardContent>
</Card>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -45,26 +45,24 @@ export const DocumentSigningCheckboxField = ({
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta); const parsedFieldMeta = ZCheckboxFieldMeta.parse(
field.fieldMeta ?? {
type: 'checkbox',
values: [{ id: 1, checked: false, value: '' }],
},
);
const values = parsedFieldMeta.values?.map((item) => ({ const values = parsedFieldMeta.values?.map((item) => ({
...item, ...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`, value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
})); }));
const parsedCheckedValues = useMemo(
() => fromCheckboxValue(field.customText),
[field.customText],
);
const [checkedValues, setCheckedValues] = useState( const [checkedValues, setCheckedValues] = useState(
field.inserted && parsedCheckedValues.length > 0 values
? parsedCheckedValues ?.map((item) =>
: values item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '',
?.map((item) => )
item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '', .filter(Boolean) || [],
)
.filter(Boolean) || [],
); );
const isReadOnly = parsedFieldMeta.readOnly; const isReadOnly = parsedFieldMeta.readOnly;
@@ -99,6 +97,10 @@ export const DocumentSigningCheckboxField = ({
const onSign = async (authOptions?: TRecipientActionAuth) => { const onSign = async (authOptions?: TRecipientActionAuth) => {
try { try {
if (!isLengthConditionMet) {
return;
}
const payload: TSignFieldWithTokenMutationSchema = { const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
@@ -179,29 +181,47 @@ export const DocumentSigningCheckboxField = ({
let updatedValues: string[] = []; let updatedValues: string[] = [];
try { try {
const itemValue = item.value.length > 0 ? item.value : `empty-value-${item.id}`; const isChecked = checkedValues.includes(
const isChecked = checkedValues.includes(itemValue); item.value.length > 0 ? item.value : `empty-value-${item.id}`,
);
if (!isChecked) { if (!isChecked) {
updatedValues = [...checkedValues, itemValue]; updatedValues = [
...checkedValues,
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
];
} else { } else {
updatedValues = checkedValues.filter((v) => v !== itemValue); updatedValues = checkedValues.filter(
(v) => v !== item.value && v !== `empty-value-${item.id}`,
);
} }
setCheckedValues(updatedValues); setCheckedValues(updatedValues);
await removeSignedFieldWithToken({ const removePayload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
}); };
if (updatedValues.length > 0) { if (onUnsignField) {
await signFieldWithToken({ await onUnsignField(removePayload);
} else {
await removeSignedFieldWithToken(removePayload);
}
if (updatedValues.length > 0 && shouldAutoSignField) {
const signPayload: TSignFieldWithTokenMutationSchema = {
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
value: toCheckboxValue(updatedValues), value: toCheckboxValue(updatedValues),
isBase64: true, isBase64: true,
}); };
if (onSignField) {
await onSignField(signPayload);
} else {
await signFieldWithToken(signPayload);
}
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -216,12 +236,6 @@ export const DocumentSigningCheckboxField = ({
} }
}; };
useEffect(() => {
if (field.inserted && parsedCheckedValues.length > 0) {
setCheckedValues(parsedCheckedValues);
}
}, [field.inserted, parsedCheckedValues]);
useEffect(() => { useEffect(() => {
if (shouldAutoSignField) { if (shouldAutoSignField) {
void executeActionAuthProcedure({ void executeActionAuthProcedure({
@@ -231,6 +245,11 @@ export const DocumentSigningCheckboxField = ({
} }
}, [checkedValues, isLengthConditionMet, field.inserted]); }, [checkedValues, isLengthConditionMet, field.inserted]);
const parsedCheckedValues = useMemo(
() => fromCheckboxValue(field.customText),
[field.customText],
);
return ( return (
<DocumentSigningFieldContainer <DocumentSigningFieldContainer
field={field} field={field}
@@ -254,17 +273,16 @@ export const DocumentSigningCheckboxField = ({
<div className="z-50 flex flex-col gap-y-2"> <div className="z-50 flex flex-col gap-y-2">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => { {values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`; const itemValue = item.value || `empty-value-${item.id}`;
const checkboxId = `checkbox-field-${field.id}-${index}`;
return ( return (
<div key={index} className="flex items-center gap-x-1.5"> <div key={index} className="flex items-center gap-x-1.5">
<Checkbox <Checkbox
className="h-4 w-4" className="h-4 w-4"
id={checkboxId} id={`checkbox-${index}`}
checked={checkedValues.includes(itemValue)} checked={checkedValues.includes(itemValue)}
onCheckedChange={() => handleCheckboxChange(item.value, item.id)} onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
/> />
<Label htmlFor={checkboxId}> <Label htmlFor={`checkbox-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value} {item.value.includes('empty-value-') ? '' : item.value}
</Label> </Label>
</div> </div>
@@ -275,21 +293,20 @@ export const DocumentSigningCheckboxField = ({
)} )}
{field.inserted && ( {field.inserted && (
<div className="flex flex-col gap-y-2"> <div className="flex flex-col gap-y-1">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => { {values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`; const itemValue = item.value || `empty-value-${item.id}`;
const checkboxId = `checkbox-field-${field.id}-${index}-inserted`;
return ( return (
<div key={index} className="flex items-center gap-x-1.5"> <div key={index} className="flex items-center gap-x-1.5">
<Checkbox <Checkbox
className="h-4 w-4" className="h-3 w-3"
id={checkboxId} id={`checkbox-${index}`}
checked={checkedValues.includes(itemValue)} checked={parsedCheckedValues.includes(itemValue)}
disabled={isLoading} disabled={isLoading}
onCheckedChange={() => void handleCheckboxOptionClick(item)} onCheckedChange={() => void handleCheckboxOptionClick(item)}
/> />
<Label htmlFor={checkboxId}> <Label htmlFor={`checkbox-${index}`} className="text-xs">
{item.value.includes('empty-value-') ? '' : item.value} {item.value.includes('empty-value-') ? '' : item.value}
</Label> </Label>
</div> </div>

View File

@@ -1,8 +1,12 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client'; import type { Field } from '@prisma/client';
import { RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -13,6 +17,15 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure'; import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
@@ -21,11 +34,23 @@ export type DocumentSigningCompleteDialogProps = {
documentTitle: string; documentTitle: string;
fields: Field[]; fields: Field[];
fieldsValidated: () => void | Promise<void>; fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>; onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
role: RecipientRole; role: RecipientRole;
disabled?: boolean; disabled?: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: {
name: string;
email: string;
};
}; };
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
export const DocumentSigningCompleteDialog = ({ export const DocumentSigningCompleteDialog = ({
isSubmitting, isSubmitting,
documentTitle, documentTitle,
@@ -34,19 +59,54 @@ export const DocumentSigningCompleteDialog = ({
onSignatureComplete, onSignatureComplete,
role, role,
disabled = false, disabled = false,
allowDictateNextSigner = false,
defaultNextSigner,
}: DocumentSigningCompleteDialogProps) => { }: DocumentSigningCompleteDialogProps) => {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
},
});
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]); const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
if (isSubmitting || !isComplete) { if (form.formState.isSubmitting || !isComplete) {
return; return;
} }
if (open) {
form.reset({
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
});
}
setIsEditingNextSigner(false);
setShowDialog(open); setShowDialog(open);
}; };
const onFormSubmit = async (data: TNextSignerFormSchema) => {
console.log('data', data);
console.log('form.formState.errors', form.formState.errors);
try {
if (allowDictateNextSigner && data.name && data.email) {
await onSignatureComplete({ name: data.name, email: data.email });
} else {
await onSignatureComplete();
}
} catch (error) {
console.error('Error completing signature:', error);
}
};
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
return ( return (
<Dialog open={showDialog} onOpenChange={handleOpenChange}> <Dialog open={showDialog} onOpenChange={handleOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -58,92 +118,196 @@ export const DocumentSigningCompleteDialog = ({
loading={isSubmitting} loading={isSubmitting}
disabled={disabled} disabled={disabled}
> >
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>} {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()}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogTitle> <Form {...form}>
<div className="text-foreground text-xl font-semibold"> <form onSubmit={form.handleSubmit(onFormSubmit)}>
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>} <fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>} <DialogTitle>
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>} <div className="text-foreground text-xl font-semibold">
</div> {match(role)
</DialogTitle> .with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]"> <div className="text-muted-foreground max-w-[50ch]">
{role === RecipientRole.VIEWER && ( {match(role)
<span> .with(RecipientRole.VIEWER, () => (
<Trans> <span>
<span className="inline-flex flex-wrap"> <Trans>
You are about to complete viewing " <span className="inline-flex flex-wrap">
<span className="inline-block max-w-[11rem] truncate align-baseline"> You are about to complete viewing "
{documentTitle} <span className="inline-block max-w-[11rem] truncate align-baseline">
</span> {documentTitle}
". </span>
</span> ".
<br /> Are you sure? </span>
</Trans> <br /> Are you sure?
</span> </Trans>
)} </span>
{role === RecipientRole.SIGNER && ( ))
<span> .with(RecipientRole.SIGNER, () => (
<Trans> <span>
<span className="inline-flex flex-wrap"> <Trans>
You are about to complete signing " <span className="inline-flex flex-wrap">
<span className="inline-block max-w-[11rem] truncate align-baseline"> You are about to complete signing "
{documentTitle} <span className="inline-block max-w-[11rem] truncate align-baseline">
</span> {documentTitle}
". </span>
</span> ".
<br /> Are you sure? </span>
</Trans> <br /> Are you sure?
</span> </Trans>
)} </span>
{role === RecipientRole.APPROVER && ( ))
<span> .with(RecipientRole.APPROVER, () => (
<Trans> <span>
<span className="inline-flex flex-wrap"> <Trans>
You are about to complete approving{' '} <span className="inline-flex flex-wrap">
<span className="inline-block max-w-[11rem] truncate align-baseline"> You are about to complete approving{' '}
"{documentTitle}" <span className="inline-block max-w-[11rem] truncate align-baseline">
</span> "{documentTitle}"
. </span>
</span> .
<br /> Are you sure? </span>
</Trans> <br /> Are you sure?
</span> </Trans>
)} </span>
</div> ))
.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>
))}
</div>
<DocumentSigningDisclosure className="mt-4" /> {allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<DialogFooter> <Button
<div className="flex w-full flex-1 flex-nowrap gap-4"> type="button"
<Button className="mt-2"
type="button" variant="outline"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10" size="sm"
variant="secondary" onClick={() => setIsEditingNextSigner((prev) => !prev)}
onClick={() => { >
setShowDialog(false); <Trans>Update Recipient</Trans>
}} </Button>
> </div>
<Trans>Cancel</Trans> )}
</Button>
<Button {isEditingNextSigner && (
type="button" <div className="flex flex-col gap-4 md:flex-row">
className="flex-1" <FormField
disabled={!isComplete} control={form.control}
loading={isSubmitting} name="name"
onClick={onSignatureComplete} render={({ field }) => (
> <FormItem className="flex-1">
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>} <FormLabel>
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>} <Trans>Name</Trans>
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>} </FormLabel>
</Button> <FormControl>
</div> <Input
</DialogFooter> {...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
<DialogFooter className="mt-4">
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
className="flex-1"
disabled={!isComplete || !isNextSignerValid}
loading={form.formState.isSubmitting}
>
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -181,6 +181,23 @@ export const DocumentSigningFieldContainer = ({
</button> </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} {children}
</FieldRootContainer> </FieldRootContainer>
</div> </div>

View File

@@ -1,11 +1,13 @@
import { useId, useMemo, useState } from 'react'; import { useId, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client'; import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
@@ -18,17 +20,26 @@ import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog'; import {
AssistantConfirmationDialog,
type NextSigner,
} from '../../dialogs/assistant-confirmation-dialog';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { useRequiredDocumentSigningContext } from './document-signing-provider'; import { useRequiredDocumentSigningContext } from './document-signing-provider';
export const ZSigningFormSchema = z.object({
name: z.string().min(1, 'Name is required').optional(),
email: z.string().email('Invalid email address').optional(),
});
export type TSigningFormSchema = z.infer<typeof ZSigningFormSchema>;
export type DocumentSigningFormProps = { export type DocumentSigningFormProps = {
document: DocumentAndSender; document: DocumentAndSender;
recipient: Recipient; recipient: Recipient;
@@ -59,8 +70,7 @@ export const DocumentSigningForm = ({
const assistantSignersId = useId(); const assistantSignersId = useId();
const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } = const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
useRequiredDocumentSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false); const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
@@ -75,7 +85,9 @@ export const DocumentSigningForm = ({
}, },
}); });
const { handleSubmit, formState } = useForm(); const { handleSubmit, formState } = useForm<TSigningFormSchema>({
resolver: zodResolver(ZSigningFormSchema),
});
// Keep the loading state going if successful since the redirect may take some time. // Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful; const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
@@ -100,20 +112,32 @@ export const DocumentSigningForm = ({
validateFieldsInserted(fieldsRequiringValidation); validateFieldsInserted(fieldsRequiringValidation);
}; };
const onFormSubmit = async () => { const onFormSubmit = async (data: TSigningFormSchema) => {
setValidateUninsertedFields(true); try {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation); const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
if (hasSignatureField && !signatureValid) { if (!isFieldsValid) {
return; return;
}
const nextSigner =
data.email && data.name
? {
email: data.email,
name: data.name,
}
: undefined;
await completeDocument(undefined, nextSigner);
} catch (error) {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'An error occurred while signing',
variant: 'destructive',
});
} }
if (!isFieldsValid) {
return;
}
await completeDocument();
}; };
const onAssistantFormSubmit = () => { const onAssistantFormSubmit = () => {
@@ -124,11 +148,11 @@ export const DocumentSigningForm = ({
setIsConfirmationDialogOpen(true); setIsConfirmationDialogOpen(true);
}; };
const handleAssistantConfirmDialogSubmit = async () => { const handleAssistantConfirmDialogSubmit = async (nextSigner?: NextSigner) => {
setIsAssistantSubmitting(true); setIsAssistantSubmitting(true);
try { try {
await completeDocument(); await completeDocument(undefined, nextSigner);
} catch (err) { } catch (err) {
toast({ toast({
title: 'Error', title: 'Error',
@@ -141,12 +165,18 @@ export const DocumentSigningForm = ({
} }
}; };
const completeDocument = async (authOptions?: TRecipientActionAuth) => { const completeDocument = async (
await completeDocumentWithToken({ authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token, token: recipient.token,
documentId: document.id, documentId: document.id,
authOptions, authOptions,
}); ...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', { analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id, signerId: recipient.id,
@@ -161,6 +191,31 @@ export const DocumentSigningForm = ({
} }
}; };
const nextRecipient = useMemo(() => {
if (
!document.documentMeta?.signingOrder ||
document.documentMeta.signingOrder !== 'SEQUENTIAL'
) {
return undefined;
}
const sortedRecipients = allRecipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
console.log('nextRecipient', nextRecipient);
return ( return (
<div <div
className={cn( className={cn(
@@ -210,12 +265,19 @@ export const DocumentSigningForm = ({
<DocumentSigningCompleteDialog <DocumentSigningCompleteDialog
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title} documentTitle={document.title}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated} fieldsValidated={fieldsValidated}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role} role={recipient.role}
disabled={!isRecipientsTurn} allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/> />
</div> </div>
</div> </div>
@@ -306,6 +368,14 @@ export const DocumentSigningForm = ({
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)} onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit} onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting} isSubmitting={isAssistantSubmitting}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/> />
</form> </form>
</> </>
@@ -313,7 +383,11 @@ export const DocumentSigningForm = ({
<> <>
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
<Trans>Please review the document before signing.</Trans> {recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
<Trans>Please review the document before approving.</Trans>
) : (
<Trans>Please review the document before signing.</Trans>
)}
</p> </p>
<hr className="border-border mb-8 mt-4" /> <hr className="border-border mb-8 mt-4" />
@@ -337,63 +411,58 @@ export const DocumentSigningForm = ({
/> />
</div> </div>
<div> {hasSignatureField && (
<Label htmlFor="Signature"> <div>
<Trans>Signature</Trans> <Label htmlFor="Signature">
</Label> <Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}> <SignaturePadDialog
<CardContent className="p-0"> className="mt-2"
<SignaturePad disabled={isSubmitting}
className="h-44 w-full" value={signature ?? ''}
disabled={isSubmitting} onChange={(v) => setSignature(v ?? '')}
defaultValue={signature ?? undefined} typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
onValidityChange={(isValid) => { uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
setSignatureValid(isValid); drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
}} />
onChange={(value) => { </div>
if (signatureValid) { )}
setSignature(value);
}
}}
allowTypedSignature={document.documentMeta?.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>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
disabled={!isRecipientsTurn}
/>
</div> </div>
</fieldset> </fieldset>
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting || isAssistantSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</div>
</form> </form>
</> </>
)} )}

View File

@@ -21,7 +21,7 @@ import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/fie
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign'; import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
@@ -40,9 +40,9 @@ import { DocumentReadOnlyFields } from '~/components/general/document/document-r
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider'; import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
export type SigningPageViewProps = { export type DocumentSigningPageViewProps = {
document: DocumentAndSender;
recipient: RecipientWithFields; recipient: RecipientWithFields;
document: DocumentAndSender;
fields: Field[]; fields: Field[];
completedFields: CompletedField[]; completedFields: CompletedField[];
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
@@ -50,13 +50,13 @@ export type SigningPageViewProps = {
}; };
export const DocumentSigningPageView = ({ export const DocumentSigningPageView = ({
document,
recipient, recipient,
document,
fields, fields,
completedFields, completedFields,
isRecipientsTurn, isRecipientsTurn,
allRecipients = [], allRecipients = [],
}: SigningPageViewProps) => { }: DocumentSigningPageViewProps) => {
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id); const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
@@ -140,12 +140,7 @@ export const DocumentSigningPageView = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer <PDFViewer key={documentData.id} documentData={documentData} document={document} />
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
/>
</CardContent> </CardContent>
</Card> </Card>
@@ -182,6 +177,8 @@ export const DocumentSigningPageView = ({
key={field.id} key={field.id}
field={field} field={field}
typedSignatureEnabled={documentMeta?.typedSignatureEnabled} typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
/> />
)) ))
.with(FieldType.INITIALS, () => ( .with(FieldType.INITIALS, () => (

View File

@@ -1,4 +1,6 @@
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, useContext, useState } from 'react';
import { isBase64Image } from '@documenso/lib/constants/signatures';
export type DocumentSigningContextValue = { export type DocumentSigningContextValue = {
fullName: string; fullName: string;
@@ -7,8 +9,6 @@ export type DocumentSigningContextValue = {
setEmail: (_value: string) => void; setEmail: (_value: string) => void;
signature: string | null; signature: string | null;
setSignature: (_value: string | null) => void; setSignature: (_value: string | null) => void;
signatureValid: boolean;
setSignatureValid: (_valid: boolean) => void;
}; };
const DocumentSigningContext = createContext<DocumentSigningContextValue | null>(null); const DocumentSigningContext = createContext<DocumentSigningContextValue | null>(null);
@@ -31,6 +31,9 @@ export interface DocumentSigningProviderProps {
fullName?: string | null; fullName?: string | null;
email?: string | null; email?: string | null;
signature?: string | null; signature?: string | null;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
@@ -38,18 +41,31 @@ export const DocumentSigningProvider = ({
fullName: initialFullName, fullName: initialFullName,
email: initialEmail, email: initialEmail,
signature: initialSignature, signature: initialSignature,
typedSignatureEnabled = true,
uploadSignatureEnabled = true,
drawSignatureEnabled = true,
children, children,
}: DocumentSigningProviderProps) => { }: DocumentSigningProviderProps) => {
const [fullName, setFullName] = useState(initialFullName || ''); const [fullName, setFullName] = useState(initialFullName || '');
const [email, setEmail] = useState(initialEmail || ''); const [email, setEmail] = useState(initialEmail || '');
const [signature, setSignature] = useState(initialSignature || null);
const [signatureValid, setSignatureValid] = useState(true);
useEffect(() => { // Ensure the user signature doesn't show up if it's not allowed.
if (initialSignature) { const [signature, setSignature] = useState(
setSignature(initialSignature); (() => {
} const sig = initialSignature || '';
}, [initialSignature]); const isBase64 = isBase64Image(sig);
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled)) {
return sig;
}
if (!isBase64 && typedSignatureEnabled) {
return sig;
}
return null;
})(),
);
return ( return (
<DocumentSigningContext.Provider <DocumentSigningContext.Provider
@@ -60,8 +76,6 @@ export const DocumentSigningProvider = ({
setEmail, setEmail,
signature, signature,
setSignature, setSignature,
signatureValid,
setSignatureValid,
}} }}
> >
{children} {children}

View File

@@ -31,10 +31,7 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ZRejectDocumentFormSchema = z.object({ const ZRejectDocumentFormSchema = z.object({
reason: z reason: z.string().max(500, msg`Reason must be less than 500 characters`),
.string()
.min(5, msg`Please provide a reason`)
.max(500, msg`Reason must be less than 500 characters`),
}); });
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>; type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
@@ -141,7 +138,7 @@ export function DocumentSigningRejectDialog({
<Textarea <Textarea
{...field} {...field}
rows={4} rows={4}
placeholder="Please provide a reason for rejecting this document" placeholder="Bitte gib' einen Grund für die Ablehnung an."
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
/> />
</FormControl> </FormControl>

View File

@@ -17,7 +17,6 @@ import type {
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -29,11 +28,14 @@ import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text'; type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
export type DocumentSigningSignatureFieldProps = { export type DocumentSigningSignatureFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
typedSignatureEnabled?: boolean; typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
}; };
export const DocumentSigningSignatureField = ({ export const DocumentSigningSignatureField = ({
@@ -41,6 +43,8 @@ export const DocumentSigningSignatureField = ({
onSignField, onSignField,
onUnsignField, onUnsignField,
typedSignatureEnabled, typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
}: DocumentSigningSignatureFieldProps) => { }: DocumentSigningSignatureFieldProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
@@ -52,12 +56,8 @@ export const DocumentSigningSignatureField = ({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState(2); const [fontSize, setFontSize] = useState(2);
const { const { signature: providedSignature, setSignature: setProvidedSignature } =
signature: providedSignature, useRequiredDocumentSigningContext();
setSignature: setProvidedSignature,
signatureValid,
setSignatureValid,
} = useRequiredDocumentSigningContext();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
@@ -89,7 +89,7 @@ export const DocumentSigningSignatureField = ({
}, [field.inserted, signature?.signatureImageAsBase64]); }, [field.inserted, signature?.signatureImageAsBase64]);
const onPreSign = () => { const onPreSign = () => {
if (!providedSignature || !signatureValid) { if (!providedSignature) {
setShowSignatureModal(true); setShowSignatureModal(true);
return false; return false;
} }
@@ -102,6 +102,7 @@ export const DocumentSigningSignatureField = ({
const onDialogSignClick = () => { const onDialogSignClick = () => {
setShowSignatureModal(false); setShowSignatureModal(false);
setProvidedSignature(localSignature); setProvidedSignature(localSignature);
if (!localSignature) { if (!localSignature) {
return; return;
} }
@@ -116,14 +117,14 @@ export const DocumentSigningSignatureField = ({
try { try {
const value = signature || providedSignature; const value = signature || providedSignature;
if (!value || (signature && !signatureValid)) { if (!value) {
setShowSignatureModal(true); setShowSignatureModal(true);
return; return;
} }
const isTypedSignature = !value.startsWith('data:image'); const isTypedSignature = !value.startsWith('data:image');
if (isTypedSignature && !typedSignatureEnabled) { if (isTypedSignature && typedSignatureEnabled === false) {
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`Typed signatures are not allowed. Please draw your signature.`), description: _(msg`Typed signatures are not allowed. Please draw your signature.`),
@@ -275,29 +276,14 @@ export const DocumentSigningSignatureField = ({
</Trans> </Trans>
</DialogTitle> </DialogTitle>
<div className=""> <SignaturePad
<Label htmlFor="signature"> className="mt-2"
<Trans>Signature</Trans> value={localSignature ?? ''}
</Label> onChange={({ value }) => setLocalSignature(value)}
typedSignatureEnabled={typedSignatureEnabled}
<div className="border-border mt-2 rounded-md border"> uploadSignatureEnabled={uploadSignatureEnabled}
<SignaturePad drawSignatureEnabled={drawSignatureEnabled}
id="signature" />
className="h-44 w-full"
onChange={(value) => setLocalSignature(value)}
allowTypedSignature={typedSignatureEnabled}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
/>
</div>
{!signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>Signature is too small. Please provide a more complete signature.</Trans>
</div>
)}
</div>
<DocumentSigningDisclosure /> <DocumentSigningDisclosure />
@@ -317,7 +303,7 @@ export const DocumentSigningSignatureField = ({
<Button <Button
type="button" type="button"
className="flex-1" className="flex-1"
disabled={!localSignature || !signatureValid} disabled={!localSignature}
onClick={() => onDialogSignClick()} onClick={() => onDialogSignClick()}
> >
<Trans>Sign</Trans> <Trans>Sign</Trans>

View File

@@ -1,9 +1,10 @@
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client'; import type { DocumentStatus } from '@prisma/client';
import { DownloadIcon } from 'lucide-react'; import { DownloadIcon } from 'lucide-react';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -76,7 +77,7 @@ export const DocumentCertificateDownloadButton = ({
className={cn('w-full sm:w-auto', className)} className={cn('w-full sm:w-auto', className)}
loading={isPending} loading={isPending}
variant="outline" variant="outline"
disabled={documentStatus !== DocumentStatus.COMPLETED} disabled={!isDocumentCompleted(documentStatus)}
onClick={() => void onDownloadCertificatesClick()} onClick={() => void onDownloadCertificatesClick()}
> >
{!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />} {!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />}

View File

@@ -5,6 +5,7 @@ import { useLingui } from '@lingui/react';
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client'; import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
import { useNavigate, useSearchParams } from 'react-router'; import { useNavigate, useSearchParams } from 'react-router';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n'; import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import { import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION, DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
@@ -24,7 +25,7 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types'; import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper'; import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -71,7 +72,7 @@ export const DocumentEditForm = ({
const { recipients, fields } = document; const { recipients, fields } = document;
const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({ const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => { onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData( utils.document.getDocumentWithDetailsById.setData(
@@ -132,9 +133,6 @@ export const DocumentEditForm = ({
}, },
}); });
const { mutateAsync: setPasswordForDocument } =
trpc.document.setPasswordForDocument.useMutation();
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = { const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
settings: { settings: {
title: msg`General`, title: msg`General`,
@@ -177,7 +175,7 @@ export const DocumentEditForm = ({
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => { const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
try { try {
const { timezone, dateFormat, redirectUrl, language } = data.meta; const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
await updateDocument({ await updateDocument({
documentId: document.id, documentId: document.id,
@@ -193,6 +191,9 @@ export const DocumentEditForm = ({
dateFormat, dateFormat,
redirectUrl, redirectUrl,
language: isValidLanguageCode(language) ? language : undefined, language: isValidLanguageCode(language) ? language : undefined,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
}, },
}); });
@@ -216,6 +217,13 @@ export const DocumentEditForm = ({
signingOrder: data.signingOrder, signingOrder: data.signingOrder,
}), }),
updateDocument({
documentId: document.id,
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
},
}),
setRecipients({ setRecipients({
documentId: document.id, documentId: document.id,
recipients: data.signers.map((signer) => ({ recipients: data.signers.map((signer) => ({
@@ -245,14 +253,6 @@ export const DocumentEditForm = ({
fields: data.fields, fields: data.fields,
}); });
await updateDocument({
documentId: document.id,
meta: {
typedSignatureEnabled: data.typedSignatureEnabled,
},
});
// Clear all field data from localStorage // Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
@@ -315,13 +315,6 @@ export const DocumentEditForm = ({
} }
}; };
const onPasswordSubmit = async (password: string) => {
await setPasswordForDocument({
documentId: document.id,
password,
});
};
const currentDocumentFlow = documentFlow[step]; const currentDocumentFlow = documentFlow[step];
/** /**
@@ -340,12 +333,10 @@ export const DocumentEditForm = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer <PDFViewer
key={document.documentData.id} key={document.documentData.id}
documentData={document.documentData} documentData={document.documentData}
document={document} document={document}
password={document.documentMeta?.password}
onPasswordSubmit={onPasswordSubmit}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/> />
</CardContent> </CardContent>
@@ -377,6 +368,7 @@ export const DocumentEditForm = ({
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
recipients={recipients} recipients={recipients}
signingOrder={document.documentMeta?.signingOrder} signingOrder={document.documentMeta?.signingOrder}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
fields={fields} fields={fields}
isDocumentEnterprise={isDocumentEnterprise} isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit} onSubmit={onAddSignersFormSubmit}
@@ -390,7 +382,6 @@ export const DocumentEditForm = ({
fields={fields} fields={fields}
onSubmit={onAddFieldsFormSubmit} onSubmit={onAddFieldsFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
teamId={team?.id} teamId={team?.id}
/> />

View File

@@ -9,6 +9,7 @@ import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -32,7 +33,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
const isRecipient = !!recipient; const isRecipient = !!recipient;
const isPending = document.status === DocumentStatus.PENDING; const isPending = document.status === DocumentStatus.PENDING;
const isComplete = document.status === DocumentStatus.COMPLETED; const isComplete = isDocumentCompleted(document);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role; const role = recipient?.role;

View File

@@ -20,6 +20,7 @@ import { useNavigate } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@@ -63,7 +64,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
const isDraft = document.status === DocumentStatus.DRAFT; const isDraft = document.status === DocumentStatus.DRAFT;
const isPending = document.status === DocumentStatus.PENDING; const isPending = document.status === DocumentStatus.PENDING;
const isDeleted = document.deletedAt !== null; const isDeleted = document.deletedAt !== null;
const isComplete = document.status === DocumentStatus.COMPLETED; const isComplete = isDocumentCompleted(document);
const isCurrentTeamDocument = team && document.team?.url === team.url; const isCurrentTeamDocument = team && document.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);

View File

@@ -17,6 +17,7 @@ import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatSigningLink } from '@documenso/lib/utils/recipients'; import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature'; import { SignatureIcon } from '@documenso/ui/icons/signature';
@@ -48,7 +49,7 @@ export const DocumentPageViewRecipients = ({
<Trans>Recipients</Trans> <Trans>Recipients</Trans>
</h1> </h1>
{document.status !== DocumentStatus.COMPLETED && ( {!isDocumentCompleted(document.status) && (
<Link <Link
to={`${documentRootPath}/${document.id}/edit?step=signers`} to={`${documentRootPath}/${document.id}/edit?step=signers`}
title={_(msg`Modify recipients`)} title={_(msg`Modify recipients`)}

View File

@@ -3,7 +3,7 @@ import type { HTMLAttributes } from 'react';
import type { MessageDescriptor } from '@lingui/core'; import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { CheckCircle2, Clock, File } from 'lucide-react'; import { CheckCircle2, Clock, File, XCircle } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@@ -36,6 +36,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
icon: File, icon: File,
color: 'text-yellow-500 dark:text-yellow-200', color: 'text-yellow-500 dark:text-yellow-200',
}, },
REJECTED: {
label: msg`Rejected`,
labelExtended: msg`Document rejected`,
icon: XCircle,
color: 'text-red-500 dark:text-red-300',
},
INBOX: { INBOX: {
label: msg`Inbox`, label: msg`Inbox`,
labelExtended: msg`Document inbox`, labelExtended: msg`Document inbox`,

View File

@@ -62,7 +62,7 @@ export const GenericErrorLayout = ({
const team = useOptionalCurrentTeam(); const team = useOptionalCurrentTeam();
const { subHeading, heading, message } = const { subHeading, heading, message } =
errorCodeMap[errorCode || 404] ?? defaultErrorCodeMap[500]; errorCodeMap[errorCode || 500] ?? defaultErrorCodeMap[500];
return ( return (
<div className="fixed inset-0 z-0 flex h-screen w-screen items-center justify-center"> <div className="fixed inset-0 z-0 flex h-screen w-screen items-center justify-center">

View File

@@ -2,6 +2,9 @@ import { useCallback, useEffect } from 'react';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
/**
* Not really used anymore, this causes random 500s when the user refreshes while this occurs.
*/
export const RefreshOnFocus = () => { export const RefreshOnFocus = () => {
const { revalidate, state } = useRevalidator(); const { revalidate, state } = useRevalidator();

View File

@@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n'; import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import { import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION, DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
@@ -15,7 +16,7 @@ import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper'; import { Stepper } from '@documenso/ui/primitives/stepper';
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields'; import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
@@ -124,6 +125,8 @@ export const TemplateEditForm = ({
}); });
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => { const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
const { signatureTypes } = data.meta;
try { try {
await updateTemplateSettings({ await updateTemplateSettings({
templateId: template.id, templateId: template.id,
@@ -136,6 +139,9 @@ export const TemplateEditForm = ({
}, },
meta: { meta: {
...data.meta, ...data.meta,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined, language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
}, },
}); });
@@ -161,6 +167,7 @@ export const TemplateEditForm = ({
templateId: template.id, templateId: template.id,
meta: { meta: {
signingOrder: data.signingOrder, signingOrder: data.signingOrder,
allowDictateNextSigner: data.allowDictateNextSigner,
}, },
}), }),
@@ -187,13 +194,6 @@ export const TemplateEditForm = ({
fields: data.fields, fields: data.fields,
}); });
await updateTemplateSettings({
templateId: template.id,
meta: {
typedSignatureEnabled: data.typedSignatureEnabled,
},
});
// Clear all field data from localStorage // Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
@@ -236,7 +236,7 @@ export const TemplateEditForm = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer <PDFViewer
key={templateDocumentData.id} key={templateDocumentData.id}
documentData={templateDocumentData} documentData={templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
@@ -271,6 +271,7 @@ export const TemplateEditForm = ({
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
signingOrder={template.templateMeta?.signingOrder} signingOrder={template.templateMeta?.signingOrder}
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
templateDirectLink={template.directLink} templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit} onSubmit={onAddTemplatePlaceholderFormSubmit}
isEnterprise={isEnterprise} isEnterprise={isEnterprise}
@@ -284,7 +285,6 @@ export const TemplateEditForm = ({
fields={fields} fields={fields}
onSubmit={onAddFieldsFormSubmit} onSubmit={onAddFieldsFormSubmit}
teamId={team?.id} teamId={team?.id}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
/> />
</Stepper> </Stepper>
</DocumentFlowFormContainer> </DocumentFlowFormContainer>

View File

@@ -9,6 +9,7 @@ import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -37,7 +38,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const isRecipient = !!recipient; const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT; const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING; const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role; const role = recipient?.role;
const isCurrentTeamDocument = team && row.team?.url === team.url; const isCurrentTeamDocument = team && row.team?.url === team.url;

View File

@@ -22,6 +22,7 @@ import { Link } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@@ -66,7 +67,7 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
// const isRecipient = !!recipient; // const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT; const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING; const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = isDocumentCompleted(row.status);
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isCurrentTeamDocument = team && row.team?.url === team.url; const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);

View File

@@ -9,8 +9,8 @@ import { match } from 'ts-pattern';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema'; import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table';
@@ -77,7 +77,7 @@ export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTab
{ {
header: _(msg`Actions`), header: _(msg`Actions`),
cell: ({ row }) => cell: ({ row }) =>
(!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && ( (!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<DocumentsTableActionButton row={row.original} /> <DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} /> <DocumentsTableActionDropdown row={row.original} />

View File

@@ -17,6 +17,7 @@ function PosthogInit() {
if (postHogConfig) { if (postHogConfig) {
posthog.init(postHogConfig.key, { posthog.init(postHogConfig.key, {
api_host: postHogConfig.host, api_host: postHogConfig.host,
capture_exceptions: true,
}); });
} }
}, []); }, []);
@@ -25,7 +26,7 @@ function PosthogInit() {
} }
async function main() { async function main() {
const locale = detect(fromHtmlTag('lang')) || 'en'; const locale = detect(fromHtmlTag('lang')) || 'de';
await dynamicActivate(locale); await dynamicActivate(locale);

View File

@@ -27,7 +27,6 @@ import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import type { Route } from './+types/root'; import type { Route } from './+types/root';
import stylesheet from './app.css?url'; import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout'; import { GenericErrorLayout } from './components/general/generic-error-layout';
import { RefreshOnFocus } from './components/general/refresh-on-focus';
import { langCookie } from './storage/lang-cookie.server'; import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server'; import { themeSessionResolver } from './storage/theme-session.server';
import { appMetaTags } from './utils/meta'; import { appMetaTags } from './utils/meta';
@@ -159,8 +158,6 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<ScrollRestoration /> <ScrollRestoration />
<Scripts /> <Scripts />
<RefreshOnFocus />
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`, __html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,

View File

@@ -20,7 +20,7 @@ import type { Route } from './+types/_layout';
*/ */
export const shouldRevalidate = () => false; export const shouldRevalidate = () => false;
export const loader = async ({ request }: Route.LoaderArgs) => { export async function loader({ request }: Route.LoaderArgs) {
const requestHeaders = Object.fromEntries(request.headers.entries()); const requestHeaders = Object.fromEntries(request.headers.entries());
const session = await getOptionalSession(request); const session = await getOptionalSession(request);
@@ -40,7 +40,7 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
banner, banner,
limits, limits,
}; };
}; }
export default function Layout({ loaderData }: Route.ComponentProps) { export default function Layout({ loaderData }: Route.ComponentProps) {
const { user, teams } = useSession(); const { user, teams } = useSession();

View File

@@ -103,7 +103,9 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
variant="outline" variant="outline"
loading={isResealDocumentLoading} loading={isResealDocumentLoading}
disabled={document.recipients.some( disabled={document.recipients.some(
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED, (recipient) =>
recipient.signingStatus !== SigningStatus.SIGNED &&
recipient.signingStatus !== SigningStatus.REJECTED,
)} )}
onClick={() => resealDocument({ id: document.id })} onClick={() => resealDocument({ id: document.id })}
> >

View File

@@ -16,7 +16,7 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet'; import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet';
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button'; import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
@@ -196,7 +196,7 @@ export default function DocumentPage() {
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer document={document} key={documentData.id} documentData={documentData} /> <PDFViewer document={document} key={documentData.id} documentData={documentData} />
</CardContent> </CardContent>
</Card> </Card>
@@ -220,6 +220,9 @@ export default function DocumentPage() {
.with(DocumentStatus.COMPLETED, () => ( .with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans> <Trans>This document has been signed by all recipients</Trans>
)) ))
.with(DocumentStatus.REJECTED, () => (
<Trans>This document has been rejected by a recipient</Trans>
))
.with(DocumentStatus.DRAFT, () => ( .with(DocumentStatus.DRAFT, () => (
<Trans>This document is currently a draft and has not been sent</Trans> <Trans>This document is currently a draft and has not been sent</Trans>
)) ))

View File

@@ -1,5 +1,5 @@
import { Plural, Trans } from '@lingui/react/macro'; import { Plural, Trans } from '@lingui/react/macro';
import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client'; import { TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react'; import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router'; import { Link, redirect } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@@ -9,6 +9,7 @@ import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-ent
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentEditForm } from '~/components/general/document/document-edit-form'; import { DocumentEditForm } from '~/components/general/document/document-edit-form';
@@ -71,7 +72,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(documentRootPath); throw redirect(documentRootPath);
} }
if (document.status === InternalDocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
throw redirect(`${documentRootPath}/${documentId}`); throw redirect(`${documentRootPath}/${documentId}`);
} }

View File

@@ -129,7 +129,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
<Trans>Document</Trans> <Trans>Document</Trans>
</Link> </Link>
<div className="flex flex-col justify-between truncate sm:flex-row"> <div className="flex flex-col">
<div> <div>
<h1 <h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl" className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
@@ -137,7 +137,8 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
> >
{document.title} {document.title}
</h1> </h1>
</div>
<div className="mt-1 flex flex-col justify-between sm:flex-row">
<div className="mt-2.5 flex items-center gap-x-6"> <div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatusComponent <DocumentStatusComponent
inheritColor inheritColor
@@ -145,16 +146,15 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
className="text-muted-foreground" className="text-muted-foreground"
/> />
</div> </div>
</div> <div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<DocumentCertificateDownloadButton
className="mr-2"
documentId={document.id}
documentStatus={document.status}
/>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end"> <DocumentAuditLogDownloadButton documentId={document.id} />
<DocumentCertificateDownloadButton </div>
className="mr-2"
documentId={document.id}
documentStatus={document.status}
/>
<DocumentAuditLogDownloadButton documentId={document.id} />
</div> </div>
</div> </div>
@@ -163,7 +163,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
{documentInformation.map((info, i) => ( {documentInformation.map((info, i) => (
<div className="text-foreground text-sm" key={i}> <div className="text-foreground text-sm" key={i}>
<h3 className="font-semibold">{_(info.description)}</h3> <h3 className="font-semibold">{_(info.description)}</h3>
<p className="text-muted-foreground">{info.value}</p> <p className="text-muted-foreground truncate">{info.value}</p>
</div> </div>
))} ))}

View File

@@ -50,6 +50,7 @@ export default function DocumentsPage() {
[ExtendedDocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0, [ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0, [ExtendedDocumentStatus.ALL]: 0,
}); });

View File

@@ -16,6 +16,7 @@ import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscriptio
import { BillingPlans } from '~/components/general/billing-plans'; import { BillingPlans } from '~/components/general/billing-plans';
import { BillingPortalButton } from '~/components/general/billing-portal-button'; import { BillingPortalButton } from '~/components/general/billing-portal-button';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/billing'; import type { Route } from './+types/billing';
@@ -62,17 +63,17 @@ export async function loader({ request }: Route.LoaderArgs) {
const isMissingOrInactiveOrFreePlan = const isMissingOrInactiveOrFreePlan =
!subscription || subscription.status === SubscriptionStatus.INACTIVE; !subscription || subscription.status === SubscriptionStatus.INACTIVE;
return { return superLoaderJson({
prices, prices,
subscription, subscription,
subscriptionProductName: subscriptionProduct?.name, subscriptionProductName: subscriptionProduct?.name,
isMissingOrInactiveOrFreePlan, isMissingOrInactiveOrFreePlan,
}; });
} }
export default function TeamsSettingBillingPage({ loaderData }: Route.ComponentProps) { export default function TeamsSettingBillingPage() {
const { prices, subscription, subscriptionProductName, isMissingOrInactiveOrFreePlan } = const { prices, subscription, subscriptionProductName, isMissingOrInactiveOrFreePlan } =
loaderData; useSuperLoaderData<typeof loader>();
const { i18n } = useLingui(); const { i18n } = useLingui();

View File

@@ -14,6 +14,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import { TeamBillingPortalButton } from '~/components/general/teams/team-billing-portal-button'; import { TeamBillingPortalButton } from '~/components/general/teams/team-billing-portal-button';
import { TeamSettingsBillingInvoicesTable } from '~/components/tables/team-settings-billing-invoices-table'; import { TeamSettingsBillingInvoicesTable } from '~/components/tables/team-settings-billing-invoices-table';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/settings.billing'; import type { Route } from './+types/settings.billing';
@@ -31,16 +32,16 @@ export async function loader({ request, params }: Route.LoaderArgs) {
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId); teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
} }
return { return superLoaderJson({
team, team,
teamSubscription, teamSubscription,
}; });
} }
export default function TeamsSettingBillingPage({ loaderData }: Route.ComponentProps) { export default function TeamsSettingBillingPage() {
const { _ } = useLingui(); const { _ } = useLingui();
const { team, teamSubscription } = loaderData; const { team, teamSubscription } = useSuperLoaderData<typeof loader>();
const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role); const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role);

View File

@@ -9,7 +9,7 @@ import { getTemplateById } from '@documenso/lib/server-only/template/get-templat
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog'; import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper'; import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
@@ -144,11 +144,7 @@ export default function TemplatePage() {
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer <PDFViewer document={template} key={template.id} documentData={templateDocumentData} />
document={template}
key={template.id}
documentData={templateDocumentData}
/>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,14 +1,47 @@
import { redirect } from 'react-router'; import { redirect } from 'react-router';
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ZTeamUrlSchema } from '@documenso/trpc/server/team-router/schema';
import type { Route } from './+types/_index'; import type { Route } from './+types/_index';
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
const { isAuthenticated } = await getOptionalSession(request); const session = await getOptionalSession(request);
if (isAuthenticated) { if (session.isAuthenticated) {
throw redirect('/documents'); const teamUrlCookie = extractCookieFromHeaders('preferred-team-url', request.headers);
const referrer = request.headers.get('referer');
let isReferrerFromTeamUrl = false;
if (referrer) {
const referrerUrl = new URL(referrer);
if (referrerUrl.pathname.startsWith('/t/')) {
isReferrerFromTeamUrl = true;
}
}
const preferredTeamUrl =
teamUrlCookie && ZTeamUrlSchema.safeParse(teamUrlCookie).success ? teamUrlCookie : undefined;
// Early return for no preferred team.
if (!preferredTeamUrl || isReferrerFromTeamUrl) {
throw redirect('/documents');
}
const teams = await getTeams({ userId: session.user.id });
const currentTeam = teams.find((team) => team.url === preferredTeamUrl);
if (!currentTeam) {
throw redirect('/documents');
}
throw redirect(formatDocumentsPath(currentTeam.url));
} }
throw redirect('/signin'); throw redirect('/signin');

View File

@@ -1,11 +1,12 @@
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { FieldType } from '@prisma/client'; import { FieldType, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { redirect } from 'react-router'; import { redirect } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js'; import { UAParser } from 'ua-parser-js';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n'; import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
import { import {
RECIPIENT_ROLES_DESCRIPTION, RECIPIENT_ROLES_DESCRIPTION,
@@ -59,6 +60,8 @@ export async function loader({ request }: Route.LoaderArgs) {
throw redirect('/'); throw redirect('/');
} }
const isPlatformDocument = await isDocumentPlatform(document);
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language); const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
const auditLogs = await getDocumentCertificateAuditLogs({ const auditLogs = await getDocumentCertificateAuditLogs({
@@ -70,6 +73,7 @@ export async function loader({ request }: Route.LoaderArgs) {
return { return {
document, document,
documentLanguage, documentLanguage,
isPlatformDocument,
auditLogs, auditLogs,
messages, messages,
}; };
@@ -85,7 +89,7 @@ export async function loader({ request }: Route.LoaderArgs) {
* Update: Maybe <Trans> tags work now after RR7 migration. * Update: Maybe <Trans> tags work now after RR7 migration.
*/ */
export default function SigningCertificate({ loaderData }: Route.ComponentProps) { export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
const { document, documentLanguage, auditLogs, messages } = loaderData; const { document, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
const { i18n, _ } = useLingui(); const { i18n, _ } = useLingui();
@@ -159,6 +163,13 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED && log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
log.data.recipientId === recipientId, log.data.recipientId === recipientId,
), ),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED]: auditLogs[
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED
].filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED &&
log.data.recipientId === recipientId,
),
}; };
}; };
@@ -282,25 +293,42 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</span> </span>
</p> </p>
<p className="text-muted-foreground text-sm print:text-xs"> {logs.DOCUMENT_RECIPIENT_REJECTED[0] ? (
<span className="font-medium">{_(msg`Signed`)}:</span>{' '} <p className="text-muted-foreground text-sm print:text-xs">
<span className="inline-block"> <span className="font-medium">{_(msg`Rejected`)}:</span>{' '}
{logs.DOCUMENT_RECIPIENT_COMPLETED[0] <span className="inline-block">
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt) {logs.DOCUMENT_RECIPIENT_REJECTED[0]
.setLocale(APP_I18N_OPTIONS.defaultLocale) ? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') .setLocale(APP_I18N_OPTIONS.defaultLocale)
: _(msg`Unknown`)} .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
</span> : _(msg`Unknown`)}
</p> </span>
</p>
) : (
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
? DateTime.fromJSDate(
logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt,
)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
</span>
</p>
)}
<p className="text-muted-foreground text-sm print:text-xs"> <p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Reason`)}:</span>{' '} <span className="font-medium">{_(msg`Reason`)}:</span>{' '}
<span className="inline-block"> <span className="inline-block">
{_( {recipient.signingStatus === SigningStatus.REJECTED
isOwner(recipient.email) ? recipient.rejectionReason
? FRIENDLY_SIGNING_REASONS['__OWNER__'] : _(
: FRIENDLY_SIGNING_REASONS[recipient.role], isOwner(recipient.email)
)} ? FRIENDLY_SIGNING_REASONS['__OWNER__']
: FRIENDLY_SIGNING_REASONS[recipient.role],
)}
</span> </span>
</p> </p>
</div> </div>
@@ -313,15 +341,17 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</CardContent> </CardContent>
</Card> </Card>
<div className="my-8 flex-row-reverse"> {isPlatformDocument && (
<div className="flex items-end justify-end gap-x-4"> <div className="my-8 flex-row-reverse">
<p className="flex-shrink-0 text-sm font-medium print:text-xs"> <div className="flex items-end justify-end gap-x-4">
{_(msg`Signing certificate provided by`)}: <p className="flex-shrink-0 text-sm font-medium print:text-xs">
</p> {_(msg`Signing certificate provided by`)}:
</p>
<BrandingLogo className="max-h-6 print:max-h-4" /> <BrandingLogo className="max-h-6 print:max-h-4" />
</div>
</div> </div>
</div> )}
</div> </div>
); );
} }

View File

@@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { PlusIcon } from 'lucide-react'; import { PlusIcon } from 'lucide-react';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { Link, Outlet } from 'react-router'; import { Link, Outlet, isRouteErrorResponse } from 'react-router';
import LogoIcon from '@documenso/assets/logo_icon.png'; import LogoIcon from '@documenso/assets/logo_icon.png';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
@@ -16,6 +16,8 @@ import { BrandingLogo } from '~/components/general/branding-logo';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/_layout';
export function meta() { export function meta() {
return appMetaTags('Profile'); return appMetaTags('Profile');
} }
@@ -73,7 +75,7 @@ export default function PublicProfileLayout() {
</p> </p>
<Button asChild variant="secondary"> <Button asChild variant="secondary">
<Link to="/signup"> <Link to="https://bls.media/sign/">
<div className="hidden flex-row items-center sm:flex"> <div className="hidden flex-row items-center sm:flex">
<PlusIcon className="mr-1 h-5 w-5" /> <PlusIcon className="mr-1 h-5 w-5" />
<Trans>Create now</Trans> <Trans>Create now</Trans>
@@ -96,7 +98,9 @@ export default function PublicProfileLayout() {
); );
} }
export function ErrorBoundary() { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
const errorCodeMap = { const errorCodeMap = {
404: { 404: {
subHeading: msg`404 Profile not found`, subHeading: msg`404 Profile not found`,
@@ -107,6 +111,7 @@ export function ErrorBoundary() {
return ( return (
<GenericErrorLayout <GenericErrorLayout
errorCode={errorCode}
errorCodeMap={errorCodeMap} errorCodeMap={errorCodeMap}
secondaryButton={null} secondaryButton={null}
primaryButton={ primaryButton={

View File

@@ -1,6 +1,6 @@
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { Link, Outlet } from 'react-router'; import { Link, Outlet, isRouteErrorResponse } from 'react-router';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -8,6 +8,8 @@ import { Button } from '@documenso/ui/primitives/button';
import { Header as AuthenticatedHeader } from '~/components/general/app-header'; import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import type { Route } from './+types/_layout';
/** /**
* A layout to handle scenarios where the user is a recipient of a given resource * A layout to handle scenarios where the user is a recipient of a given resource
* where we do not care whether they are authenticated or not. * where we do not care whether they are authenticated or not.
@@ -30,9 +32,12 @@ export default function RecipientLayout() {
); );
} }
export function ErrorBoundary() { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
return ( return (
<GenericErrorLayout <GenericErrorLayout
errorCode={errorCode}
secondaryButton={null} secondaryButton={null}
primaryButton={ primaryButton={
<Button asChild className="w-32"> <Button asChild className="w-32">

View File

@@ -79,7 +79,14 @@ export default function DirectTemplatePage() {
const { template, directTemplateRecipient } = data; const { template, directTemplateRecipient } = data;
return ( return (
<DocumentSigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}> <DocumentSigningProvider
email={user?.email}
fullName={user?.name}
signature={user?.signature}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
>
<DocumentSigningAuthProvider <DocumentSigningAuthProvider
documentAuthOptions={template.authOptions} documentAuthOptions={template.authOptions}
recipient={directTemplateRecipient} recipient={directTemplateRecipient}

View File

@@ -1,5 +1,5 @@
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { DocumentSigningOrder, DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { Clock8 } from 'lucide-react'; import { Clock8 } from 'lucide-react';
import { Link, redirect } from 'react-router'; import { Link, redirect } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session'; import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
@@ -13,6 +13,7 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token'; import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn'; import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant'; import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
@@ -72,7 +73,24 @@ export async function loader({ params, request }: Route.LoaderArgs) {
? await getRecipientsForAssistant({ ? await getRecipientsForAssistant({
token, token,
}) })
: []; : [recipient];
if (
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
recipient.role !== RecipientRole.ASSISTANT
) {
const nextPendingRecipient = await getNextPendingRecipient({
documentId: document.id,
currentRecipientId: recipient.id,
});
if (nextPendingRecipient) {
allRecipients.push({
...nextPendingRecipient,
fields: [],
});
}
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions, documentAuth: document.authOptions,
@@ -160,7 +178,7 @@ export default function SigningPage() {
recipientWithFields, recipientWithFields,
} = data; } = data;
if (document.deletedAt) { if (document.deletedAt || document.status === DocumentStatus.REJECTED) {
return ( return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24"> <div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
<SigningCard3D <SigningCard3D
@@ -215,6 +233,9 @@ export default function SigningPage() {
email={recipient.email} email={recipient.email}
fullName={user?.email === recipient.email ? user?.name : recipient.name} fullName={user?.email === recipient.email ? user?.name : recipient.name}
signature={user?.email === recipient.email ? user?.signature : undefined} signature={user?.email === recipient.email ? user?.signature : undefined}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
> >
<DocumentSigningAuthProvider <DocumentSigningAuthProvider
documentAuthOptions={document.authOptions} documentAuthOptions={document.authOptions}

View File

@@ -17,6 +17,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import DocumentDialog from '@documenso/ui/components/document/document-dialog'; import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
@@ -203,14 +204,14 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
))} ))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4"> <div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
<DocumentShareButton documentId={document.id} token={recipient.token} /> {/* <DocumentShareButton documentId={document.id} token={recipient.token} /> Share Button ausgeblendet */}
{document.status === DocumentStatus.COMPLETED ? ( {isDocumentCompleted(document.status) ? (
<DocumentDownloadButton <DocumentDownloadButton
className="flex-1" className="flex-1"
fileName={document.title} fileName={document.title}
documentData={document.documentData} documentData={document.documentData}
disabled={document.status !== DocumentStatus.COMPLETED} disabled={!isDocumentCompleted(document.status)}
/> />
) : ( ) : (
<DocumentDialog <DocumentDialog
@@ -268,7 +269,7 @@ export const PollUntilDocumentCompleted = ({ document }: PollUntilDocumentComple
const { revalidate } = useRevalidator(); const { revalidate } = useRevalidator();
useEffect(() => { useEffect(() => {
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return; return;
} }

View File

@@ -115,7 +115,7 @@ export default function RejectedSigningPage({ loaderData }: Route.ComponentProps
{user && ( {user && (
<Button className="mt-6" asChild> <Button className="mt-6" asChild>
<Link to={`/`}>Return Home</Link> <Link to={`/`}>Zurück</Link>
</Button> </Button>
)} )}
</div> </div>

View File

@@ -95,7 +95,7 @@ export default function WaitingForTurnToSignPage({ loaderData }: Route.Component
</Button> </Button>
) : ( ) : (
<Button variant="link" asChild> <Button variant="link" asChild>
<Link to="/documents">Return Home</Link> <Link to="/documents">Zurück</Link>
</Button> </Button>
)} )}
</div> </div>

View File

@@ -19,18 +19,30 @@ const posthogProxy = async (request: Request) => {
const headers = new Headers(request.headers); const headers = new Headers(request.headers);
headers.set('host', hostname); headers.set('host', hostname);
const response = await fetch(newUrl, { const fetchOptions: RequestInit = {
method: request.method, method: request.method,
headers, headers,
body: request.body, redirect: 'follow',
// @ts-expect-error - Not really sure about this };
duplex: 'half',
}); if (!['GET', 'HEAD'].includes(request.method)) {
fetchOptions.body = request.body;
// @ts-expect-error - It should exist
fetchOptions.duplex = 'half';
}
const response = await fetch(newUrl, fetchOptions);
const responseHeaders = new Headers(response.headers);
responseHeaders.delete('content-encoding');
responseHeaders.delete('content-length');
responseHeaders.delete('transfer-encoding');
responseHeaders.delete('cookie');
return new Response(response.body, { return new Response(response.body, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
headers: response.headers, headers: responseHeaders,
}); });
}; };

View File

@@ -3,7 +3,7 @@ import { Link } from 'react-router';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
const SUPPORT_EMAIL = 'support@documenso.com'; const SUPPORT_EMAIL = 'hello@bls-media.de';
export default function SignatureDisclosure() { export default function SignatureDisclosure() {
return ( return (

View File

@@ -6,15 +6,15 @@ import type { Route } from './+types/share.$slug';
export function meta({ params: { slug } }: Route.MetaArgs) { export function meta({ params: { slug } }: Route.MetaArgs) {
return [ return [
{ title: 'Documenso - Share' }, { title: 'BLS sign - Share' },
{ description: 'I just signed a document in style with Documenso!' }, { description: 'I just signed a document in style with BLS sign!' },
{ {
property: 'og:title', property: 'og:title',
content: 'Documenso - Join the open source signing revolution', content: 'BLS sign - Join the open source signing revolution',
}, },
{ {
property: 'og:description', property: 'og:description',
content: 'I just signed with Documenso!', content: 'I just signed with BLS sign!',
}, },
{ {
property: 'og:type', property: 'og:type',
@@ -38,7 +38,7 @@ export function meta({ params: { slug } }: Route.MetaArgs) {
}, },
{ {
name: 'twitter:description', name: 'twitter:description',
content: 'I just signed with Documenso!', content: 'I just signed with BLS sign!',
}, },
]; ];
} }
@@ -50,8 +50,8 @@ export const loader = ({ request }: Route.LoaderArgs) => {
return null; return null;
} }
// Is hardcoded because this whole meta is hardcoded anyway for Documenso. // Is hardcoded because this whole meta is hardcoded anyway for BLS sign.
throw redirect('https://documenso.com'); throw redirect('https://bls.media/');
}; };
export default function SharePage() { export default function SharePage() {

View File

@@ -131,7 +131,14 @@ export default function EmbedDirectTemplatePage() {
} = useSuperLoaderData<typeof loader>(); } = useSuperLoaderData<typeof loader>();
return ( return (
<DocumentSigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}> <DocumentSigningProvider
email={user?.email}
fullName={user?.name}
signature={user?.signature}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
>
<DocumentSigningAuthProvider <DocumentSigningAuthProvider
documentAuthOptions={template.authOptions} documentAuthOptions={template.authOptions}
recipient={recipient} recipient={recipient}
@@ -145,7 +152,9 @@ export default function EmbedDirectTemplatePage() {
recipient={recipient} recipient={recipient}
fields={fields} fields={fields}
metadata={template.templateMeta} metadata={template.templateMeta}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy} hidePoweredBy={
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
}
allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument} allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
/> />
</DocumentSigningRecipientProvider> </DocumentSigningRecipientProvider>

View File

@@ -1,4 +1,4 @@
import { DocumentStatus, RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { data } from 'react-router'; import { data } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@@ -14,6 +14,7 @@ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-re
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant'; import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page'; import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page';
@@ -155,6 +156,9 @@ export default function EmbedSignDocumentPage() {
email={recipient.email} email={recipient.email}
fullName={user?.email === recipient.email ? user?.name : recipient.name} fullName={user?.email === recipient.email ? user?.name : recipient.name}
signature={user?.email === recipient.email ? user?.signature : undefined} signature={user?.email === recipient.email ? user?.signature : undefined}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
> >
<DocumentSigningAuthProvider <DocumentSigningAuthProvider
documentAuthOptions={document.authOptions} documentAuthOptions={document.authOptions}
@@ -168,8 +172,10 @@ export default function EmbedSignDocumentPage() {
recipient={recipient} recipient={recipient}
fields={fields} fields={fields}
metadata={document.documentMeta} metadata={document.documentMeta}
isCompleted={document.status === DocumentStatus.COMPLETED} isCompleted={isDocumentCompleted(document.status)}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy} hidePoweredBy={
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
}
allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument} allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
allRecipients={allRecipients} allRecipients={allRecipients}
/> />

View File

@@ -12,6 +12,7 @@ const themeSessionStorage = createCookieSessionStorage({
secrets: ['insecure-secret-do-not-care'], secrets: ['insecure-secret-do-not-care'],
secure: useSecureCookies, secure: useSecureCookies,
domain: getCookieDomain(), domain: getCookieDomain(),
maxAge: 60 * 60 * 24 * 365,
}, },
}); });

View File

@@ -0,0 +1 @@
export default {};

View File

@@ -2,11 +2,11 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
export const appMetaTags = (title?: string) => { export const appMetaTags = (title?: string) => {
const description = const description =
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.'; 'Join BLS sign, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.';
return [ return [
{ {
title: title ? `${title} - Documenso` : 'Documenso', title: title ? `${title} - BLS sign` : 'BLS sign',
}, },
{ {
name: 'description', name: 'description',
@@ -15,7 +15,7 @@ export const appMetaTags = (title?: string) => {
{ {
name: 'keywords', name: 'keywords',
content: content:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates', 'BLS sign, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
}, },
{ {
name: 'author', name: 'author',
@@ -27,7 +27,7 @@ export const appMetaTags = (title?: string) => {
}, },
{ {
property: 'og:title', property: 'og:title',
content: 'Documenso - The Open Source DocuSign Alternative', content: 'BLS sign',
}, },
{ {
property: 'og:description', property: 'og:description',

View File

@@ -49,8 +49,8 @@
"luxon": "^3.4.0", "luxon": "^3.4.0",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"plausible-tracker": "^0.3.9", "plausible-tracker": "^0.3.9",
"posthog-js": "^1.223.3", "posthog-js": "^1.224.0",
"posthog-node": "^4.7.0", "posthog-node": "^4.8.1",
"react": "^18", "react": "^18",
"react-call": "^1.3.0", "react-call": "^1.3.0",
"react-dom": "^18", "react-dom": "^18",
@@ -76,7 +76,7 @@
"@babel/preset-typescript": "^7.26.0", "@babel/preset-typescript": "^7.26.0",
"@lingui/babel-plugin-lingui-macro": "^5.2.0", "@lingui/babel-plugin-lingui-macro": "^5.2.0",
"@lingui/vite-plugin": "^5.2.0", "@lingui/vite-plugin": "^5.2.0",
"@react-router/dev": "^7.1.1", "@react-router/dev": "^7.1.5",
"@react-router/remix-routes-option-adapter": "^7.1.5", "@react-router/remix-routes-option-adapter": "^7.1.5",
"@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^28.0.2", "@rollup/plugin-commonjs": "^28.0.2",
@@ -99,5 +99,6 @@
"vite": "^6.1.0", "vite": "^6.1.0",
"vite-plugin-babel-macros": "^1.0.6", "vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
} },
"version": "1.10.0-rc.1"
} }

View File

@@ -2,6 +2,6 @@
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
# Report critical issues privately to let us take appropriate action before publishing. # Report critical issues privately to let us take appropriate action before publishing.
Contact: mailto:security@documenso.com Contact: mailto:security@sign.bls.media
Preferred-Languages: en Preferred-Languages: en
Canonical: https://documenso.com/.well-known/security.txt Canonical: https://documenso.com/.well-known/security.txt

View File

@@ -1,6 +1,6 @@
{ {
"name": "Documenso", "name": "BLS sign",
"short_name": "Documenso", "short_name": "BLS sign",
"icons": [ "icons": [
{ {
"src": "/android-chrome-192x192.png", "src": "/android-chrome-192x192.png",

View File

@@ -0,0 +1,75 @@
import type { Context, Next } from 'hono';
import { deleteCookie, setCookie } from 'hono/cookie';
import { AppDebugger } from '@documenso/lib/utils/debugger';
const debug = new AppDebugger('Middleware');
/**
* Middleware for initial page loads.
*
* You won't be able to easily handle sequential page loads because they will be
* called under `path.data`
*
* Example an initial page load would be `/documents` then if the user click templates
* the path here would be `/templates.data`.
*/
export const appMiddleware = async (c: Context, next: Next) => {
const { req } = c;
const { path } = req;
// Paths to ignore.
if (nonPagePathRegex.test(path)) {
return next();
}
// PRE-HANDLER CODE: Place code here to execute BEFORE the route handler runs.
await next();
// POST-HANDLER CODE: Place code here to execute AFTER the route handler completes.
// This is useful for:
// - Setting cookies
// - Any operations that should happen after all route handlers but before sending the response
debug.log('Path', path);
const pathname = path.replace('.data', '');
const referrer = c.req.header('referer');
const referrerUrl = referrer ? new URL(referrer) : null;
const referrerPathname = referrerUrl ? referrerUrl.pathname : null;
// Whether to reset the preferred team url cookie if the user accesses a non team page from a team page.
const resetPreferredTeamUrl =
referrerPathname &&
referrerPathname.startsWith('/t/') &&
(!pathname.startsWith('/t/') || pathname === '/');
// Set the preferred team url cookie if user accesses a team page.
if (pathname.startsWith('/t/')) {
debug.log('Setting preferred team url cookie');
setCookie(c, 'preferred-team-url', pathname.split('/')[2], {
sameSite: 'lax',
});
return;
}
// Clear preferred team url cookie if user accesses a non team page from a team page.
if (resetPreferredTeamUrl || pathname === '/documents') {
debug.log('Deleting preferred team url cookie');
deleteCookie(c, 'preferred-team-url');
return;
}
};
// This regex matches any path that:
// 1. Starts with /api/, /ingest/, /__manifest/, or /assets/
// 2. Starts with /apple- (like /apple-touch-icon.png)
// 3. Starts with /favicon (like /favicon.ico)
// The ^ ensures matching from the beginning of the string
// The | acts as OR operator between different patterns
const nonPagePathRegex = /^(\/api\/|\/ingest\/|\/__manifest|\/assets\/|\/apple-.*|\/favicon.*)/;

View File

@@ -9,6 +9,7 @@ import { openApiDocument } from '@documenso/trpc/server/open-api';
import { filesRoute } from './api/files'; import { filesRoute } from './api/files';
import { type AppContext, appContext } from './context'; import { type AppContext, appContext } from './context';
import { appMiddleware } from './middleware';
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api'; import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
import { reactRouterTrpcServer } from './trpc/hono-trpc-remix'; import { reactRouterTrpcServer } from './trpc/hono-trpc-remix';
@@ -26,6 +27,11 @@ const app = new Hono<HonoEnv>();
app.use(contextStorage()); app.use(contextStorage());
app.use(appContext); app.use(appContext);
/**
* RR7 app middleware.
*/
app.use('*', appMiddleware);
// Auth server. // Auth server.
app.route('/api/auth', auth); app.route('/api/auth', auth);

View File

@@ -46,6 +46,7 @@ export default defineConfig({
https: 'node:https', https: 'node:https',
'.prisma/client/default': '../../node_modules/.prisma/client/default.js', '.prisma/client/default': '../../node_modules/.prisma/client/default.js',
'.prisma/client/index-browser': '../../node_modules/.prisma/client/index-browser.js', '.prisma/client/index-browser': '../../node_modules/.prisma/client/index-browser.js',
canvas: './app/types/empty-module.ts',
}, },
}, },
/** /**

View File

@@ -8,6 +8,7 @@ command -v docker >/dev/null 2>&1 || {
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")" SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")" MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
# Get Git information
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)" GIT_SHA="$(git rev-parse HEAD)"
@@ -15,12 +16,39 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION" echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA" echo "Git SHA: $GIT_SHA"
# Build with temporary base tag
docker build -f "$SCRIPT_DIR/Dockerfile" \ docker build -f "$SCRIPT_DIR/Dockerfile" \
--progress=plain \ --progress=plain \
-t "documenso/documenso:latest" \ -t "documenso-base" \
-t "documenso/documenso:$GIT_SHA" \
-t "documenso/documenso:$APP_VERSION" \
-t "ghcr.io/documenso/documenso:latest" \
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
"$MONOREPO_ROOT" "$MONOREPO_ROOT"
# Handle repository tagging
if [ ! -z "$DOCKER_REPOSITORY" ]; then
echo "Using custom repository: $DOCKER_REPOSITORY"
# Add tags for custom repository
docker tag "documenso-base" "$DOCKER_REPOSITORY:latest"
docker tag "documenso-base" "$DOCKER_REPOSITORY:$GIT_SHA"
# Add version tag if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "$DOCKER_REPOSITORY:$APP_VERSION"
fi
else
echo "Using default repositories: dockerhub and ghcr.io"
# Add tags for both default repositories
docker tag "documenso-base" "documenso/documenso:latest"
docker tag "documenso-base" "documenso/documenso:$GIT_SHA"
docker tag "documenso-base" "ghcr.io/documenso/documenso:latest"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$GIT_SHA"
# Add version tags if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "documenso/documenso:$APP_VERSION"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$APP_VERSION"
fi
fi
# Remove the temporary base tag
docker rmi "documenso-base"

View File

@@ -9,11 +9,11 @@ SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")" MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
# Get the platform from environment variable or set to linux/amd64 if not set # Get the platform from environment variable or set to linux/amd64 if not set
# quote the string to prevent word splitting
if [ -z "$PLATFORM" ]; then if [ -z "$PLATFORM" ]; then
PLATFORM="linux/amd64" PLATFORM="linux/amd64"
fi fi
# Get Git information
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)" GIT_SHA="$(git rev-parse HEAD)"
@@ -21,14 +21,41 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION" echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA" echo "Git SHA: $GIT_SHA"
# Build with temporary base tag
docker buildx build \ docker buildx build \
-f "$SCRIPT_DIR/Dockerfile" \ -f "$SCRIPT_DIR/Dockerfile" \
--platform=$PLATFORM \ --platform=$PLATFORM \
--progress=plain \ --progress=plain \
-t "documenso/documenso:latest" \ -t "documenso-base" \
-t "documenso/documenso:$GIT_SHA" \
-t "documenso/documenso:$APP_VERSION" \
-t "ghcr.io/documenso/documenso:latest" \
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
"$MONOREPO_ROOT" "$MONOREPO_ROOT"
# Handle repository tagging
if [ ! -z "$DOCKER_REPOSITORY" ]; then
echo "Using custom repository: $DOCKER_REPOSITORY"
# Add tags for custom repository
docker tag "documenso-base" "$DOCKER_REPOSITORY:latest"
docker tag "documenso-base" "$DOCKER_REPOSITORY:$GIT_SHA"
# Add version tag if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "$DOCKER_REPOSITORY:$APP_VERSION"
fi
else
echo "Using default repositories: dockerhub and ghcr.io"
# Add tags for both default repositories
docker tag "documenso-base" "documenso/documenso:latest"
docker tag "documenso-base" "documenso/documenso:$GIT_SHA"
docker tag "documenso-base" "ghcr.io/documenso/documenso:latest"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$GIT_SHA"
# Add version tags if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "documenso/documenso:$APP_VERSION"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$APP_VERSION"
fi
fi
# Remove the temporary base tag
docker rmi "documenso-base"

View File

@@ -1,4 +1,6 @@
import { defineConfig } from '@lingui/cli';
import type { LinguiConfig } from '@lingui/conf'; import type { LinguiConfig } from '@lingui/conf';
import { formatter } from '@lingui/format-po';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
@@ -14,6 +16,7 @@ const config: LinguiConfig = {
}, },
], ],
compileNamespace: 'es', compileNamespace: 'es',
format: formatter({ lineNumbers: false }),
}; };
export default config; export default config;

24
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.9.0-rc.11", "version": "1.10.0-rc.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.9.0-rc.11", "version": "1.10.0-rc.1",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@@ -95,6 +95,7 @@
}, },
"apps/remix": { "apps/remix": {
"name": "@documenso/remix", "name": "@documenso/remix",
"version": "1.10.0-rc.1",
"dependencies": { "dependencies": {
"@documenso/api": "*", "@documenso/api": "*",
"@documenso/assets": "*", "@documenso/assets": "*",
@@ -131,8 +132,8 @@
"luxon": "^3.4.0", "luxon": "^3.4.0",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"plausible-tracker": "^0.3.9", "plausible-tracker": "^0.3.9",
"posthog-js": "^1.223.3", "posthog-js": "^1.224.0",
"posthog-node": "^4.7.0", "posthog-node": "^4.8.1",
"react": "^18", "react": "^18",
"react-call": "^1.3.0", "react-call": "^1.3.0",
"react-dom": "^18", "react-dom": "^18",
@@ -158,7 +159,7 @@
"@babel/preset-typescript": "^7.26.0", "@babel/preset-typescript": "^7.26.0",
"@lingui/babel-plugin-lingui-macro": "^5.2.0", "@lingui/babel-plugin-lingui-macro": "^5.2.0",
"@lingui/vite-plugin": "^5.2.0", "@lingui/vite-plugin": "^5.2.0",
"@react-router/dev": "^7.1.1", "@react-router/dev": "^7.1.5",
"@react-router/remix-routes-option-adapter": "^7.1.5", "@react-router/remix-routes-option-adapter": "^7.1.5",
"@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^28.0.2", "@rollup/plugin-commonjs": "^28.0.2",
@@ -902,9 +903,9 @@
} }
}, },
"apps/remix/node_modules/posthog-node": { "apps/remix/node_modules/posthog-node": {
"version": "4.7.0", "version": "4.8.1",
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.7.0.tgz", "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.8.1.tgz",
"integrity": "sha512-RgdUKSW8MfMOkjUa8cYVqWndNjPePNuuxlGbrZC6z1WRBsVc6TdGl8caidmC10RW8mu/BOfmrGbP4cRTo2jARg==", "integrity": "sha512-ApMEC1+DbctP/88+VhaCl8SRKpIoReibMf7Mb3rxw3yMthr1rKaM4opbHdZJ0buLhwS5zX8B2ckqLjpwpSjRPg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.7.4" "axios": "^1.7.4"
@@ -31060,9 +31061,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/posthog-js": { "node_modules/posthog-js": {
"version": "1.223.3", "version": "1.224.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.223.3.tgz", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.224.0.tgz",
"integrity": "sha512-ZQTc17M21IzkQmECJa2Xjont4tZrvIn252uGT3sTfmahTqZoW4j+kBj4eOJt9SNR6hOheFNkg7MSiI/rA6FaDA==", "integrity": "sha512-JT1XQQeYs0CKb4lU2vujmeLTDLWc61I5lT7d6oG/H/cnCpXAqBi5rMuCFFeotHeMy3hqJ/Tpu3eAPFE2p5ErHA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"core-js": "^3.38.1", "core-js": "^3.38.1",
@@ -41567,6 +41568,7 @@
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pg": "^8.11.3", "pg": "^8.11.3",
"playwright": "1.43.0", "playwright": "1.43.0",
"posthog-js": "^1.224.0",
"react": "^18", "react": "^18",
"remeda": "^2.17.3", "remeda": "^2.17.3",
"sharp": "0.32.6", "sharp": "0.32.6",

View File

@@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"version": "1.9.0-rc.11", "version": "1.10.0-rc.1",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix", "dev": "turbo run dev --filter=@documenso/remix",
@@ -14,7 +14,7 @@
"prepare": "husky && husky install || true", "prepare": "husky && husky install || true",
"commitlint": "commitlint --edit", "commitlint": "commitlint --edit",
"clean": "turbo run clean && rimraf node_modules", "clean": "turbo run clean && rimraf node_modules",
"d": "npm run dx && npm run dev", "d": "npm run dx && npm run translate:compile && npm run dev",
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed", "dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed",
"dx:up": "docker compose -f docker/development/compose.yml up -d", "dx:up": "docker compose -f docker/development/compose.yml up -d",
"dx:down": "docker compose -f docker/development/compose.yml down", "dx:down": "docker compose -f docker/development/compose.yml down",

View File

@@ -1,4 +1,4 @@
import { fetchRequestHandler } from '@ts-rest/serverless/fetch'; import { TsRestHttpError, fetchRequestHandler } from '@ts-rest/serverless/fetch';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { ApiContractV1 } from '@documenso/api/v1/contract'; import { ApiContractV1 } from '@documenso/api/v1/contract';
@@ -29,6 +29,12 @@ tsRestHonoApp.mount('/', async (request) => {
request, request,
contract: ApiContractV1, contract: ApiContractV1,
router: ApiContractV1Implementation, router: ApiContractV1Implementation,
options: {}, options: {
errorHandler: (err) => {
if (err instanceof TsRestHttpError && err.statusCode === 500) {
console.error(err);
}
},
},
}); });
}); });

View File

@@ -1,3 +1,5 @@
import type { Prisma } from '@prisma/client';
import { DocumentDataType, SigningStatus, TeamMemberRole } from '@prisma/client';
import { tsr } from '@ts-rest/serverless/fetch'; import { tsr } from '@ts-rest/serverless/fetch';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@@ -48,15 +50,9 @@ import {
getPresignGetUrl, getPresignGetUrl,
getPresignPostUrl, getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions'; } from '@documenso/lib/universal/upload/server-actions';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import {
DocumentDataType,
DocumentStatus,
SigningStatus,
TeamMemberRole,
} from '@documenso/prisma/client';
import { ApiContractV1 } from './contract'; import { ApiContractV1 } from './contract';
import { authenticatedMiddleware } from './middleware/authenticated'; import { authenticatedMiddleware } from './middleware/authenticated';
@@ -181,7 +177,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status !== DocumentStatus.COMPLETED) { if (!isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { body: {
@@ -327,8 +323,11 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
dateFormat: dateFormat?.value, dateFormat: dateFormat?.value,
redirectUrl: body.meta.redirectUrl, redirectUrl: body.meta.redirectUrl,
signingOrder: body.meta.signingOrder, signingOrder: body.meta.signingOrder,
allowDictateNextSigner: body.meta.allowDictateNextSigner,
language: body.meta.language, language: body.meta.language,
typedSignatureEnabled: body.meta.typedSignatureEnabled, typedSignatureEnabled: body.meta.typedSignatureEnabled,
uploadSignatureEnabled: body.meta.uploadSignatureEnabled,
drawSignatureEnabled: body.meta.drawSignatureEnabled,
distributionMethod: body.meta.distributionMethod, distributionMethod: body.meta.distributionMethod,
emailSettings: body.meta.emailSettings, emailSettings: body.meta.emailSettings,
requestMetadata: metadata, requestMetadata: metadata,
@@ -585,6 +584,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
userId: user.id, userId: user.id,
teamId: team?.id, teamId: team?.id,
recipients: body.recipients, recipients: body.recipients,
prefillFields: body.prefillFields,
override: { override: {
title: body.title, title: body.title,
...body.meta, ...body.meta,
@@ -673,7 +673,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { body: {
@@ -776,7 +776,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { body: {
@@ -867,7 +867,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { body: {
@@ -926,7 +926,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { body: {
@@ -991,7 +991,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { message: 'Document is already completed' }, body: { message: 'Document is already completed' },
@@ -1153,7 +1153,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { body: {
@@ -1241,7 +1241,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { body: {

View File

@@ -1,10 +1,10 @@
import type { Team, User } from '@prisma/client';
import type { TsRestRequest } from '@ts-rest/serverless'; import type { TsRestRequest } from '@ts-rest/serverless';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token'; import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { Team, User } from '@documenso/prisma/client';
type B = { type B = {
// appRoute: any; // appRoute: any;

View File

@@ -9,9 +9,9 @@ export const OpenAPIV1 = Object.assign(
ApiContractV1, ApiContractV1,
{ {
info: { info: {
title: 'Documenso API', title: 'BLS sign API',
version: '1.0.0', version: '1.0.0',
description: 'The Documenso API for retrieving, creating, updating and deleting documents.', description: 'The BLS sign API for retrieving, creating, updating and deleting documents.',
}, },
servers: [ servers: [
{ {

View File

@@ -1,4 +1,16 @@
import { extendZodWithOpenApi } from '@anatine/zod-openapi'; import { extendZodWithOpenApi } from '@anatine/zod-openapi';
import {
DocumentDataType,
DocumentDistributionMethod,
DocumentSigningOrder,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
TeamMemberRole,
TemplateType,
} from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
@@ -11,19 +23,7 @@ import {
ZRecipientActionAuthTypesSchema, ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import {
DocumentDataType,
DocumentDistributionMethod,
DocumentSigningOrder,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
TeamMemberRole,
TemplateType,
} from '@documenso/prisma/client';
extendZodWithOpenApi(z); extendZodWithOpenApi(z);
@@ -96,7 +96,7 @@ export const ZSendDocumentForSigningMutationSchema = z
'Whether to send completion emails when the document is fully signed. This will override the document email settings.', 'Whether to send completion emails when the document is fully signed. This will override the document email settings.',
}), }),
}) })
.or(z.literal('').transform(() => ({ sendEmail: true, sendCompletionEmails: undefined }))); .or(z.any().transform(() => ({ sendEmail: true, sendCompletionEmails: undefined })));
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema; export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
@@ -155,8 +155,11 @@ export const ZCreateDocumentMutationSchema = z.object({
}), }),
redirectUrl: z.string(), redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(), language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
typedSignatureEnabled: z.boolean().optional().default(true), typedSignatureEnabled: z.boolean().optional().default(true),
uploadSignatureEnabled: z.boolean().optional().default(true),
drawSignatureEnabled: z.boolean().optional().default(true),
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(), distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(), emailSettings: ZDocumentEmailSettingsSchema.optional(),
}) })
@@ -218,6 +221,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
dateFormat: z.string(), dateFormat: z.string(),
redirectUrl: z.string(), redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(), language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
}) })
.partial() .partial()
@@ -285,9 +289,12 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
dateFormat: z.string(), dateFormat: z.string(),
redirectUrl: ZUrlSchema, redirectUrl: ZUrlSchema,
signingOrder: z.nativeEnum(DocumentSigningOrder), signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean(),
language: z.enum(SUPPORTED_LANGUAGE_CODES), language: z.enum(SUPPORTED_LANGUAGE_CODES),
distributionMethod: z.nativeEnum(DocumentDistributionMethod), distributionMethod: z.nativeEnum(DocumentDistributionMethod),
typedSignatureEnabled: z.boolean(), typedSignatureEnabled: z.boolean(),
uploadSignatureEnabled: z.boolean(),
drawSignatureEnabled: z.boolean(),
emailSettings: ZDocumentEmailSettingsSchema, emailSettings: ZDocumentEmailSettingsSchema,
}) })
.partial() .partial()
@@ -299,6 +306,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
}) })
.optional(), .optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).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< export type TGenerateDocumentFromTemplateMutationSchema = z.infer<

View File

@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
import { import {
ZFindTeamMembersResponseSchema, ZFindTeamMembersResponseSchema,
@@ -10,7 +11,6 @@ import {
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';

View File

@@ -0,0 +1,614 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import 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';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
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

@@ -0,0 +1,602 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import 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';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
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

@@ -16,7 +16,7 @@ test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page })
const document = await seedPendingDocument(user, [ const document = await seedPendingDocument(user, [
recipientWithAccount, recipientWithAccount,
'recipientwithoutaccount@documenso.com', 'recipientwithoutaccount@sign.bls.media',
]); ]);
const recipients = await prisma.recipient.findMany({ const recipients = await prisma.recipient.findMany({
@@ -40,7 +40,7 @@ test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page
const document = await seedPendingDocument( const document = await seedPendingDocument(
user, user,
[recipientWithAccount, 'recipientwithoutaccount@documenso.com'], [recipientWithAccount, 'recipientwithoutaccount@sign.bls.media'],
{ {
createDocumentOptions: { createDocumentOptions: {
authOptions: createDocumentAuthOptions({ authOptions: createDocumentAuthOptions({

View File

@@ -1,11 +1,11 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { import {
createDocumentAuthOptions, createDocumentAuthOptions,
createRecipientAuthOptions, createRecipientAuthOptions,
} from '@documenso/lib/utils/document-auth'; } from '@documenso/lib/utils/document-auth';
import { FieldType } from '@documenso/prisma/client';
import { import {
seedPendingDocumentNoFields, seedPendingDocumentNoFields,
seedPendingDocumentWithFullFields, seedPendingDocumentWithFullFields,
@@ -210,7 +210,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
}), }),
}, },
], ],
fields: [FieldType.DATE], fields: [FieldType.DATE, FieldType.SIGNATURE],
}); });
for (const recipient of recipients) { for (const recipient of recipients) {
@@ -246,7 +246,9 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
}); });
} }
await signSignaturePad(page); if (fields.some((field) => field.type === FieldType.SIGNATURE)) {
await signSignaturePad(page);
}
for (const field of fields) { for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click(); await page.locator(`#field-${field.id}`).getByRole('button').click();
@@ -307,7 +309,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
}), }),
}, },
], ],
fields: [FieldType.DATE], fields: [FieldType.DATE, FieldType.SIGNATURE],
updateDocumentOptions: { updateDocumentOptions: {
authOptions: createDocumentAuthOptions({ authOptions: createDocumentAuthOptions({
globalAccessAuth: null, globalAccessAuth: null,
@@ -349,7 +351,9 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
}); });
} }
await signSignaturePad(page); if (fields.some((field) => field.type === FieldType.SIGNATURE)) {
await signSignaturePad(page);
}
for (const field of fields) { for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click(); await page.locator(`#field-${field.id}`).getByRole('button').click();

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