Compare commits

..

99 Commits

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

**Tabbed style signature dialog**

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

**Document settings**

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

**Team preferences**

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

## Changes Made

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

## Testing Performed

Added E2E tests to check settings are applied correctly for documents
and templates
2025-03-24 15:25:29 +11:00
Catalin Pit 1b5d24e308 chore: add terms and privacy policy link (#1707) 2025-03-19 10:05:44 +02:00
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:
https://github.com/documenso/embeds/blob/280251cfddb56a6aeeb0d767098915ae7058c376/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
Mythie bc7907271b fix: unbreak build for docker 2025-02-25 21:46:51 +11:00
David Nguyen 9b376d34cf feat: add stripe dev cli 2025-02-25 21:22:28 +11:00
Mythie deea99d865 feat: search by externalId 2025-02-25 20:07:47 +11:00
Mythie 3328074f51 fix: early adopters can use platform features 2025-02-25 20:07:40 +11:00
David Nguyen 5e69665e00 fix: rr7 github build 2025-02-25 16:52:10 +11:00
David Nguyen c1c7cfaf8b chore: cleanup 2025-02-25 16:37:36 +11:00
David Nguyen 7e8955b89c fix: add posthog error monitor 2025-02-25 15:14:45 +11:00
David Nguyen cedd5e87b1 chore: update API documentation 2025-02-25 02:36:08 +11:00
David Nguyen 5255e8671f chore: refactor pdf worker loader 2025-02-24 21:47:06 +11:00
David Nguyen d4c1bad407 fix: add default oauth user url 2025-02-23 18:49:22 +11:00
David Nguyen 01dccb7916 chore: flattern routes 2025-02-21 15:53:23 +11:00
Lucas Smith 483d7caef7 feat: allow document rejection in embeds (#1662) 2025-02-21 01:27:03 +11:00
David Nguyen 139bc265c7 fix: migrate billing to RR7 2025-02-21 01:16:23 +11:00
David Nguyen 991ce5ff46 fix: update teams API tokens logic 2025-02-21 00:34:50 +11:00
David Nguyen 7728c8641c fix: share opengraph 2025-02-20 15:38:06 +11:00
David Nguyen 50a41d0799 fix: pdf viewer and embeds 2025-02-20 15:06:36 +11:00
David Nguyen 250381fec8 fix: billing 2025-02-20 12:17:55 +11:00
David Nguyen d2f3d24542 chore: update docs 2025-02-19 22:36:17 +11:00
David Nguyen ec07092bf6 fix: session refresh 2025-02-19 22:29:30 +11:00
David Nguyen 63e2ef0abf fix: static caching 2025-02-19 21:35:35 +11:00
David Nguyen 90ce52164c chore: add password tests 2025-02-19 18:41:53 +11:00
David Nguyen ac30654913 fix: add auth session lifetime 2025-02-19 18:04:36 +11:00
David Nguyen 24f3ecd94f fix: remove marketing url 2025-02-19 16:45:54 +11:00
David Nguyen a319ea0f5e fix: add public profiles tests 2025-02-19 16:07:04 +11:00
David Nguyen 5ce2bae39d fix: resolve internal pdf translations 2025-02-19 14:43:35 +11:00
Ephraim Atta-Duncan 7f9f7c4092 feat: use multiselect for webhook triggers 2025-02-18 18:02:31 +00:00
David Nguyen 5d86e84217 fix: prepare auth migration (#1648)
Add schema session migration in preparation for auth migration.
2025-02-18 15:19:42 +11:00
David Nguyen 79e26a9a46 fix: remove session migration 2025-02-18 15:19:39 +11:00
David Nguyen dd602a7e1c fix: themes 2025-02-18 15:17:13 +11:00
Ephraim Duncan fb16214dc5 chore: add asssitant role to the docs (#1638) 2025-02-17 23:28:00 +11:00
David Nguyen 5fc724b247 fix: rework sessions 2025-02-17 22:46:36 +11:00
David Nguyen 1ed1cb0773 chore: refactor sessions 2025-02-16 00:44:01 +11:00
David Nguyen 8d5fafec27 feat: add static cache 2025-02-15 00:08:36 +11:00
David Nguyen 0f6f236e0c fix: firefox fouc 2025-02-14 23:02:45 +11:00
David Nguyen e518985833 fix: migrate 2fa to custom auth 2025-02-14 22:00:55 +11:00
David Nguyen 595e901bc2 fix: make auth migration more flexible 2025-02-14 19:22:11 +11:00
David Nguyen df8ea09021 fix: add oidc env variables 2025-02-14 18:11:54 +11:00
David Nguyen 180656978b feat: add themes 2025-02-14 17:50:23 +11:00
David Nguyen 28f5177064 fix: dialogs with search params 2025-02-14 16:14:02 +11:00
David Nguyen 31de86e425 feat: add oidc 2025-02-14 16:01:16 +11:00
Mythie 113ab293bb chore: make all the docker stuff work 2025-02-14 14:53:01 +11:00
David Nguyen 1c4878e526 fix: documentation build 2025-02-13 21:21:51 +11:00
David Nguyen 92db4d68db fix: cleanup env variables 2025-02-13 20:56:44 +11:00
David Nguyen 7379391f92 fix: migrate translations 2025-02-13 20:24:27 +11:00
David Nguyen ebc2b00067 fix: add sign up hook 2025-02-13 20:21:23 +11:00
Ephraim Duncan 87dcdd44cd chore: update local seed data (#1622)
Add multiple example documents, pending documents, and templates for
both admin and example users
2025-02-13 19:50:05 +11:00
Catalin Pit b205f7e5f3 fix: typed signature not working (#1635)
The `typedSignatureEnabled` prop was removed from the `SignatureField`
component, which broke the typed signature meaning that nobody could
sign documents by typing their signature.
2025-02-13 19:47:38 +11:00
Mythie c5d5355cf7 fix: assistant mode breaks for number fields 2025-02-13 19:46:14 +11:00
Mythie 5fac29a07f fix: add css targets for embeds 2025-02-13 19:45:54 +11:00
Catalin Pit 1aaacab6ca fix: temp field label/text truncation (#1565)
TEMP: Fix the truncation of the field label/text.
2025-02-13 19:43:35 +11:00
Ephraim Duncan 06076c1809 fix: unable to check on the checkbox field (#1593)
This change prevents race conditions between state updates and API
operations by updating local state immediately before making async
calls.
2025-02-13 19:42:08 +11:00
Ephraim Duncan c0ae68c28b feat: assistant role (#1588)
Introduces the ability for users with the **Assistant** role to prefill
fields on behalf of other signers. Assistants can fill in various field
types such as text, checkboxes, dates, and more, streamlining the
document preparation process before it reaches the final signers.
2025-02-13 19:37:34 +11:00
Timur Ercan 3e106c1a2d chore: api v2 docs (#1620)
chore update docs for api v2 announce
2025-02-13 18:49:37 +11:00
Mythie 741639ee78 fix: improve move to team display logic 2025-02-13 18:49:03 +11:00
David Nguyen 0b3638c42c feat: add Polish and Italian (#1618) 2025-02-13 18:48:37 +11:00
Catalin Pit 0f50110853 fix: create global settings on team creation (#1601)
The global team settings weren't created when creating a new team.

## Changes Made

The global team settings are now created when a new team is created.
2025-02-13 18:47:59 +11:00
Catalin Pit b0f8c83134 chore: add cancelled webhook event (#1608) 2025-02-13 18:47:43 +11:00
Lucas Smith c9e8a32471 feat: bulk send templates via csv (#1578)
Implements a bulk send feature allowing users to upload a CSV file to
create multiple documents from a template. Includes CSV template
generation, background processing, and email notifications.
2025-02-13 18:44:29 +11:00
Lucas Smith 84b193d99c fix: tidy document invite email render logic (#1597)
Updates one of our confusing ternaries to use `ts-pattern` for rendering
the conditional blocks making it easy to follow the logic occurring.
2025-02-13 18:34:38 +11:00
Ephraim Duncan b03c5ab1a7 fix: admin leaderboard query sorting (#1548) 2025-02-13 18:32:38 +11:00
Lucas Smith 9db42accf3 feat: add text align option to fields (#1610)
Adds the ability to align text to the left, center or right for relevant
fields.

Previously text was always centered which can be less desirable.

See attached debug document which has left, center and right text
alignments set for fields.

<img width="614" alt="image"
src="https://github.com/user-attachments/assets/361a030e-813d-458b-9c7a-ff4c9fa5e33c"
/>

N/A

- Added text align option
- Update the insert in pdf method to support different alignments
- Added a debug mode to field insertion

- Ran manual tests using the debug mode
2025-02-13 18:28:33 +11:00
David Nguyen 383b5f78f0 feat: migrate nextjs to rr7 2025-02-13 14:10:38 +11:00
80 changed files with 3023 additions and 1190 deletions
@@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
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 DocumentData, type Field, FieldType } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router';
@@ -25,12 +25,11 @@ import type {
} from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
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 { BrandingLogo } from '~/components/general/branding-logo';
@@ -69,16 +68,8 @@ export const EmbedDirectTemplateClientPage = ({
const [searchParams] = useSearchParams();
const {
fullName,
email,
signature,
signatureValid,
setFullName,
setEmail,
setSignature,
setSignatureValid,
} = useRequiredDocumentSigningContext();
const { fullName, email, signature, setFullName, setEmail, setSignature } =
useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
@@ -194,10 +185,6 @@ export const EmbedDirectTemplateClientPage = ({
const onCompleteClick = async () => {
try {
if (hasSignatureField && !signatureValid) {
return;
}
const valid = validateFieldsInserted(pendingFields);
if (!valid) {
@@ -419,34 +406,16 @@ export const EmbedDirectTemplateClientPage = ({
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
allowTypedSignature={Boolean(
metadata &&
'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled,
)}
/>
</CardContent>
</Card>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
<SignaturePadDialog
className="mt-2"
disabled={isThrottled || isSubmitting}
disableAnimation
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={metadata?.typedSignatureEnabled}
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
drawSignatureEnabled={metadata?.drawSignatureEnabled}
/>
</div>
)}
</div>
@@ -54,6 +54,8 @@ export const EmbedDocumentFields = ({
onSignField={onSignField}
onUnsignField={onUnsignField}
typedSignatureEnabled={metadata?.typedSignatureEnabled}
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
drawSignatureEnabled={metadata?.drawSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => (
@@ -21,13 +21,12 @@ import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
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 { BrandingLogo } from '~/components/general/branding-logo';
@@ -70,15 +69,8 @@ export const EmbedSignDocumentClientPage = ({
const { _ } = useLingui();
const { toast } = useToast();
const {
fullName,
email,
signature,
signatureValid,
setFullName,
setSignature,
setSignatureValid,
} = useRequiredDocumentSigningContext();
const { fullName, email, signature, setFullName, setSignature } =
useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
@@ -129,10 +121,6 @@ export const EmbedSignDocumentClientPage = ({
const onCompleteClick = async () => {
try {
if (hasSignatureField && !signatureValid) {
return;
}
const valid = validateFieldsInserted(fieldsRequiringValidation);
if (!valid) {
@@ -432,34 +420,16 @@ export const EmbedSignDocumentClientPage = ({
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
allowTypedSignature={Boolean(
metadata &&
'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled,
)}
/>
</CardContent>
</Card>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
<SignaturePadDialog
className="mt-2"
disabled={isThrottled || isSubmitting}
disableAnimation
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={metadata?.typedSignatureEnabled}
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
drawSignatureEnabled={metadata?.drawSignatureEnabled}
/>
</div>
)}
</>
@@ -477,9 +447,7 @@ export const EmbedSignDocumentClientPage = ({
) : (
<Button
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
disabled={isThrottled}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
+11 -10
View File
@@ -19,12 +19,15 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
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';
export const ZProfileFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
signature: z.string().min(1, 'Signature Pad cannot be empty'),
name: z
.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({
@@ -109,22 +112,20 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
</Label>
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
</div>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange } }) => (
render={({ field: { onChange, value } }) => (
<FormItem>
<FormLabel>
<Trans>Signature</Trans>
</FormLabel>
<FormControl>
<SignaturePad
className="h-44 w-full"
<SignaturePadDialog
disabled={isSubmitting}
containerClassName={cn('rounded-lg border bg-background')}
defaultValue={user.signature ?? undefined}
value={value}
onChange={(v) => onChange(v ?? '')}
allowTypedSignature={true}
/>
</FormControl>
<FormMessage />
@@ -134,7 +135,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
</fieldset>
<Button type="submit" loading={isSubmitting} className="self-end">
{isSubmitting ? <Trans>Updating profile...</Trans> : <Trans>Update profile</Trans>}
<Trans>Update profile</Trans>
</Button>
</form>
</Form>
+25 -5
View File
@@ -30,7 +30,7 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/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 { UserProfileSkeleton } from '~/components/general/user-profile-skeleton';
@@ -353,16 +353,15 @@ export const SignUpForm = ({
<FormField
control={form.control}
name="signature"
render={({ field: { onChange } }) => (
render={({ field: { onChange, value } }) => (
<FormItem>
<FormLabel>
<Trans>Sign Here</Trans>
</FormLabel>
<FormControl>
<SignaturePad
className="h-36 w-full"
<SignaturePadDialog
disabled={isSubmitting}
containerClassName="mt-2 rounded-lg border bg-background"
value={value}
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
@@ -531,6 +530,27 @@ export const SignUpForm = ({
</div>
</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>
);
@@ -308,7 +308,7 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Save</Trans>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
@@ -8,12 +8,15 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
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 { Button } from '@documenso/ui/primitives/button';
import {
@@ -23,7 +26,9 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
@@ -38,8 +43,10 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
documentVisibility: z.nativeEnum(DocumentVisibility),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
includeSenderDetails: z.boolean(),
typedSignatureEnabled: 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>;
@@ -69,8 +76,8 @@ export const TeamDocumentPreferencesForm = ({
? settings?.documentLanguage
: 'en',
includeSenderDetails: settings?.includeSenderDetails ?? false,
typedSignatureEnabled: settings?.typedSignatureEnabled ?? true,
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
signatureTypes: extractTeamSignatureSettings(settings),
},
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
});
@@ -84,7 +91,7 @@ export const TeamDocumentPreferencesForm = ({
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
signatureTypes,
} = data;
await updateTeamDocumentPreferences({
@@ -93,8 +100,10 @@ export const TeamDocumentPreferencesForm = ({
documentVisibility,
documentLanguage,
includeSenderDetails,
typedSignatureEnabled,
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
control={form.control}
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
control={form.control}
name="includeSigningCertificate"
@@ -301,7 +318,7 @@ export const TeamDocumentPreferencesForm = ({
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Save</Trans>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
@@ -24,7 +24,6 @@ import type {
} from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
@@ -35,7 +34,7 @@ import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/ty
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
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 { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
@@ -73,8 +72,7 @@ export const DirectTemplateSigningForm = ({
template,
onSubmit,
}: DirectTemplateSigningFormProps) => {
const { fullName, signature, signatureValid, setFullName, setSignature } =
useRequiredDocumentSigningContext();
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(directRecipientFields);
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
@@ -135,8 +133,6 @@ export const DirectTemplateSigningForm = ({
);
};
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
}, [localFields]);
@@ -149,10 +145,6 @@ export const DirectTemplateSigningForm = ({
const handleSubmit = async () => {
setValidateUninsertedFields(true);
if (hasSignatureField && !signatureValid) {
return;
}
const isFieldsValid = validateFieldsInserted(localFields);
if (!isFieldsValid) {
@@ -240,6 +232,8 @@ export const DirectTemplateSigningForm = ({
onSignField={onSignField}
onUnsignField={onUnsignField}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => (
@@ -384,19 +378,15 @@ export const DirectTemplateSigningForm = ({
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
allowTypedSignature={template.templateMeta?.typedSignatureEnabled}
/>
</CardContent>
</Card>
<SignaturePadDialog
className="mt-2"
disabled={isSubmitting}
value={signature ?? ''}
onChange={(value) => setSignature(value)}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
/>
</div>
</div>
</div>
@@ -18,11 +18,10 @@ import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
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 { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog';
@@ -59,8 +58,7 @@ export const DocumentSigningForm = ({
const assistantSignersId = useId();
const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } =
useRequiredDocumentSigningContext();
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
@@ -105,10 +103,6 @@ export const DocumentSigningForm = ({
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
if (hasSignatureField && !signatureValid) {
return;
}
if (!isFieldsValid) {
return;
}
@@ -347,32 +341,15 @@ export const DocumentSigningForm = ({
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isSubmitting}
defaultValue={signature ?? undefined}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
onChange={(value) => {
if (signatureValid) {
setSignature(value);
}
}}
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
/>
</CardContent>
</Card>
{!signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
<SignaturePadDialog
className="mt-2"
disabled={isSubmitting}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
/>
</div>
)}
</div>
@@ -177,6 +177,8 @@ export const DocumentSigningPageView = ({
key={field.id}
field={field}
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => (
@@ -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 = {
fullName: string;
@@ -7,8 +9,6 @@ export type DocumentSigningContextValue = {
setEmail: (_value: string) => void;
signature: string | null;
setSignature: (_value: string | null) => void;
signatureValid: boolean;
setSignatureValid: (_valid: boolean) => void;
};
const DocumentSigningContext = createContext<DocumentSigningContextValue | null>(null);
@@ -31,6 +31,9 @@ export interface DocumentSigningProviderProps {
fullName?: string | null;
email?: string | null;
signature?: string | null;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
children: React.ReactNode;
}
@@ -38,18 +41,31 @@ export const DocumentSigningProvider = ({
fullName: initialFullName,
email: initialEmail,
signature: initialSignature,
typedSignatureEnabled = true,
uploadSignatureEnabled = true,
drawSignatureEnabled = true,
children,
}: DocumentSigningProviderProps) => {
const [fullName, setFullName] = useState(initialFullName || '');
const [email, setEmail] = useState(initialEmail || '');
const [signature, setSignature] = useState(initialSignature || null);
const [signatureValid, setSignatureValid] = useState(true);
useEffect(() => {
if (initialSignature) {
setSignature(initialSignature);
}
}, [initialSignature]);
// Ensure the user signature doesn't show up if it's not allowed.
const [signature, setSignature] = useState(
(() => {
const sig = initialSignature || '';
const isBase64 = isBase64Image(sig);
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled)) {
return sig;
}
if (!isBase64 && typedSignatureEnabled) {
return sig;
}
return null;
})(),
);
return (
<DocumentSigningContext.Provider
@@ -60,8 +76,6 @@ export const DocumentSigningProvider = ({
setEmail,
signature,
setSignature,
signatureValid,
setSignatureValid,
}}
>
{children}
@@ -17,7 +17,6 @@ import type {
} from '@documenso/trpc/server/field-router/schema';
import { Button } from '@documenso/ui/primitives/button';
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 { useToast } from '@documenso/ui/primitives/use-toast';
@@ -29,11 +28,14 @@ import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
export type DocumentSigningSignatureFieldProps = {
field: FieldWithSignature;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
};
export const DocumentSigningSignatureField = ({
@@ -41,6 +43,8 @@ export const DocumentSigningSignatureField = ({
onSignField,
onUnsignField,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
}: DocumentSigningSignatureFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@@ -52,12 +56,8 @@ export const DocumentSigningSignatureField = ({
const containerRef = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState(2);
const {
signature: providedSignature,
setSignature: setProvidedSignature,
signatureValid,
setSignatureValid,
} = useRequiredDocumentSigningContext();
const { signature: providedSignature, setSignature: setProvidedSignature } =
useRequiredDocumentSigningContext();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
@@ -89,7 +89,7 @@ export const DocumentSigningSignatureField = ({
}, [field.inserted, signature?.signatureImageAsBase64]);
const onPreSign = () => {
if (!providedSignature || !signatureValid) {
if (!providedSignature) {
setShowSignatureModal(true);
return false;
}
@@ -102,6 +102,7 @@ export const DocumentSigningSignatureField = ({
const onDialogSignClick = () => {
setShowSignatureModal(false);
setProvidedSignature(localSignature);
if (!localSignature) {
return;
}
@@ -116,14 +117,14 @@ export const DocumentSigningSignatureField = ({
try {
const value = signature || providedSignature;
if (!value || (signature && !signatureValid)) {
if (!value) {
setShowSignatureModal(true);
return;
}
const isTypedSignature = !value.startsWith('data:image');
if (isTypedSignature && !typedSignatureEnabled) {
if (isTypedSignature && typedSignatureEnabled === false) {
toast({
title: _(msg`Error`),
description: _(msg`Typed signatures are not allowed. Please draw your signature.`),
@@ -275,29 +276,14 @@ export const DocumentSigningSignatureField = ({
</Trans>
</DialogTitle>
<div className="">
<Label htmlFor="signature">
<Trans>Signature</Trans>
</Label>
<div className="border-border mt-2 rounded-md border">
<SignaturePad
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>
<SignaturePad
className="mt-2"
value={localSignature ?? ''}
onChange={({ value }) => setLocalSignature(value)}
typedSignatureEnabled={typedSignatureEnabled}
uploadSignatureEnabled={uploadSignatureEnabled}
drawSignatureEnabled={drawSignatureEnabled}
/>
<DocumentSigningDisclosure />
@@ -317,7 +303,7 @@ export const DocumentSigningSignatureField = ({
<Button
type="button"
className="flex-1"
disabled={!localSignature || !signatureValid}
disabled={!localSignature}
onClick={() => onDialogSignClick()}
>
<Trans>Sign</Trans>
@@ -5,6 +5,7 @@ import { useLingui } from '@lingui/react';
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
import { useNavigate, useSearchParams } from 'react-router';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
@@ -71,7 +72,7 @@ export const DocumentEditForm = ({
const { recipients, fields } = document;
const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({
const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
@@ -174,7 +175,7 @@ export const DocumentEditForm = ({
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
try {
const { timezone, dateFormat, redirectUrl, language } = data.meta;
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
await updateDocument({
documentId: document.id,
@@ -190,6 +191,9 @@ export const DocumentEditForm = ({
dateFormat,
redirectUrl,
language: isValidLanguageCode(language) ? language : undefined,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
},
});
@@ -242,14 +246,6 @@ export const DocumentEditForm = ({
fields: data.fields,
});
await updateDocument({
documentId: document.id,
meta: {
typedSignatureEnabled: data.typedSignatureEnabled,
},
});
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
@@ -378,7 +374,6 @@ export const DocumentEditForm = ({
fields={fields}
onSubmit={onAddFieldsFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
teamId={team?.id}
/>
@@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useNavigate } from 'react-router';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
@@ -124,6 +125,8 @@ export const TemplateEditForm = ({
});
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
const { signatureTypes } = data.meta;
try {
await updateTemplateSettings({
templateId: template.id,
@@ -136,6 +139,9 @@ export const TemplateEditForm = ({
},
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,
},
});
@@ -187,13 +193,6 @@ export const TemplateEditForm = ({
fields: data.fields,
});
await updateTemplateSettings({
templateId: template.id,
meta: {
typedSignatureEnabled: data.typedSignatureEnabled,
},
});
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
@@ -284,7 +283,6 @@ export const TemplateEditForm = ({
fields={fields}
onSubmit={onAddFieldsFormSubmit}
teamId={team?.id}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
/>
</Stepper>
</DocumentFlowFormContainer>
@@ -1,96 +1,43 @@
import { useEffect, useState } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { WebhookTriggerEvents } from '@prisma/client';
import { Check, ChevronsUpDown } from 'lucide-react';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { truncateTitle } from '~/utils/truncate-title';
import { MultipleSelector } from '@documenso/ui/primitives/multiselect';
type WebhookMultiSelectComboboxProps = {
listValues: string[];
onChange: (_values: string[]) => void;
};
const triggerEvents = Object.values(WebhookTriggerEvents).map((value) => ({
value,
label: toFriendlyWebhookEventName(value),
}));
export const WebhookMultiSelectCombobox = ({
listValues,
onChange,
}: WebhookMultiSelectComboboxProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const triggerEvents = Object.values(WebhookTriggerEvents);
useEffect(() => {
setSelectedValues(listValues);
}, [listValues]);
const allEvents = [...new Set([...triggerEvents, ...selectedValues])];
const handleSelect = (currentValue: string) => {
let newSelectedValues;
if (selectedValues.includes(currentValue)) {
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
} else {
newSelectedValues = [...selectedValues, currentValue];
}
setSelectedValues(newSelectedValues);
onChange(newSelectedValues);
setIsOpen(false);
const handleOnChange = (options: { value: string; label: string }[]) => {
onChange(options.map((option) => option.value));
};
const mappedValues = listValues.map((value) => ({
value,
label: toFriendlyWebhookEventName(value),
}));
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isOpen}
className="w-[200px] justify-between"
>
<Plural value={selectedValues.length} zero="Select values" other="# selected..." />
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="z-9999 w-full max-w-[280px] p-0">
<Command>
<CommandInput
placeholder={truncateTitle(
selectedValues.map((v) => toFriendlyWebhookEventName(v)).join(', '),
15,
)}
/>
<CommandEmpty>
<Trans>No value found.</Trans>
</CommandEmpty>
<CommandGroup>
{allEvents.map((value: string, i: number) => (
<CommandItem key={i} onSelect={() => handleSelect(value)}>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
)}
/>
{toFriendlyWebhookEventName(value)}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<MultipleSelector
commandProps={{
label: 'Select triggers',
}}
defaultOptions={triggerEvents}
value={mappedValues}
onChange={handleOnChange}
placeholder="Select triggers"
hideClearAllButton
hidePlaceholderWhenSelected
emptyIndicator={<p className="text-center text-sm">No triggers available</p>}
/>
);
};
@@ -79,7 +79,14 @@ export default function DirectTemplatePage() {
const { template, directTemplateRecipient } = data;
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
documentAuthOptions={template.authOptions}
recipient={directTemplateRecipient}
@@ -215,6 +215,9 @@ export default function SigningPage() {
email={recipient.email}
fullName={user?.email === recipient.email ? user?.name : recipient.name}
signature={user?.email === recipient.email ? user?.signature : undefined}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
>
<DocumentSigningAuthProvider
documentAuthOptions={document.authOptions}
+8 -1
View File
@@ -131,7 +131,14 @@ export default function EmbedDirectTemplatePage() {
} = useSuperLoaderData<typeof loader>();
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
documentAuthOptions={template.authOptions}
recipient={recipient}
@@ -156,6 +156,9 @@ export default function EmbedSignDocumentPage() {
email={recipient.email}
fullName={user?.email === recipient.email ? user?.name : recipient.name}
signature={user?.email === recipient.email ? user?.signature : undefined}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
>
<DocumentSigningAuthProvider
documentAuthOptions={document.authOptions}
+2
View File
@@ -325,6 +325,8 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
signingOrder: body.meta.signingOrder,
language: body.meta.language,
typedSignatureEnabled: body.meta.typedSignatureEnabled,
uploadSignatureEnabled: body.meta.uploadSignatureEnabled,
drawSignatureEnabled: body.meta.drawSignatureEnabled,
distributionMethod: body.meta.distributionMethod,
emailSettings: body.meta.emailSettings,
requestMetadata: metadata,
+4
View File
@@ -157,6 +157,8 @@ export const ZCreateDocumentMutationSchema = z.object({
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
typedSignatureEnabled: z.boolean().optional().default(true),
uploadSignatureEnabled: z.boolean().optional().default(true),
drawSignatureEnabled: z.boolean().optional().default(true),
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
@@ -288,6 +290,8 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
language: z.enum(SUPPORTED_LANGUAGE_CODES),
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
typedSignatureEnabled: z.boolean(),
uploadSignatureEnabled: z.boolean(),
drawSignatureEnabled: z.boolean(),
emailSettings: ZDocumentEmailSettingsSchema,
})
.partial()
@@ -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) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
@@ -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) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
@@ -222,7 +222,10 @@ test.describe('Signing Certificate Tests', () => {
// Toggle signing certificate setting
await page.getByLabel('Include the Signing Certificate in the Document').click();
await page.getByRole('button', { name: /Save/ }).first().click();
await page
.getByRole('button', { name: /Update/ })
.first()
.click();
await page.waitForTimeout(1000);
@@ -236,7 +239,10 @@ test.describe('Signing Certificate Tests', () => {
// Toggle the setting back to true
await page.getByLabel('Include the Signing Certificate in the Document').click();
await page.getByRole('button', { name: /Save/ }).first().click();
await page
.getByRole('button', { name: /Update/ })
.first()
.click();
await page.waitForTimeout(1000);
+6 -32
View File
@@ -3,38 +3,12 @@ import type { Page } from '@playwright/test';
export const signSignaturePad = async (page: Page) => {
await page.waitForTimeout(200);
const canvas = page.getByTestId('signature-pad');
await page.getByTestId('signature-pad-dialog-button').click();
const box = await canvas.boundingBox();
// Click type tab
await page.getByRole('tab', { name: 'Type' }).click();
await page.getByTestId('signature-pad-type-input').fill('Signature');
if (!box) {
throw new Error('Signature pad not found');
}
// Calculate center point
const centerX = box.x + box.width / 2;
const centerY = box.y + box.height / 2;
// Calculate square size (making it slightly smaller than the canvas)
const squareSize = Math.min(box.width, box.height) * 0.4; // 40% of the smallest dimension
// Move to center
await page.mouse.move(centerX, centerY);
await page.mouse.down();
// Draw square clockwise from center
// Move right
await page.mouse.move(centerX + squareSize, centerY, { steps: 10 });
// Move down
await page.mouse.move(centerX + squareSize, centerY + squareSize, { steps: 10 });
// Move left
await page.mouse.move(centerX - squareSize, centerY + squareSize, { steps: 10 });
// Move up
await page.mouse.move(centerX - squareSize, centerY - squareSize, { steps: 10 });
// Move right
await page.mouse.move(centerX + squareSize, centerY - squareSize, { steps: 10 });
// Move down to close the square
await page.mouse.move(centerX + squareSize, centerY, { steps: 10 });
await page.mouse.up();
// Click Next button
await page.getByRole('button', { name: 'Next' }).click();
};
@@ -23,7 +23,7 @@ test('[TEAMS]: update the default document visibility in the team global setting
// !: Brittle selector
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'Admin' }).click();
await page.getByRole('button', { name: 'Save' }).first().click();
await page.getByRole('button', { name: 'Update' }).first().click();
const toast = page.locator('li[role="status"][data-state="open"]').first();
await expect(toast).toBeVisible();
@@ -47,7 +47,7 @@ test('[TEAMS]: update the sender details in the team global settings', async ({
await expect(checkbox).toBeChecked();
await page.getByRole('button', { name: 'Save' }).first().click();
await page.getByRole('button', { name: 'Update' }).first().click();
const toast = page.locator('li[role="status"][data-state="open"]').first();
await expect(toast).toBeVisible();
@@ -0,0 +1,182 @@
import { expect, test } from '@playwright/test';
import { prisma } from '@documenso/prisma';
import {
seedTeamDocumentWithMeta,
seedTeamDocuments,
seedTeamTemplateWithMeta,
} from '@documenso/prisma/seed/documents';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: check that default team signature settings are all enabled', async ({ page }) => {
const { team } = await seedTeamDocuments();
await apiSignin({
page,
email: team.owner.email,
password: 'password',
redirectPath: `/t/${team.url}/settings/preferences`,
});
// Verify that the default created team settings has all signatures enabled
await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
const document = await seedTeamDocumentWithMeta(team);
// Create a document and check the settings
await page.goto(`/t/${team.url}/documents/${document.id}/edit`);
// Verify that the settings match
await page.getByRole('button', { name: 'Advanced Options' }).click();
await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
// Go to document and check that the signatured tabs are correct.
await page.goto(`/sign/${document.recipients[0].token}`);
await page.getByTestId('signature-pad-dialog-button').click();
// Check the tab values
await expect(page.getByRole('tab', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Upload' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
});
test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
const { team } = await seedTeamDocuments();
await apiSignin({
page,
email: team.owner.email,
password: 'password',
redirectPath: `/t/${team.url}/settings/preferences`,
});
const allTabs = ['Type', 'Upload', 'Draw'];
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
for (const tabs of tabTest) {
await page.goto(`/t/${team.url}/settings/preferences`);
// Update combobox to have the correct tabs
await page.getByTestId('signature-types-combobox').click();
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
// Clear all selected items.
for (const tab of allTabs) {
const item = page.getByRole('option', { name: tab });
const isSelected = (await item.innerHTML()).includes('opacity-100');
if (isSelected) {
await item.click();
}
}
// Selected wanted items.
for (const tab of tabs) {
const item = page.getByRole('option', { name: tab });
await item.click();
}
await page.getByRole('button', { name: 'Update' }).first().click();
const document = await seedTeamDocumentWithMeta(team);
// Go to document and check that the signatured tabs are correct.
await page.goto(`/sign/${document.recipients[0].token}`);
await page.getByTestId('signature-pad-dialog-button').click();
// Check the tab values
for (const tab of allTabs) {
if (tabs.includes(tab)) {
await expect(page.getByRole('tab', { name: tab })).toBeVisible();
} else {
await expect(page.getByRole('tab', { name: tab })).not.toBeVisible();
}
}
}
});
test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
const { team } = await seedTeamDocuments();
await apiSignin({
page,
email: team.owner.email,
password: 'password',
redirectPath: `/t/${team.url}/settings/preferences`,
});
const allTabs = ['Type', 'Upload', 'Draw'];
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
for (const tabs of tabTest) {
await page.goto(`/t/${team.url}/settings/preferences`);
// Update combobox to have the correct tabs
await page.getByTestId('signature-types-combobox').click();
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
// Clear all selected items.
for (const tab of allTabs) {
const item = page.getByRole('option', { name: tab });
const isSelected = (await item.innerHTML()).includes('opacity-100');
if (isSelected) {
await item.click();
}
}
// Selected wanted items.
for (const tab of tabs) {
const item = page.getByRole('option', { name: tab });
await item.click();
}
await page.getByRole('button', { name: 'Update' }).first().click();
const template = await seedTeamTemplateWithMeta(team);
await page.goto(`/t/${team.url}/templates/${template.id}`);
await page.getByRole('button', { name: 'Use' }).click();
// Check the send document checkbox to true
await page.getByLabel('Send document').click();
await page.getByRole('button', { name: 'Create and send' }).click();
await page.waitForTimeout(1000);
const document = await prisma.document.findFirst({
where: {
templateId: template.id,
},
include: {
documentMeta: true,
},
});
// Test kinda flaky, debug here.
// console.log({
// tabs,
// typedSignatureEnabled: document?.documentMeta?.typedSignatureEnabled,
// uploadSignatureEnabled: document?.documentMeta?.uploadSignatureEnabled,
// drawSignatureEnabled: document?.documentMeta?.drawSignatureEnabled,
// });
expect(document?.documentMeta?.typedSignatureEnabled).toEqual(tabs.includes('Type'));
expect(document?.documentMeta?.uploadSignatureEnabled).toEqual(tabs.includes('Upload'));
expect(document?.documentMeta?.drawSignatureEnabled).toEqual(tabs.includes('Draw'));
}
});
@@ -298,6 +298,7 @@ test('[DIRECT_TEMPLATES]: use direct template link with 2 recipients', async ({
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.waitForTimeout(1000);
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
@@ -4,6 +4,7 @@ import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-emai
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
test('[USER] update full name', async ({ page }) => {
const user = await seedUser();
@@ -12,7 +13,7 @@ test('[USER] update full name', async ({ page }) => {
await page.getByLabel('Full Name').fill('John Doe');
await page.getByPlaceholder('Type your signature').fill('John Doe');
await signSignaturePad(page);
await page.getByRole('button', { name: 'Update profile' }).click();
+26
View File
@@ -34,3 +34,29 @@ export const DOCUMENT_DISTRIBUTION_METHODS: Record<string, DocumentDistributionM
description: msg`None`,
},
} satisfies Record<DocumentDistributionMethod, DocumentDistributionMethodTypeData>;
export enum DocumentSignatureType {
DRAW = 'draw',
TYPE = 'type',
UPLOAD = 'upload',
}
type DocumentSignatureTypeData = {
label: MessageDescriptor;
value: DocumentSignatureType;
};
export const DOCUMENT_SIGNATURE_TYPES = {
[DocumentSignatureType.DRAW]: {
label: msg`Draw`,
value: DocumentSignatureType.DRAW,
},
[DocumentSignatureType.TYPE]: {
label: msg`Type`,
value: DocumentSignatureType.TYPE,
},
[DocumentSignatureType.UPLOAD]: {
label: msg`Upload`,
value: DocumentSignatureType.UPLOAD,
},
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;
+4
View File
@@ -0,0 +1,4 @@
export const SIGNATURE_CANVAS_DPI = 2;
export const SIGNATURE_MIN_COVERAGE_THRESHOLD = 0.01;
export const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,');
@@ -23,6 +23,8 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
brandingHidePoweredBy: z.boolean(),
teamId: z.number(),
typedSignatureEnabled: z.boolean(),
uploadSignatureEnabled: z.boolean(),
drawSignatureEnabled: z.boolean(),
})
.nullish(),
}),
@@ -26,6 +26,8 @@ export type CreateDocumentMetaOptions = {
signingOrder?: DocumentSigningOrder;
distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
language?: SupportedLanguageCodes;
requestMetadata: ApiRequestMetadata;
};
@@ -44,6 +46,8 @@ export const upsertDocumentMeta = async ({
emailSettings,
distributionMethod,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
language,
requestMetadata,
}: CreateDocumentMetaOptions) => {
@@ -96,6 +100,8 @@ export const upsertDocumentMeta = async ({
emailSettings,
distributionMethod,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
language,
},
update: {
@@ -109,6 +115,8 @@ export const upsertDocumentMeta = async ({
emailSettings,
distributionMethod,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
language,
},
});
@@ -158,6 +158,10 @@ export const createDocumentV2 = async ({
language: meta?.language || team?.teamGlobalSettings?.documentLanguage,
typedSignatureEnabled:
meta?.typedSignatureEnabled ?? team?.teamGlobalSettings?.typedSignatureEnabled,
uploadSignatureEnabled:
meta?.uploadSignatureEnabled ?? team?.teamGlobalSettings?.uploadSignatureEnabled,
drawSignatureEnabled:
meta?.drawSignatureEnabled ?? team?.teamGlobalSettings?.drawSignatureEnabled,
},
},
},
@@ -128,8 +128,10 @@ export const createDocument = async ({
documentMeta: {
create: {
language: team?.teamGlobalSettings?.documentLanguage,
typedSignatureEnabled: team?.teamGlobalSettings?.typedSignatureEnabled,
timezone: timezone,
typedSignatureEnabled: team?.teamGlobalSettings?.typedSignatureEnabled ?? true,
uploadSignatureEnabled: team?.teamGlobalSettings?.uploadSignatureEnabled ?? true,
drawSignatureEnabled: team?.teamGlobalSettings?.drawSignatureEnabled ?? true,
},
},
},
@@ -201,7 +201,7 @@ export const signFieldWithToken = async ({
throw new Error('Signature field must have a signature');
}
if (isSignatureField && !documentMeta?.typedSignatureEnabled && typedSignature) {
if (isSignatureField && documentMeta?.typedSignatureEnabled === false && typedSignature) {
throw new Error('Typed signatures are not allowed. Please draw your signature');
}
@@ -1,73 +0,0 @@
import type { DocumentVisibility } from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TeamGlobalSettingsSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamGlobalSettingsSchema';
import type { SupportedLanguageCodes } from '../../constants/i18n';
export type UpdateTeamDocumentSettingsOptions = {
userId: number;
teamId: number;
settings: {
documentVisibility: DocumentVisibility;
documentLanguage: SupportedLanguageCodes;
includeSenderDetails: boolean;
typedSignatureEnabled: boolean;
includeSigningCertificate: boolean;
};
};
export const ZUpdateTeamDocumentSettingsResponseSchema = TeamGlobalSettingsSchema;
export type TUpdateTeamDocumentSettingsResponse = z.infer<
typeof ZUpdateTeamDocumentSettingsResponseSchema
>;
export const updateTeamDocumentSettings = async ({
userId,
teamId,
settings,
}: UpdateTeamDocumentSettingsOptions): Promise<TUpdateTeamDocumentSettingsResponse> => {
const {
documentVisibility,
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
} = settings;
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
},
});
if (!member || member.role !== TeamMemberRole.ADMIN) {
throw new Error('You do not have permission to update this team.');
}
return await prisma.teamGlobalSettings.upsert({
where: {
teamId,
},
create: {
teamId,
documentVisibility,
documentLanguage,
includeSenderDetails,
typedSignatureEnabled,
includeSigningCertificate,
},
update: {
documentVisibility,
documentLanguage,
includeSenderDetails,
typedSignatureEnabled,
includeSigningCertificate,
},
});
};
@@ -324,6 +324,9 @@ export const createDocumentFromDirectTemplate = async ({
language: metaLanguage,
signingOrder: metaSigningOrder,
distributionMethod: template.templateMeta?.distributionMethod,
typedSignatureEnabled: template.templateMeta?.typedSignatureEnabled,
uploadSignatureEnabled: template.templateMeta?.uploadSignatureEnabled,
drawSignatureEnabled: template.templateMeta?.drawSignatureEnabled,
},
},
},
@@ -96,6 +96,9 @@ export const createDocumentFromTemplateLegacy = async ({
signingOrder: template.templateMeta?.signingOrder ?? undefined,
language:
template.templateMeta?.language || template.team?.teamGlobalSettings?.documentLanguage,
typedSignatureEnabled: template.templateMeta?.typedSignatureEnabled,
uploadSignatureEnabled: template.templateMeta?.uploadSignatureEnabled,
drawSignatureEnabled: template.templateMeta?.drawSignatureEnabled,
},
},
},
@@ -82,8 +82,10 @@ export type CreateDocumentFromTemplateOptions = {
signingOrder?: DocumentSigningOrder;
language?: SupportedLanguageCodes;
distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean;
emailSettings?: TDocumentEmailSettings;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
};
requestMetadata: ApiRequestMetadata;
};
@@ -404,6 +406,10 @@ export const createDocumentFromTemplate = async ({
template.team?.teamGlobalSettings?.documentLanguage,
typedSignatureEnabled:
override?.typedSignatureEnabled ?? template.templateMeta?.typedSignatureEnabled,
uploadSignatureEnabled:
override?.uploadSignatureEnabled ?? template.templateMeta?.uploadSignatureEnabled,
drawSignatureEnabled:
override?.drawSignatureEnabled ?? template.templateMeta?.drawSignatureEnabled,
},
},
recipients: {
@@ -4,6 +4,8 @@ import { prisma } from '@documenso/prisma';
import { TemplateSchema } from '@documenso/prisma/generated/zod/modelSchema//TemplateSchema';
import type { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type CreateTemplateOptions = TCreateTemplateMutationSchema & {
userId: number;
teamId?: number;
@@ -19,8 +21,10 @@ export const createTemplate = async ({
teamId,
templateDocumentDataId,
}: CreateTemplateOptions) => {
let team = null;
if (teamId) {
await prisma.team.findFirstOrThrow({
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
@@ -29,7 +33,14 @@ export const createTemplate = async ({
},
},
},
include: {
teamGlobalSettings: true,
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
}
return await prisma.template.create({
@@ -38,6 +49,14 @@ export const createTemplate = async ({
userId,
templateDocumentDataId,
teamId,
templateMeta: {
create: {
language: team?.teamGlobalSettings?.documentLanguage,
typedSignatureEnabled: team?.teamGlobalSettings?.typedSignatureEnabled ?? true,
uploadSignatureEnabled: team?.teamGlobalSettings?.uploadSignatureEnabled ?? true,
drawSignatureEnabled: team?.teamGlobalSettings?.drawSignatureEnabled ?? true,
},
},
},
});
};
+53
View File
@@ -0,0 +1,53 @@
import { msg } from '@lingui/core/macro';
import { z } from 'zod';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
/**
* The full document response schema.
*
* Mainly used for returning a single document from the API.
*/
export const ZDocumentMetaSchema = DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
id: true,
subject: true,
message: true,
timezone: true,
password: true,
dateFormat: true,
documentId: true,
redirectUrl: true,
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
language: true,
emailSettings: true,
});
export type TDocumentMeta = z.infer<typeof ZDocumentMetaSchema>;
/**
* If you update this, you must also update the schema.prisma @default value for
* - Template meta
* - Document meta
*/
export const ZDocumentSignatureSettingsSchema = z
.object({
typedSignatureEnabled: z.boolean(),
uploadSignatureEnabled: z.boolean(),
drawnSignatureEnabled: z.boolean(),
})
.refine(
(data) => {
return (
data.typedSignatureEnabled || data.uploadSignatureEnabled || data.drawnSignatureEnabled
);
},
{
message: msg`At least one signature type must be enabled`.id,
},
);
export type TDocumentSignatureSettings = z.infer<typeof ZDocumentSignatureSettingsSchema>;
+2
View File
@@ -51,6 +51,8 @@ export const ZDocumentSchema = DocumentSchema.pick({
documentId: true,
redirectUrl: true,
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
language: true,
emailSettings: true,
}).nullable(),
+2
View File
@@ -45,6 +45,8 @@ export const ZTemplateSchema = TemplateSchema.pick({
dateFormat: true,
signingOrder: true,
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
distributionMethod: true,
templateId: true,
redirectUrl: true,
+2
View File
@@ -47,6 +47,8 @@ export const ZWebhookDocumentMetaSchema = z.object({
redirectUrl: z.string().nullable(),
signingOrder: z.nativeEnum(DocumentSigningOrder),
typedSignatureEnabled: z.boolean(),
uploadSignatureEnabled: z.boolean(),
drawSignatureEnabled: z.boolean(),
language: z.string(),
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
emailSettings: z.any().nullable(),
+29
View File
@@ -1,4 +1,5 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { DocumentSignatureType } from '../constants/document';
import type { TEAM_MEMBER_ROLE_MAP } from '../constants/teams';
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../constants/teams';
@@ -44,3 +45,31 @@ export const isTeamRoleWithinUserHierarchy = (
) => {
return TEAM_MEMBER_ROLE_HIERARCHY[currentUserRole].some((i) => i === roleToCheck);
};
export const extractTeamSignatureSettings = (
settings?: {
typedSignatureEnabled: boolean;
drawSignatureEnabled: boolean;
uploadSignatureEnabled: boolean;
} | null,
) => {
if (!settings) {
return [DocumentSignatureType.TYPE, DocumentSignatureType.UPLOAD, DocumentSignatureType.DRAW];
}
const signatureTypes: DocumentSignatureType[] = [];
if (settings.typedSignatureEnabled) {
signatureTypes.push(DocumentSignatureType.TYPE);
}
if (settings.drawSignatureEnabled) {
signatureTypes.push(DocumentSignatureType.DRAW);
}
if (settings.uploadSignatureEnabled) {
signatureTypes.push(DocumentSignatureType.UPLOAD);
}
return signatureTypes;
};
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "drawSignatureEnabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "uploadSignatureEnabled" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "drawSignatureEnabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "uploadSignatureEnabled" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE "TemplateMeta" ADD COLUMN "drawSignatureEnabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "uploadSignatureEnabled" BOOLEAN NOT NULL DEFAULT true;
+25
View File
@@ -0,0 +1,25 @@
import type { PrismaClient } from '@prisma/client';
export function addPrismaMiddleware(prisma: PrismaClient) {
prisma.$use(async (params, next) => {
// Check if we're creating a new team
if (params.model === 'Team' && params.action === 'create') {
// Execute the team creation
const result = await next(params);
// Create the TeamGlobalSettings
await prisma.teamGlobalSettings.create({
data: {
teamId: result.id,
},
});
return result;
}
// For all other operations, just pass through
return next(params);
});
return prisma;
}
+35 -24
View File
@@ -390,20 +390,25 @@ enum DocumentDistributionMethod {
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
model DocumentMeta {
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
signingOrder DocumentSigningOrder @default(PARALLEL)
typedSignatureEnabled Boolean @default(true)
language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL)
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
signingOrder DocumentSigningOrder @default(PARALLEL)
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
drawSignatureEnabled Boolean @default(true)
language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL)
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
}
enum ReadStatus {
@@ -544,9 +549,12 @@ model TeamGlobalSettings {
documentVisibility DocumentVisibility @default(EVERYONE)
documentLanguage String @default("en")
includeSenderDetails Boolean @default(true)
typedSignatureEnabled Boolean @default(true)
includeSigningCertificate Boolean @default(true)
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
drawSignatureEnabled Boolean @default(true)
brandingEnabled Boolean @default(false)
brandingLogo String @default("")
brandingUrl String @default("")
@@ -660,15 +668,18 @@ enum TemplateType {
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
model TemplateMeta {
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
signingOrder DocumentSigningOrder? @default(PARALLEL)
typedSignatureEnabled Boolean @default(true)
distributionMethod DocumentDistributionMethod @default(EMAIL)
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
signingOrder DocumentSigningOrder? @default(PARALLEL)
distributionMethod DocumentDistributionMethod @default(EMAIL)
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
drawSignatureEnabled Boolean @default(true)
templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
+143 -1
View File
@@ -1,9 +1,12 @@
import type { Document, User } from '@prisma/client';
import type { Document, Team, User } from '@prisma/client';
import { nanoid } from 'nanoid';
import fs from 'node:fs';
import path from 'node:path';
import { match } from 'ts-pattern';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
import { prisma } from '..';
import {
DocumentDataType,
@@ -87,6 +90,145 @@ export const unseedDocument = async (documentId: number) => {
});
};
export const seedTeamDocumentWithMeta = async (team: Team) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await createDocument({
userId: team.ownerUserId,
teamId: team.id,
title: `[TEST] Document ${nanoid(8)} - Draft`,
documentDataId: documentData.id,
normalizePdf: true,
requestMetadata: {
auth: null,
requestMetadata: {},
source: 'app',
},
});
const owner = await prisma.user.findFirstOrThrow({
where: {
id: team.ownerUserId,
},
});
await prisma.document.update({
where: {
id: document.id,
},
data: {
status: DocumentStatus.PENDING,
},
});
await prisma.recipient.create({
data: {
email: owner.email,
name: owner.name ?? '',
token: nanoid(),
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
signedAt: new Date(),
document: {
connect: {
id: document.id,
},
},
fields: {
create: {
page: 1,
type: FieldType.SIGNATURE,
inserted: false,
customText: '',
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(5),
height: new Prisma.Decimal(5),
documentId: document.id,
},
},
},
});
return await prisma.document.findFirstOrThrow({
where: {
id: document.id,
},
include: {
recipients: true,
},
});
};
export const seedTeamTemplateWithMeta = async (team: Team) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const template = await createTemplate({
title: `[TEST] Template ${nanoid(8)} - Draft`,
userId: team.ownerUserId,
teamId: team.id,
templateDocumentDataId: documentData.id,
});
const owner = await prisma.user.findFirstOrThrow({
where: {
id: team.ownerUserId,
},
});
await prisma.recipient.create({
data: {
email: owner.email,
name: owner.name ?? '',
token: nanoid(),
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
signedAt: new Date(),
template: {
connect: {
id: template.id,
},
},
fields: {
create: {
page: 1,
type: FieldType.SIGNATURE,
inserted: false,
customText: '',
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(5),
height: new Prisma.Decimal(5),
templateId: template.id,
},
},
},
});
return await prisma.document.findFirstOrThrow({
where: {
id: template.id,
},
include: {
recipients: true,
},
});
};
export const seedDraftDocument = async (
sender: User,
recipients: (User | string)[],
+16
View File
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { fontFamily } = require('tailwindcss/defaultTheme');
const { default: flattenColorPalette } = require('tailwindcss/lib/util/flattenColorPalette');
/** @type {import('tailwindcss').Config} */
module.exports = {
@@ -14,6 +15,9 @@ module.exports = {
zIndex: {
9999: '9999',
},
aspectRatio: {
'signature-pad': '16 / 7',
},
colors: {
border: 'hsl(var(--border))',
'field-border': 'hsl(var(--field-border))',
@@ -147,5 +151,17 @@ module.exports = {
require('tailwindcss-animate'),
require('@tailwindcss/typography'),
require('@tailwindcss/container-queries'),
addVariablesForColors,
],
};
function addVariablesForColors({ addBase, theme }) {
let allColors = flattenColorPalette(theme('colors'));
let newVars = Object.fromEntries(
Object.entries(allColors).map(([key, val]) => [`--${key}`, val]),
);
addBase({
':root': newVars,
});
}
+9 -12
View File
@@ -56,9 +56,12 @@ import {
ZSearchDocumentsMutationSchema,
ZSetSigningOrderForDocumentMutationSchema,
ZSuccessResponseSchema,
} from './schema';
import { updateDocumentRoute } from './update-document';
import {
ZUpdateDocumentRequestSchema,
ZUpdateDocumentResponseSchema,
} from './schema';
} from './update-document.types';
export const documentRouter = router({
/**
@@ -335,20 +338,12 @@ export const documentRouter = router({
});
}),
updateDocument: updateDocumentRoute,
/**
* @public
*
* Todo: Refactor to updateDocument.
* @deprecated Delete this after updateDocument endpoint is deployed
*/
setSettingsForDocument: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/update',
summary: 'Update document',
tags: ['Document'],
},
})
.input(ZUpdateDocumentRequestSchema)
.output(ZUpdateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
@@ -368,6 +363,8 @@ export const documentRouter = router({
dateFormat: meta.dateFormat,
language: meta.language,
typedSignatureEnabled: meta.typedSignatureEnabled,
uploadSignatureEnabled: meta.uploadSignatureEnabled,
drawSignatureEnabled: meta.drawSignatureEnabled,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
signingOrder: meta.signingOrder,
+10 -29
View File
@@ -109,6 +109,14 @@ export const ZDocumentMetaTypedSignatureEnabledSchema = z
.boolean()
.describe('Whether to allow recipients to sign using a typed signature.');
export const ZDocumentMetaDrawSignatureEnabledSchema = z
.boolean()
.describe('Whether to allow recipients to sign using a draw signature.');
export const ZDocumentMetaUploadSignatureEnabledSchema = z
.boolean()
.describe('Whether to allow recipients to sign using an uploaded signature.');
export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({
templateId: z
.number()
@@ -233,6 +241,8 @@ export const ZCreateDocumentV2RequestSchema = z.object({
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
@@ -249,35 +259,6 @@ export const ZCreateDocumentV2ResponseSchema = z.object({
),
});
export const ZUpdateDocumentRequestSchema = z.object({
documentId: z.number(),
data: z
.object({
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullish(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullish(),
})
.optional(),
meta: z
.object({
subject: ZDocumentMetaSubjectSchema.optional(),
message: ZDocumentMetaMessageSchema.optional(),
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
});
export const ZUpdateDocumentResponseSchema = ZDocumentLiteSchema;
export const ZSetFieldsForDocumentMutationSchema = z.object({
documentId: z.number(),
fields: z.array(
@@ -0,0 +1,52 @@
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateDocumentRequestSchema,
ZUpdateDocumentResponseSchema,
} from './update-document.types';
import { updateDocumentMeta } from './update-document.types';
/**
* Public route.
*/
export const updateDocumentRoute = authenticatedProcedure
.meta(updateDocumentMeta)
.input(ZUpdateDocumentRequestSchema)
.output(ZUpdateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId, data, meta = {} } = input;
const userId = ctx.user.id;
if (Object.values(meta).length > 0) {
await upsertDocumentMeta({
userId: ctx.user.id,
teamId,
documentId,
subject: meta.subject,
message: meta.message,
timezone: meta.timezone,
dateFormat: meta.dateFormat,
language: meta.language,
typedSignatureEnabled: meta.typedSignatureEnabled,
uploadSignatureEnabled: meta.uploadSignatureEnabled,
drawSignatureEnabled: meta.drawSignatureEnabled,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
signingOrder: meta.signingOrder,
emailSettings: meta.emailSettings,
requestMetadata: ctx.metadata,
});
}
return await updateDocument({
userId,
teamId,
documentId,
data,
requestMetadata: ctx.metadata,
});
});
@@ -0,0 +1,67 @@
import { DocumentSigningOrder } from '@prisma/client';
// import type { OpenApiMeta } from 'trpc-to-openapi';
import { z } from 'zod';
import { ZDocumentLiteSchema } from '@documenso/lib/types/document';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import type { TrpcRouteMeta } from '../trpc';
import {
ZDocumentExternalIdSchema,
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
ZDocumentMetaTypedSignatureEnabledSchema,
ZDocumentMetaUploadSignatureEnabledSchema,
ZDocumentTitleSchema,
ZDocumentVisibilitySchema,
} from './schema';
export const updateDocumentMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/document/update',
summary: 'Update document',
tags: ['Document'],
},
};
export const ZUpdateDocumentRequestSchema = z.object({
documentId: z.number(),
data: z
.object({
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullish(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullish(),
})
.optional(),
meta: z
.object({
subject: ZDocumentMetaSubjectSchema.optional(),
message: ZDocumentMetaMessageSchema.optional(),
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
});
export const ZUpdateDocumentResponseSchema = ZDocumentLiteSchema;
+2 -14
View File
@@ -33,7 +33,6 @@ import { resendTeamEmailVerification } from '@documenso/lib/server-only/team/res
import { resendTeamMemberInvitation } from '@documenso/lib/server-only/team/resend-team-member-invitation';
import { updateTeam } from '@documenso/lib/server-only/team/update-team';
import { updateTeamBrandingSettings } from '@documenso/lib/server-only/team/update-team-branding-settings';
import { updateTeamDocumentSettings } from '@documenso/lib/server-only/team/update-team-document-settings';
import { updateTeamEmail } from '@documenso/lib/server-only/team/update-team-email';
import { updateTeamMember } from '@documenso/lib/server-only/team/update-team-member';
import { updateTeamPublicProfile } from '@documenso/lib/server-only/team/update-team-public-profile';
@@ -66,12 +65,12 @@ import {
ZResendTeamEmailVerificationMutationSchema,
ZResendTeamMemberInvitationMutationSchema,
ZUpdateTeamBrandingSettingsMutationSchema,
ZUpdateTeamDocumentSettingsMutationSchema,
ZUpdateTeamEmailMutationSchema,
ZUpdateTeamMemberMutationSchema,
ZUpdateTeamMutationSchema,
ZUpdateTeamPublicProfileMutationSchema,
} from './schema';
import { updateTeamDocumentSettingsRoute } from './update-team-document-settings';
export const teamRouter = router({
// Internal endpoint for now.
@@ -571,18 +570,7 @@ export const teamRouter = router({
return await getTeamPrices();
}),
// Internal endpoint. Use updateTeam instead.
updateTeamDocumentSettings: authenticatedProcedure
.input(ZUpdateTeamDocumentSettingsMutationSchema)
.mutation(async ({ ctx, input }) => {
const { teamId, settings } = input;
return await updateTeamDocumentSettings({
userId: ctx.user.id,
teamId,
settings,
});
}),
updateTeamDocumentSettings: updateTeamDocumentSettingsRoute,
// Internal endpoint for now.
acceptTeamInvitation: authenticatedProcedure
+1 -19
View File
@@ -1,7 +1,6 @@
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import { z } from 'zod';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
@@ -195,20 +194,6 @@ export const ZUpdateTeamBrandingSettingsMutationSchema = z.object({
}),
});
export const ZUpdateTeamDocumentSettingsMutationSchema = z.object({
teamId: z.number(),
settings: z.object({
documentVisibility: z
.nativeEnum(DocumentVisibility)
.optional()
.default(DocumentVisibility.EVERYONE),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).optional().default('en'),
includeSenderDetails: z.boolean().optional().default(false),
typedSignatureEnabled: z.boolean().optional().default(true),
includeSigningCertificate: z.boolean().optional().default(true),
}),
});
export type TCreateTeamMutationSchema = z.infer<typeof ZCreateTeamMutationSchema>;
export type TCreateTeamEmailVerificationMutationSchema = z.infer<
typeof ZCreateTeamEmailVerificationMutationSchema
@@ -247,6 +232,3 @@ export type TResendTeamMemberInvitationMutationSchema = z.infer<
export type TUpdateTeamBrandingSettingsMutationSchema = z.infer<
typeof ZUpdateTeamBrandingSettingsMutationSchema
>;
export type TUpdateTeamDocumentSettingsMutationSchema = z.infer<
typeof ZUpdateTeamDocumentSettingsMutationSchema
>;
@@ -0,0 +1,71 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateTeamDocumentSettingsRequestSchema,
ZUpdateTeamDocumentSettingsResponseSchema,
} from './update-team-document-settings.types';
/**
* Private route.
*/
export const updateTeamDocumentSettingsRoute = authenticatedProcedure
.input(ZUpdateTeamDocumentSettingsRequestSchema)
.output(ZUpdateTeamDocumentSettingsResponseSchema)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;
const { teamId, settings } = input;
const {
documentVisibility,
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
} = settings;
const member = await prisma.teamMember.findFirst({
where: {
userId: user.id,
teamId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_TEAM,
},
},
});
if (!member) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update this team.',
});
}
return await prisma.teamGlobalSettings.upsert({
where: {
teamId,
},
create: {
teamId,
documentVisibility,
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
},
update: {
documentVisibility,
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
},
});
});
@@ -0,0 +1,23 @@
import { z } from 'zod';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import TeamGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/TeamGlobalSettingsSchema';
export const ZUpdateTeamDocumentSettingsRequestSchema = z.object({
teamId: z.number(),
settings: z.object({
documentVisibility: z
.nativeEnum(DocumentVisibility)
.optional()
.default(DocumentVisibility.EVERYONE),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).optional().default('en'),
includeSenderDetails: z.boolean().optional().default(false),
includeSigningCertificate: z.boolean().optional().default(true),
typedSignatureEnabled: z.boolean().optional().default(true),
uploadSignatureEnabled: z.boolean().optional().default(true),
drawSignatureEnabled: z.boolean().optional().default(true),
}),
});
export const ZUpdateTeamDocumentSettingsResponseSchema = TeamGlobalSettingsSchema;
@@ -19,12 +19,14 @@ import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod/modelS
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
ZDocumentMetaTypedSignatureEnabledSchema,
ZDocumentMetaUploadSignatureEnabledSchema,
} from '../document-router/schema';
import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema';
@@ -164,6 +166,8 @@ export const ZUpdateTemplateRequestSchema = z.object({
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
})
.optional(),
+3 -3
View File
@@ -9,8 +9,8 @@ import { isAdmin } from '@documenso/lib/utils/is-admin';
import type { TrpcContext } from './context';
// Can't import type from trpc-to-openapi because it breaks nextjs build, not sure why.
type OpenApiMeta = {
// Can't import type from trpc-to-openapi because it breaks build, not sure why.
export type TrpcRouteMeta = {
openapi?: {
enabled?: boolean;
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
@@ -30,7 +30,7 @@ type OpenApiMeta = {
} & Record<string, unknown>;
const t = initTRPC
.meta<OpenApiMeta>()
.meta<TrpcRouteMeta>()
.context<TrpcContext>()
.create({
transformer: SuperJSON,
@@ -0,0 +1,58 @@
import { Trans } from '@lingui/react/macro';
import { InfoIcon } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentSignatureSettingsTooltip = () => {
return (
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>
<Trans>Signature types</Trans>
</strong>
</h2>
<p>
<Trans>
The types of signatures that recipients are allowed to use when signing the document.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>
<Trans>Drawn</Trans>
</strong>
{' - '}
<Trans>A signature that is drawn using a mouse or stylus.</Trans>
</Trans>
</li>
<li>
<Trans>
<strong>
<Trans>Typed</Trans>
</strong>
{' - '}
<Trans>A signature that is typed using a keyboard.</Trans>
</Trans>
</li>
<li>
<Trans>
<strong>
<Trans>Uploaded</Trans>
</strong>
{' - '}
<Trans>A signature that is uploaded from a file.</Trans>
</Trans>
</li>
</ul>
</TooltipContent>
</Tooltip>
);
};
@@ -47,9 +47,8 @@ import { cn } from '../../lib/utils';
import { Alert, AlertDescription } from '../alert';
import { Button } from '../button';
import { Card, CardContent } from '../card';
import { Checkbox } from '../checkbox';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../command';
import { Form, FormControl, FormField, FormItem, FormLabel } from '../form/form';
import { Form } from '../form/form';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
@@ -94,7 +93,6 @@ export type AddFieldsFormProps = {
onSubmit: (_data: TAddFieldsFormSchema) => void;
canGoBack?: boolean;
isDocumentPdfLoaded: boolean;
typedSignatureEnabled?: boolean;
teamId?: number;
};
@@ -106,7 +104,6 @@ export const AddFieldsFormPartial = ({
onSubmit,
canGoBack = false,
isDocumentPdfLoaded,
typedSignatureEnabled,
teamId,
}: AddFieldsFormProps) => {
const { toast } = useToast();
@@ -137,7 +134,6 @@ export const AddFieldsFormPartial = ({
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
})),
typedSignatureEnabled: typedSignatureEnabled ?? false,
},
});
@@ -787,31 +783,6 @@ export const AddFieldsFormPartial = ({
)}
<Form {...form}>
<FormField
control={form.control}
name="typedSignatureEnabled"
render={({ field: { value, ...field } }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="typedSignatureEnabled"
checked={value}
onCheckedChange={(checked) => field.onChange(checked)}
disabled={form.formState.isSubmitting}
/>
</FormControl>
<FormLabel
htmlFor="typedSignatureEnabled"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Enable Typed Signatures</Trans>
</FormLabel>
</FormItem>
)}
/>
<div className="-mx-2 flex-1 overflow-y-auto px-2">
<fieldset disabled={isFieldsDisabled} className="my-2 grid grid-cols-3 gap-4">
<button
@@ -18,7 +18,6 @@ export const ZAddFieldsFormSchema = z.object({
fieldMeta: ZFieldMetaSchema,
}),
),
typedSignatureEnabled: z.boolean(),
});
export type TAddFieldsFormSchema = z.infer<typeof ZAddFieldsFormSchema>;
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useLingui } from '@lingui/react/macro';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
@@ -9,10 +10,12 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDocument } from '@documenso/lib/types/document';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import {
DocumentGlobalAuthAccessSelect,
DocumentGlobalAuthAccessTooltip,
@@ -39,7 +42,9 @@ import {
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip';
import { Combobox } from '../combobox';
import { Input } from '../input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
@@ -78,6 +83,8 @@ export const AddSettingsFormPartial = ({
currentTeamMemberRole,
onSubmit,
}: AddSettingsFormProps) => {
const { t } = useLingui();
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
@@ -90,6 +97,7 @@ export const AddSettingsFormPartial = ({
visibility: document.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
meta: {
timezone:
TIME_ZONES.find((timezone) => timezone === document.documentMeta?.timezone) ??
@@ -99,6 +107,7 @@ export const AddSettingsFormPartial = ({
?.value ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: document.documentMeta?.redirectUrl ?? '',
language: document.documentMeta?.language ?? 'en',
signatureTypes: extractTeamSignatureSettings(document.documentMeta),
},
},
});
@@ -189,9 +198,11 @@ export const AddSettingsFormPartial = ({
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
Controls the language for the document, including the language to be used
for email notifications, and the final certificate that is generated and
attached to the document.
<Trans>
Controls the language for the document, including the language to be used
for email notifications, and the final certificate that is generated and
attached to the document.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
@@ -314,6 +325,34 @@ export const AddSettingsFormPartial = ({
)}
/>
<FormField
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: t(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.dateFormat"
@@ -1,7 +1,9 @@
import { msg } from '@lingui/core/macro';
import { DocumentVisibility } from '@prisma/client';
import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
@@ -26,7 +28,10 @@ export const ZMapNegativeOneToUndefinedSchema = z
});
export const ZAddSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
title: z
.string()
.trim()
.min(1, { message: msg`Title cannot be empty`.id }),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
@@ -49,6 +54,9 @@ export const ZAddSettingsFormSchema = z.object({
.union([z.string(), z.enum(SUPPORTED_LANGUAGE_CODES)])
.optional()
.default('en'),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
}),
});
@@ -24,11 +24,14 @@ type ComboBoxOption<T = OptionValue> = {
type MultiSelectComboboxProps<T = OptionValue> = {
emptySelectionPlaceholder?: React.ReactElement | string;
enableClearAllButton?: boolean;
enableSearch?: boolean;
className?: string;
loading?: boolean;
inputPlaceholder?: MessageDescriptor;
onChange: (_values: T[]) => void;
options: ComboBoxOption<T>[];
selectedValues: T[];
testId?: string;
};
/**
@@ -41,11 +44,14 @@ type MultiSelectComboboxProps<T = OptionValue> = {
export function MultiSelectCombobox<T = OptionValue>({
emptySelectionPlaceholder = 'Select values...',
enableClearAllButton,
enableSearch = true,
className,
inputPlaceholder,
loading,
onChange,
options,
selectedValues,
testId,
}: MultiSelectComboboxProps<T>) {
const { _ } = useLingui();
@@ -59,8 +65,6 @@ export function MultiSelectCombobox<T = OptionValue>({
}
onChange(newSelectedOptions);
setOpen(false);
};
const selectedOptions = React.useMemo(() => {
@@ -107,7 +111,8 @@ export function MultiSelectCombobox<T = OptionValue>({
role="combobox"
disabled={loading}
aria-expanded={open}
className="w-[200px] px-3"
className={cn('w-[200px] px-3', className)}
data-testid={testId}
>
<AnimatePresence>
{loading ? (
@@ -146,7 +151,7 @@ export function MultiSelectCombobox<T = OptionValue>({
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={inputPlaceholder && _(inputPlaceholder)} />
{enableSearch && <CommandInput placeholder={inputPlaceholder && _(inputPlaceholder)} />}
<CommandEmpty>
<Trans>No value found.</Trans>
</CommandEmpty>
+587
View File
@@ -0,0 +1,587 @@
import * as React from 'react';
import { forwardRef, useEffect } from 'react';
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
import { X } from 'lucide-react';
import { cn } from '../lib/utils';
import { Command, CommandGroup, CommandItem, CommandList } from './command';
export interface Option {
value: string;
label: string;
disable?: boolean;
fixed?: boolean;
[key: string]: string | boolean | undefined;
}
interface GroupOption {
[key: string]: Option[];
}
interface MultipleSelectorProps {
value?: Option[];
defaultOptions?: Option[];
options?: Option[];
placeholder?: string;
loadingIndicator?: React.ReactNode;
emptyIndicator?: React.ReactNode;
delay?: number;
triggerSearchOnFocus?: boolean;
onSearch?: (value: string) => Promise<Option[]>;
onSearchSync?: (value: string) => Option[];
onChange?: (options: Option[]) => void;
maxSelected?: number;
onMaxSelected?: (maxLimit: number) => void;
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
groupBy?: string;
className?: string;
badgeClassName?: string;
selectFirstItem?: boolean;
creatable?: boolean;
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
'value' | 'placeholder' | 'disabled'
>;
hideClearAllButton?: boolean;
}
export interface MultipleSelectorRef {
selectedValue: Option[];
input: HTMLInputElement;
focus: () => void;
reset: () => void;
}
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
function transToGroupOption(options: Option[], groupBy?: string) {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
'': options,
};
}
const groupOption: GroupOption = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || '';
if (!groupOption[key]) {
groupOption[key] = [];
}
groupOption[key].push(option);
});
return groupOption;
}
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value));
}
return cloneOption;
}
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
for (const [, value] of Object.entries(groupOption)) {
if (value.some((option) => targetOption.find((p) => p.value === option.value))) {
return true;
}
}
return false;
}
/**
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
* So we create one and copy the `Empty` implementation from `cmdk`.
*
* @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
**/
const CommandEmpty = forwardRef<
HTMLDivElement,
React.ComponentProps<typeof CommandPrimitive.Empty>
>(({ className, ...props }, forwardedRef) => {
const render = useCommandState((state) => state.filtered.count === 0);
if (!render) return null;
return (
<div
ref={forwardedRef}
className={cn('px-2 py-4 text-center text-sm', className)}
cmdk-empty=""
role="presentation"
{...props}
/>
);
});
CommandEmpty.displayName = 'CommandEmpty';
const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorProps>(
(
{
value,
onChange,
placeholder,
defaultOptions: arrayDefaultOptions = [],
options: arrayOptions,
delay,
onSearch,
onSearchSync,
loadingIndicator,
emptyIndicator,
maxSelected = Number.MAX_SAFE_INTEGER,
onMaxSelected,
hidePlaceholderWhenSelected,
disabled,
groupBy,
className,
badgeClassName,
selectFirstItem = true,
creatable = false,
triggerSearchOnFocus = false,
commandProps,
inputProps,
hideClearAllButton = false,
}: MultipleSelectorProps,
ref: React.Ref<MultipleSelectorRef>,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [onScrollbar, setOnScrollbar] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
const [selected, setSelected] = React.useState<Option[]>(value || []);
const [options, setOptions] = React.useState<GroupOption>(
transToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = React.useState('');
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
React.useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
input: inputRef.current as HTMLInputElement,
focus: () => inputRef?.current?.focus(),
reset: () => setSelected([]),
}),
[selected],
);
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setOpen(false);
inputRef.current.blur();
}
};
const handleUnselect = React.useCallback(
(option: Option) => {
const newOptions = selected.filter((s) => s.value !== option.value);
setSelected(newOptions);
onChange?.(newOptions);
},
[onChange, selected],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (input.value === '' && selected.length > 0) {
const lastSelectOption = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (!lastSelectOption.fixed) {
handleUnselect(selected[selected.length - 1]);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === 'Escape') {
input.blur();
}
}
},
[handleUnselect, selected],
);
useEffect(() => {
if (open) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchend', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
};
}, [open]);
useEffect(() => {
if (value) {
setSelected(value);
}
}, [value]);
useEffect(() => {
/** If `onSearch` is provided, do not trigger options updated. */
if (!arrayOptions || onSearch) {
return;
}
const newOption = transToGroupOption(arrayOptions || [], groupBy);
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption);
}
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
useEffect(() => {
/** sync search */
const doSearchSync = () => {
const res = onSearchSync?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
};
const exec = () => {
if (!onSearchSync || !open) return;
if (triggerSearchOnFocus) {
doSearchSync();
}
if (debouncedSearchTerm) {
doSearchSync();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
useEffect(() => {
/** async search */
const doSearch = async () => {
setIsLoading(true);
const res = await onSearch?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
setIsLoading(false);
};
const exec = async () => {
if (!onSearch || !open) return;
if (triggerSearchOnFocus) {
await doSearch();
}
if (debouncedSearchTerm) {
await doSearch();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
const CreatableItem = () => {
if (!creatable) return undefined;
if (
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
selected.find((s) => s.value === inputValue)
) {
return undefined;
}
const Item = (
<CommandItem
value={inputValue}
className="cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value: string) => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, { value, label: value }];
setSelected(newOptions);
onChange?.(newOptions);
}}
>
{`Create "${inputValue}"`}
</CommandItem>
);
// For normal creatable
if (!onSearch && inputValue.length > 0) {
return Item;
}
// For async search creatable. avoid showing creatable item before loading at first.
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
return Item;
}
return undefined;
};
const EmptyItem = React.useCallback(() => {
if (!emptyIndicator) return undefined;
// For async search that showing emptyIndicator
if (onSearch && !creatable && Object.keys(options).length === 0) {
return (
<CommandItem value="-" disabled>
{emptyIndicator}
</CommandItem>
);
}
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
}, [creatable, emptyIndicator, onSearch, options]);
const selectables = React.useMemo<GroupOption>(
() => removePickedOption(options, selected),
[options, selected],
);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
if (commandProps?.filter) {
return commandProps.filter;
}
if (creatable) {
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}
// Using default filter in `cmdk`. We don&lsquo;t have to provide it.
return undefined;
}, [creatable, commandProps?.filter]);
return (
<Command
ref={dropdownRef}
{...commandProps}
onKeyDown={(e) => {
handleKeyDown(e);
commandProps?.onKeyDown?.(e);
}}
className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)}
shouldFilter={
commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch
} // When onSearch is provided, we don&lsquo;t want to filter the options. You can still override it.
filter={commandFilter()}
>
<div
className={cn(
'border-input focus-within:border-ring focus-within:ring-ring/20 relative min-h-[38px] rounded-lg border text-sm transition-shadow focus-within:outline-none focus-within:ring-[3px] has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50',
{
'p-1': selected.length !== 0,
'cursor-text': !disabled && selected.length !== 0,
},
!hideClearAllButton && 'pe-9',
className,
)}
onClick={() => {
if (disabled) return;
inputRef?.current?.focus();
}}
>
<div className="flex flex-wrap gap-1">
{selected.map((option) => {
return (
<div
key={option.value}
className={cn(
'animate-fadeIn border-border bg-background text-secondary-foreground hover:bg-background relative inline-flex h-7 cursor-default items-center rounded-md border pe-7 pl-2 ps-2 text-xs font-medium transition-all disabled:cursor-not-allowed disabled:opacity-50 data-[fixed]:pe-2',
badgeClassName,
)}
data-fixed={option.fixed}
data-disabled={disabled || undefined}
>
{option.label}
<button
className="text-muted-foreground/80 hover:text-foreground focus-visible:outline-ring/70 absolute -inset-y-px -end-px flex size-7 items-center justify-center rounded-e-lg border border-transparent p-0 outline-0 transition-colors focus-visible:outline focus-visible:outline-2"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}
aria-label="Remove"
>
<X size={14} strokeWidth={2} aria-hidden="true" />
</button>
</div>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
{...inputProps}
ref={inputRef}
value={inputValue}
disabled={disabled}
onValueChange={(value) => {
setInputValue(value);
inputProps?.onValueChange?.(value);
}}
onBlur={(event) => {
if (!onScrollbar) {
setOpen(false);
}
inputProps?.onBlur?.(event);
}}
onFocus={(event) => {
setOpen(true);
if (triggerSearchOnFocus) {
void onSearch?.(debouncedSearchTerm);
}
inputProps?.onFocus?.(event);
}}
placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder}
className={cn(
'placeholder:text-muted-foreground flex-1 bg-transparent outline-none disabled:cursor-not-allowed',
{
'w-full': hidePlaceholderWhenSelected,
'px-3 py-2': selected.length === 0,
'ml-1': selected.length !== 0,
},
inputProps?.className,
)}
/>
<button
type="button"
onClick={() => {
setSelected(selected.filter((s) => s.fixed));
onChange?.(selected.filter((s) => s.fixed));
}}
className={cn(
'text-muted-foreground/80 hover:text-foreground focus-visible:outline-ring/70 absolute end-0 top-0 flex size-9 items-center justify-center rounded-lg border border-transparent transition-colors focus-visible:outline focus-visible:outline-2',
(hideClearAllButton ||
disabled ||
selected.length < 1 ||
selected.filter((s) => s.fixed).length === selected.length) &&
'hidden',
)}
aria-label="Clear all"
>
<X size={16} strokeWidth={2} aria-hidden="true" />
</button>
</div>
</div>
<div className="relative">
<div
className={cn(
'border-input absolute top-2 z-10 w-full overflow-hidden rounded-lg border',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
!open && 'hidden',
)}
data-state={open ? 'open' : 'closed'}
>
{open && (
<CommandList
className="bg-popover text-popover-foreground shadow-lg shadow-black/5 outline-none"
onMouseLeave={() => {
setOnScrollbar(false);
}}
onMouseEnter={() => {
setOnScrollbar(true);
}}
onMouseUp={() => {
inputRef?.current?.focus();
}}
>
{isLoading ? (
<>{loadingIndicator}</>
) : (
<>
{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && <CommandItem value="-" className="hidden" />}
{Object.entries(selectables).map(([key, dropdowns]) => (
<CommandGroup key={key} heading={key} className="h-full overflow-auto">
<>
{dropdowns.map((option) => {
return (
<CommandItem
key={option.value}
value={option.value}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
'cursor-pointer',
option.disable && 'cursor-not-allowed opacity-50',
)}
>
{option.label}
</CommandItem>
);
})}
</>
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</div>
</Command>
);
},
);
MultipleSelector.displayName = 'MultipleSelector';
export { MultipleSelector };
@@ -1,3 +1,5 @@
import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures';
import { Point } from './point';
export class Canvas {
@@ -14,7 +16,7 @@ export class Canvas {
private lastVelocity = 0;
private readonly VELOCITY_FILTER_WEIGHT = 0.5;
private readonly DPI = 2;
private readonly DPI = SIGNATURE_CANVAS_DPI;
constructor(canvas: HTMLCanvasElement) {
this.$canvas = canvas;
@@ -0,0 +1,63 @@
import { Trans } from '@lingui/react/macro';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { cn } from '../../lib/utils';
export type SignaturePadColorPickerProps = {
selectedColor: string;
setSelectedColor: (color: string) => void;
className?: string;
};
export const SignaturePadColorPicker = ({
selectedColor,
setSelectedColor,
className,
}: SignaturePadColorPickerProps) => {
return (
<div className={cn('text-foreground absolute right-2 top-2 filter', className)}>
<Select defaultValue={selectedColor} onValueChange={(value) => setSelectedColor(value)}>
<SelectTrigger className="h-auto w-auto border-none p-0.5">
<SelectValue placeholder="" />
</SelectTrigger>
<SelectContent className="w-[100px]" align="end">
<SelectItem value="black">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-black shadow-sm" />
<Trans>Black</Trans>
</div>
</SelectItem>
<SelectItem value="red">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[red] shadow-sm" />
<Trans>Red</Trans>
</div>
</SelectItem>
<SelectItem value="blue">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[blue] shadow-sm" />
<Trans>Blue</Trans>
</div>
</SelectItem>
<SelectItem value="green">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[green] shadow-sm" />
<Trans>Green</Trans>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
);
};
@@ -0,0 +1,150 @@
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { Trans, useLingui } from '@lingui/react/macro';
import { motion } from 'framer-motion';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { Dialog, DialogClose, DialogContent, DialogFooter } from '@documenso/ui/primitives/dialog';
import { cn } from '../../lib/utils';
import { Button } from '../button';
import { SignaturePad } from './signature-pad';
import { SignatureRender } from './signature-render';
export type SignaturePadDialogProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
disabled?: boolean;
value?: string;
onChange: (_value: string) => void;
dialogConfirmText?: MessageDescriptor | string;
disableAnimation?: boolean;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
};
export const SignaturePadDialog = ({
className,
value,
onChange,
disabled = false,
disableAnimation = false,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
dialogConfirmText,
}: SignaturePadDialogProps) => {
const { i18n } = useLingui();
const [showSignatureModal, setShowSignatureModal] = useState(false);
const [signature, setSignature] = useState<string>(value ?? '');
return (
<div
className={cn(
'aspect-signature-pad bg-background relative block w-full select-none rounded-lg border',
className,
{
'pointer-events-none opacity-50': disabled,
},
)}
>
{value && (
<div className="inset-0 h-full w-full">
<SignatureRender value={value} />
</div>
)}
<motion.button
data-testid="signature-pad-dialog-button"
type="button"
disabled={disabled}
className="absolute inset-0 flex items-center justify-center bg-transparent"
onClick={() => setShowSignatureModal(true)}
whileHover="onHover"
>
{!value && !disableAnimation && (
<motion.svg
width="120"
height="120"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-muted-foreground/60"
variants={{
onHover: {
scale: 1.1,
transition: {
type: 'spring',
stiffness: 300,
damping: 12,
mass: 0.8,
restDelta: 0.001,
},
},
}}
>
<motion.path
d="M1.5 11H14.5M1.5 14C1.5 14 8.72 2 4.86938 2H4.875C2.01 2 1.97437 14.0694 8 6.51188V6.5C8 6.5 9 11.3631 11.5 7.52375V7.5C11.5 7.5 11.5 9 14.5 9"
stroke="currentColor"
strokeWidth="1.1"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{
pathLength: 1,
opacity: 1,
transition: {
pathLength: {
duration: 2,
ease: 'easeInOut',
},
opacity: { duration: 0.6 },
},
}}
/>
</motion.svg>
)}
</motion.button>
<Dialog open={showSignatureModal} onOpenChange={disabled ? undefined : setShowSignatureModal}>
<DialogContent hideClose={true} className="p-6 pt-4">
<SignaturePad
id="signature"
value={value}
className={className}
disabled={disabled}
onChange={({ value }) => setSignature(value)}
typedSignatureEnabled={typedSignatureEnabled}
uploadSignatureEnabled={uploadSignatureEnabled}
drawSignatureEnabled={drawSignatureEnabled}
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
disabled={!signature}
onClick={() => {
onChange(signature);
setShowSignatureModal(false);
}}
>
{dialogConfirmText ? (
parseMessageDescriptor(i18n._, dialogConfirmText)
) : (
<Trans>Next</Trans>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
@@ -0,0 +1,327 @@
import type { MouseEvent, PointerEvent, RefObject, TouchEvent } from 'react';
import { useMemo, useRef, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { Undo2 } from 'lucide-react';
import type { StrokeOptions } from 'perfect-freehand';
import { getStroke } from 'perfect-freehand';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import {
SIGNATURE_CANVAS_DPI,
SIGNATURE_MIN_COVERAGE_THRESHOLD,
} from '@documenso/lib/constants/signatures';
import { cn } from '../../lib/utils';
import { getSvgPathFromStroke } from './helper';
import { Point } from './point';
import { SignaturePadColorPicker } from './signature-pad-color-picker';
const checkSignatureValidity = (element: RefObject<HTMLCanvasElement>) => {
if (!element.current) {
return false;
}
const ctx = element.current.getContext('2d');
if (!ctx) {
return false;
}
const imageData = ctx.getImageData(0, 0, element.current.width, element.current.height);
const data = imageData.data;
let filledPixels = 0;
const totalPixels = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) filledPixels++;
}
const filledPercentage = filledPixels / totalPixels;
const isValid = filledPercentage > SIGNATURE_MIN_COVERAGE_THRESHOLD;
return isValid;
};
export type SignaturePadDrawProps = {
className?: string;
value: string;
onChange: (_signatureDataUrl: string) => void;
};
export const SignaturePadDraw = ({
className,
value,
onChange,
...props
}: SignaturePadDrawProps) => {
const $el = useRef<HTMLCanvasElement>(null);
const $imageData = useRef<ImageData | null>(null);
const $fileInput = useRef<HTMLInputElement>(null);
const [isPressed, setIsPressed] = useState(false);
const [lines, setLines] = useState<Point[][]>([]);
const [currentLine, setCurrentLine] = useState<Point[]>([]);
const [isSignatureValid, setIsSignatureValid] = useState<boolean | null>(null);
const [selectedColor, setSelectedColor] = useState('black');
const perfectFreehandOptions = useMemo(() => {
const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10;
return {
size,
thinning: 0.25,
streamline: 0.5,
smoothing: 0.5,
end: {
taper: size * 2,
},
} satisfies StrokeOptions;
}, []);
const onMouseDown = (event: MouseEvent | PointerEvent | TouchEvent) => {
if (event.cancelable) {
event.preventDefault();
}
setIsPressed(true);
const point = Point.fromEvent(event, SIGNATURE_CANVAS_DPI, $el.current);
setCurrentLine([point]);
};
const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => {
if (event.cancelable) {
event.preventDefault();
}
if (!isPressed) {
return;
}
const point = Point.fromEvent(event, SIGNATURE_CANVAS_DPI, $el.current);
const lastPoint = currentLine[currentLine.length - 1];
if (lastPoint && point.distanceTo(lastPoint) > 5) {
setCurrentLine([...currentLine, point]);
// Update the canvas here to draw the lines
if ($el.current) {
const ctx = $el.current.getContext('2d');
if (ctx) {
ctx.restore();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.fillStyle = selectedColor;
lines.forEach((line) => {
const pathData = new Path2D(
getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)),
);
ctx.fill(pathData);
});
const pathData = new Path2D(
getSvgPathFromStroke(getStroke([...currentLine, point], perfectFreehandOptions)),
);
ctx.fill(pathData);
}
}
}
};
const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addLine = true) => {
if (event.cancelable) {
event.preventDefault();
}
setIsPressed(false);
const point = Point.fromEvent(event, SIGNATURE_CANVAS_DPI, $el.current);
const newLines = [...lines];
if (addLine && currentLine.length > 0) {
newLines.push([...currentLine, point]);
setCurrentLine([]);
}
setLines(newLines);
if ($el.current && newLines.length > 0) {
const ctx = $el.current.getContext('2d');
if (ctx) {
ctx.restore();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.fillStyle = selectedColor;
newLines.forEach((line) => {
const pathData = new Path2D(
getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)),
);
ctx.fill(pathData);
});
const isValidSignature = checkSignatureValidity($el);
setIsSignatureValid(isValidSignature);
if (isValidSignature) {
onChange?.($el.current.toDataURL());
}
ctx.save();
}
}
};
const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => {
if (event.cancelable) {
event.preventDefault();
}
if ('buttons' in event && event.buttons === 1) {
onMouseDown(event);
}
};
const onMouseLeave = (event: MouseEvent | PointerEvent | TouchEvent) => {
if (event.cancelable) {
event.preventDefault();
}
if (isPressed) {
onMouseUp(event, true);
} else {
onMouseUp(event, false);
}
};
const onClearClick = () => {
if ($el.current) {
const ctx = $el.current.getContext('2d');
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
$imageData.current = null;
}
if ($fileInput.current) {
$fileInput.current.value = '';
}
onChange('');
setLines([]);
setCurrentLine([]);
setIsPressed(false);
};
const onUndoClick = () => {
if (lines.length === 0 || !$el.current) {
return;
}
const newLines = lines.slice(0, -1);
setLines(newLines);
// Clear and redraw the canvas
const ctx = $el.current.getContext('2d');
const { width, height } = $el.current;
ctx?.clearRect(0, 0, width, height);
if ($imageData.current) {
ctx?.putImageData($imageData.current, 0, 0);
}
newLines.forEach((line) => {
const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)));
ctx?.fill(pathData);
});
onChange?.($el.current.toDataURL());
};
unsafe_useEffectOnce(() => {
if ($el.current) {
$el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI;
$el.current.height = $el.current.clientHeight * SIGNATURE_CANVAS_DPI;
}
if ($el.current && value) {
const ctx = $el.current.getContext('2d');
const { width, height } = $el.current;
const img = new Image();
img.onload = () => {
ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height));
const defaultImageData = ctx?.getImageData(0, 0, width, height) || null;
$imageData.current = defaultImageData;
};
img.src = value;
}
});
return (
<div className={cn('h-full w-full', className)}>
<canvas
data-testid="signature-pad-draw"
ref={$el}
className={cn('h-full w-full', {
'dark:hue-rotate-180 dark:invert': selectedColor === 'black',
})}
style={{ touchAction: 'none' }}
onPointerMove={(event) => onMouseMove(event)}
onPointerDown={(event) => onMouseDown(event)}
onPointerUp={(event) => onMouseUp(event)}
onPointerLeave={(event) => onMouseLeave(event)}
onPointerEnter={(event) => onMouseEnter(event)}
{...props}
/>
<SignaturePadColorPicker selectedColor={selectedColor} setSelectedColor={setSelectedColor} />
<div className="absolute bottom-3 right-3 flex gap-2">
<button
type="button"
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
onClick={() => onClearClick()}
>
<Trans>Clear Signature</Trans>
</button>
</div>
{isSignatureValid === false && (
<div className="absolute bottom-4 left-4 flex gap-2">
<span className="text-destructive text-xs">
<Trans>Signature is too small</Trans>
</span>
</div>
)}
{isSignatureValid && lines.length > 0 && (
<div className="absolute bottom-4 left-4 flex gap-2">
<button
type="button"
title="undo"
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
onClick={onUndoClick}
>
<Undo2 className="h-4 w-4" />
<span className="sr-only">Undo</span>
</button>
</div>
)}
</div>
);
};
@@ -0,0 +1,29 @@
import { useState } from 'react';
import { cn } from '../../lib/utils';
export type SignaturePadTypeProps = {
className?: string;
value?: string;
onChange: (_value: string) => void;
};
export const SignaturePadType = ({ className, value, onChange }: SignaturePadTypeProps) => {
// Colors don't actually work for text.
const [selectedColor, setSelectedColor] = useState('black');
return (
<div className={cn('flex h-full w-full items-center justify-center', className)}>
<input
data-testid="signature-pad-type-input"
placeholder="Type your signature"
className="font-signature w-full bg-transparent px-4 text-center text-7xl text-black placeholder:text-4xl focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-white"
// style={{ color: selectedColor }}
value={value}
onChange={(event) => onChange(event.target.value.trimStart())}
/>
{/* <SignaturePadColorPicker selectedColor={selectedColor} setSelectedColor={setSelectedColor} /> */}
</div>
);
};
@@ -0,0 +1,166 @@
import { useRef } from 'react';
import { Trans } from '@lingui/react/macro';
import { motion } from 'framer-motion';
import { UploadCloudIcon } from 'lucide-react';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures';
import { cn } from '../../lib/utils';
const loadImage = async (file: File | undefined): Promise<HTMLImageElement> => {
if (!file) {
throw new Error('No file selected');
}
if (!file.type.startsWith('image/')) {
throw new Error('Invalid file type');
}
if (file.size > 5 * 1024 * 1024) {
throw new Error('Image size should be less than 5MB');
}
return new Promise((resolve, reject) => {
const img = new Image();
const objectUrl = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(objectUrl);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error('Failed to load image'));
};
img.src = objectUrl;
});
};
const loadImageOntoCanvas = (
image: HTMLImageElement,
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
): ImageData => {
const scale = Math.min((canvas.width * 0.8) / image.width, (canvas.height * 0.8) / image.height);
const x = (canvas.width - image.width * scale) / 2;
const y = (canvas.height - image.height * scale) / 2;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(image, x, y, image.width * scale, image.height * scale);
ctx.restore();
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
return imageData;
};
export type SignaturePadUploadProps = {
className?: string;
value: string;
onChange: (_signatureDataUrl: string) => void;
};
export const SignaturePadUpload = ({
className,
value,
onChange,
...props
}: SignaturePadUploadProps) => {
const $el = useRef<HTMLCanvasElement>(null);
const $imageData = useRef<ImageData | null>(null);
const $fileInput = useRef<HTMLInputElement>(null);
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
try {
const img = await loadImage(event.target.files?.[0]);
if (!$el.current) return;
const ctx = $el.current.getContext('2d');
if (!ctx) return;
$imageData.current = loadImageOntoCanvas(img, $el.current, ctx);
onChange?.($el.current.toDataURL());
} catch (error) {
console.error(error);
}
};
unsafe_useEffectOnce(() => {
// Todo: Not really sure if this is required for uploaded images.
if ($el.current) {
$el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI;
$el.current.height = $el.current.clientHeight * SIGNATURE_CANVAS_DPI;
}
if ($el.current && value) {
const ctx = $el.current.getContext('2d');
const { width, height } = $el.current;
const img = new Image();
img.onload = () => {
ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height));
const defaultImageData = ctx?.getImageData(0, 0, width, height) || null;
$imageData.current = defaultImageData;
};
img.src = value;
}
});
return (
<div className={cn('relative h-full w-full', className)}>
<canvas
data-testid="signature-pad-upload"
ref={$el}
className="h-full w-full dark:hue-rotate-180 dark:invert"
style={{ touchAction: 'none' }}
{...props}
/>
<input
ref={$fileInput}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
/>
<motion.button
className="absolute inset-0 flex h-full w-full items-center justify-center"
initial="initial"
animate="animate"
whileHover="hover"
onClick={() => $fileInput.current?.click()}
>
{!value && (
<motion.div>
<div className="text-muted-foreground flex flex-col items-center justify-center">
<div className="flex flex-col items-center">
<UploadCloudIcon className="h-8 w-8" />
<span className="text-lg font-semibold">
<Trans>Upload Signature</Trans>
</span>
</div>
</div>
</motion.div>
)}
</motion.button>
</div>
);
};
@@ -1,591 +1,199 @@
import type { HTMLAttributes, MouseEvent, PointerEvent, TouchEvent } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { Undo2, Upload } from 'lucide-react';
import type { StrokeOptions } from 'perfect-freehand';
import { getStroke } from 'perfect-freehand';
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { SignatureIcon } from '../../icons/signature';
import { cn } from '../../lib/utils';
import { getSvgPathFromStroke } from './helper';
import { Point } from './point';
import { SignaturePadDraw } from './signature-pad-draw';
import { SignaturePadType } from './signature-pad-type';
import { SignaturePadUpload } from './signature-pad-upload';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './signature-tabs';
const DPI = 2;
const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,');
const loadImage = async (file: File | undefined): Promise<HTMLImageElement> => {
if (!file) {
throw new Error('No file selected');
}
if (!file.type.startsWith('image/')) {
throw new Error('Invalid file type');
}
if (file.size > 5 * 1024 * 1024) {
throw new Error('Image size should be less than 5MB');
}
return new Promise((resolve, reject) => {
const img = new Image();
const objectUrl = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(objectUrl);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error('Failed to load image'));
};
img.src = objectUrl;
});
};
const loadImageOntoCanvas = (
image: HTMLImageElement,
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
): ImageData => {
const scale = Math.min((canvas.width * 0.8) / image.width, (canvas.height * 0.8) / image.height);
const x = (canvas.width - image.width * scale) / 2;
const y = (canvas.height - image.height * scale) / 2;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(image, x, y, image.width * scale, image.height * scale);
ctx.restore();
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
return imageData;
export type SignaturePadValue = {
type: DocumentSignatureType;
value: string;
};
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
onChange?: (_signatureDataUrl: string | null) => void;
containerClassName?: string;
value?: string;
onChange?: (_value: SignaturePadValue) => void;
disabled?: boolean;
allowTypedSignature?: boolean;
defaultValue?: string;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
onValidityChange?: (isValid: boolean) => void;
minCoverageThreshold?: number;
};
export const SignaturePad = ({
className,
containerClassName,
defaultValue,
value = '',
onChange,
disabled = false,
allowTypedSignature,
onValidityChange,
minCoverageThreshold = 0.01,
...props
typedSignatureEnabled = true,
uploadSignatureEnabled = true,
drawSignatureEnabled = true,
}: SignaturePadProps) => {
const $el = useRef<HTMLCanvasElement>(null);
const $imageData = useRef<ImageData | null>(null);
const $fileInput = useRef<HTMLInputElement>(null);
const [imageSignature, setImageSignature] = useState(isBase64Image(value) ? value : '');
const [drawSignature, setDrawSignature] = useState(isBase64Image(value) ? value : '');
const [typedSignature, setTypedSignature] = useState(isBase64Image(value) ? '' : value);
const [isPressed, setIsPressed] = useState(false);
const [lines, setLines] = useState<Point[][]>([]);
const [currentLine, setCurrentLine] = useState<Point[]>([]);
const [selectedColor, setSelectedColor] = useState('black');
const [typedSignature, setTypedSignature] = useState(
defaultValue && !isBase64Image(defaultValue) ? defaultValue : '',
/**
* This is cooked.
*
* Get the first enabled tab that has a signature if possible, otherwise just get
* the first enabled tab.
*/
const [tab, setTab] = useState(
((): 'draw' | 'text' | 'image' => {
// First passthrough to check to see if there's a signature for a given tab.
if (drawSignatureEnabled && drawSignature) {
return 'draw';
}
if (typedSignatureEnabled && typedSignature) {
return 'text';
}
if (uploadSignatureEnabled && imageSignature) {
return 'image';
}
// Second passthrough to just select the first avaliable tab.
if (drawSignatureEnabled) {
return 'draw';
}
if (typedSignatureEnabled) {
return 'text';
}
if (uploadSignatureEnabled) {
return 'image';
}
throw new Error('No signature enabled');
})(),
);
const perfectFreehandOptions = useMemo(() => {
const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10;
const onImageSignatureChange = (value: string) => {
setImageSignature(value);
return {
size,
thinning: 0.25,
streamline: 0.5,
smoothing: 0.5,
end: {
taper: size * 2,
},
} satisfies StrokeOptions;
}, []);
const checkSignatureValidity = () => {
if ($el.current) {
const ctx = $el.current.getContext('2d');
if (ctx) {
const imageData = ctx.getImageData(0, 0, $el.current.width, $el.current.height);
const data = imageData.data;
let filledPixels = 0;
const totalPixels = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) filledPixels++;
}
const filledPercentage = filledPixels / totalPixels;
const isValid = filledPercentage > minCoverageThreshold;
onValidityChange?.(isValid);
return isValid;
}
}
onChange?.({
type: DocumentSignatureType.UPLOAD,
value,
});
};
const onMouseDown = (event: MouseEvent | PointerEvent | TouchEvent) => {
if (event.cancelable) {
event.preventDefault();
}
const onDrawSignatureChange = (value: string) => {
setDrawSignature(value);
setIsPressed(true);
if (typedSignature) {
setTypedSignature('');
if ($el.current) {
const ctx = $el.current.getContext('2d');
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
}
}
const point = Point.fromEvent(event, DPI, $el.current);
setCurrentLine([point]);
onChange?.({
type: DocumentSignatureType.DRAW,
value,
});
};
const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => {
if (event.cancelable) {
event.preventDefault();
}
const onTypedSignatureChange = (value: string) => {
setTypedSignature(value);
if (!isPressed) {
onChange?.({
type: DocumentSignatureType.TYPE,
value,
});
};
const onTabChange = (value: 'draw' | 'text' | 'image') => {
if (disabled) {
return;
}
const point = Point.fromEvent(event, DPI, $el.current);
const lastPoint = currentLine[currentLine.length - 1];
setTab(value);
if (lastPoint && point.distanceTo(lastPoint) > 5) {
setCurrentLine([...currentLine, point]);
// Update the canvas here to draw the lines
if ($el.current) {
const ctx = $el.current.getContext('2d');
if (ctx) {
ctx.restore();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.fillStyle = selectedColor;
lines.forEach((line) => {
const pathData = new Path2D(
getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)),
);
ctx.fill(pathData);
});
const pathData = new Path2D(
getSvgPathFromStroke(getStroke([...currentLine, point], perfectFreehandOptions)),
);
ctx.fill(pathData);
}
}
}
match(value)
.with('draw', () => {
onDrawSignatureChange(drawSignature);
})
.with('text', () => {
onTypedSignatureChange(typedSignature);
})
.with('image', () => {
onImageSignatureChange(imageSignature);
})
.exhaustive();
};
const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addLine = true) => {
if (event.cancelable) {
event.preventDefault();
}
setIsPressed(false);
const point = Point.fromEvent(event, DPI, $el.current);
const newLines = [...lines];
if (addLine && currentLine.length > 0) {
newLines.push([...currentLine, point]);
setCurrentLine([]);
}
setLines(newLines);
if ($el.current && newLines.length > 0) {
const ctx = $el.current.getContext('2d');
if (ctx) {
ctx.restore();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.fillStyle = selectedColor;
newLines.forEach((line) => {
const pathData = new Path2D(
getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)),
);
ctx.fill(pathData);
});
const isValidSignature = checkSignatureValidity();
if (isValidSignature) {
onChange?.($el.current.toDataURL());
}
ctx.save();
}
}
};
const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => {
if (event.cancelable) {
event.preventDefault();
}
if ('buttons' in event && event.buttons === 1) {
onMouseDown(event);
}
};
const onMouseLeave = (event: MouseEvent | PointerEvent | TouchEvent) => {
if (event.cancelable) {
event.preventDefault();
}
if (isPressed) {
onMouseUp(event, true);
} else {
onMouseUp(event, false);
}
};
const onClearClick = () => {
if ($el.current) {
const ctx = $el.current.getContext('2d');
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
$imageData.current = null;
}
if ($fileInput.current) {
$fileInput.current.value = '';
}
onChange?.(null);
setTypedSignature('');
setLines([]);
setCurrentLine([]);
setIsPressed(false);
};
const renderTypedSignature = () => {
if ($el.current && typedSignature) {
const ctx = $el.current.getContext('2d');
if (ctx) {
const canvasWidth = $el.current.width;
const canvasHeight = $el.current.height;
const fontFamily = 'Caveat';
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = selectedColor;
// Calculate the desired width (25ch)
const desiredWidth = canvasWidth * 0.85; // 85% of canvas width
// Start with a base font size
let fontSize = 18;
ctx.font = `${fontSize}px ${fontFamily}`;
// Measure 10 characters and calculate scale factor
const characterWidth = ctx.measureText('m'.repeat(10)).width;
const scaleFactor = desiredWidth / characterWidth;
// Apply scale factor to font size
fontSize = fontSize * scaleFactor;
// Adjust font size if it exceeds canvas width
ctx.font = `${fontSize}px ${fontFamily}`;
const textWidth = ctx.measureText(typedSignature).width;
if (textWidth > desiredWidth) {
fontSize = fontSize * (desiredWidth / textWidth);
}
// Set final font and render text
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.fillText(typedSignature, canvasWidth / 2, canvasHeight / 2);
}
}
};
const handleTypedSignatureChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
// Deny input while drawing.
if (isPressed) {
return;
}
if (lines.length > 0) {
setLines([]);
setCurrentLine([]);
}
setTypedSignature(newValue);
if ($el.current) {
const ctx = $el.current.getContext('2d');
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
}
if (newValue.trim() !== '') {
onChange?.(newValue);
onValidityChange?.(true);
} else {
onChange?.(null);
onValidityChange?.(false);
}
};
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
try {
const img = await loadImage(event.target.files?.[0]);
if (!$el.current) return;
const ctx = $el.current.getContext('2d');
if (!ctx) return;
$imageData.current = loadImageOntoCanvas(img, $el.current, ctx);
onChange?.($el.current.toDataURL());
setLines([]);
setCurrentLine([]);
setTypedSignature('');
} catch (error) {
console.error(error);
}
};
useEffect(() => {
if (typedSignature.trim() !== '' && !isBase64Image(typedSignature)) {
renderTypedSignature();
onChange?.(typedSignature);
}
}, [typedSignature, selectedColor]);
const onUndoClick = () => {
if (lines.length === 0 && typedSignature.length === 0) {
return;
}
if (typedSignature.length > 0) {
const newTypedSignature = typedSignature.slice(0, -1);
setTypedSignature(newTypedSignature);
// You might want to call onChange here as well
// onChange?.(newTypedSignature);
} else {
const newLines = lines.slice(0, -1);
setLines(newLines);
// Clear and redraw the canvas
if ($el.current) {
const ctx = $el.current.getContext('2d');
const { width, height } = $el.current;
ctx?.clearRect(0, 0, width, height);
if (typeof defaultValue === 'string' && $imageData.current) {
ctx?.putImageData($imageData.current, 0, 0);
}
newLines.forEach((line) => {
const pathData = new Path2D(
getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)),
);
ctx?.fill(pathData);
});
onChange?.($el.current.toDataURL());
}
}
};
useEffect(() => {
if ($el.current) {
$el.current.width = $el.current.clientWidth * DPI;
$el.current.height = $el.current.clientHeight * DPI;
}
if (defaultValue && typedSignature) {
renderTypedSignature();
}
}, []);
unsafe_useEffectOnce(() => {
if ($el.current && typeof defaultValue === 'string') {
const ctx = $el.current.getContext('2d');
const { width, height } = $el.current;
const img = new Image();
img.onload = () => {
ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height));
const defaultImageData = ctx?.getImageData(0, 0, width, height) || null;
$imageData.current = defaultImageData;
};
img.src = defaultValue;
}
});
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled) {
return null;
}
return (
<div
className={cn('relative block select-none', containerClassName, {
'pointer-events-none opacity-50': disabled,
<Tabs
defaultValue={tab}
className={cn({
'pointer-events-none': disabled,
})}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image')}
>
<canvas
data-testid="signature-pad"
ref={$el}
className={cn(
'relative block',
{
'dark:hue-rotate-180 dark:invert': selectedColor === 'black',
},
className,
<TabsList>
{drawSignatureEnabled && (
<TabsTrigger value="draw">
<SignatureIcon className="mr-2 size-4" />
Draw
</TabsTrigger>
)}
style={{ touchAction: 'none' }}
onPointerMove={(event) => onMouseMove(event)}
onPointerDown={(event) => onMouseDown(event)}
onPointerUp={(event) => onMouseUp(event)}
onPointerLeave={(event) => onMouseLeave(event)}
onPointerEnter={(event) => onMouseEnter(event)}
{...props}
/>
{allowTypedSignature && (
<div
className={cn('ml-4 pb-1', {
'ml-10': lines.length > 0 || typedSignature.length > 0,
})}
>
<Input
placeholder="Type your signature"
className="w-1/2 border-none p-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
value={typedSignature}
onChange={handleTypedSignatureChange}
/>
</div>
)}
{typedSignatureEnabled && (
<TabsTrigger value="text">
<KeyboardIcon className="mr-2 size-4" />
Type
</TabsTrigger>
)}
<div className="text-foreground absolute left-3 top-3 filter">
<div
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground flex cursor-pointer flex-row gap-2 rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
onClick={() => $fileInput.current?.click()}
>
<Input
ref={$fileInput}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={disabled}
/>
<Upload className="h-4 w-4" />
<span>
<Trans>Upload Signature</Trans>
</span>
</div>
</div>
{uploadSignatureEnabled && (
<TabsTrigger value="image">
<UploadCloudIcon className="mr-2 size-4" />
Upload
</TabsTrigger>
)}
</TabsList>
<div className="text-foreground absolute right-2 top-2 filter">
<Select defaultValue={selectedColor} onValueChange={(value) => setSelectedColor(value)}>
<SelectTrigger className="h-auto w-auto border-none p-0.5">
<SelectValue placeholder="" />
</SelectTrigger>
<TabsContent
value="draw"
className="border-border aspect-signature-pad dark:bg-background relative flex items-center justify-center rounded-md border bg-neutral-50 text-center"
>
<SignaturePadDraw
className="h-full w-full"
onChange={onDrawSignatureChange}
value={drawSignature}
/>
</TabsContent>
<SelectContent className="w-[100px]" align="end">
<SelectItem value="black">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-black shadow-sm" />
<Trans>Black</Trans>
</div>
</SelectItem>
<TabsContent
value="text"
className="border-border aspect-signature-pad dark:bg-background relative flex items-center justify-center rounded-md border bg-neutral-50 text-center"
>
<SignaturePadType value={typedSignature} onChange={onTypedSignatureChange} />
</TabsContent>
<SelectItem value="red">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[red] shadow-sm" />
<Trans>Red</Trans>
</div>
</SelectItem>
<SelectItem value="blue">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[blue] shadow-sm" />
<Trans>Blue</Trans>
</div>
</SelectItem>
<SelectItem value="green">
<div className="text-muted-foreground flex items-center text-[0.688rem]">
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[green] shadow-sm" />
<Trans>Green</Trans>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="absolute bottom-3 right-3 flex gap-2">
<button
type="button"
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
onClick={() => onClearClick()}
>
<Trans>Clear Signature</Trans>
</button>
</div>
{(lines.length > 0 || typedSignature.length > 0) && (
<div className="absolute bottom-4 left-4 flex gap-2">
<button
type="button"
title="undo"
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
onClick={onUndoClick}
>
<Undo2 className="h-4 w-4" />
<span className="sr-only">Undo</span>
</button>
</div>
)}
</div>
<TabsContent
value="image"
className={cn(
'border-border aspect-signature-pad dark:bg-background relative rounded-md border bg-neutral-50',
{
'bg-white': imageSignature,
},
)}
>
<SignaturePadUpload value={imageSignature} onChange={onImageSignatureChange} />
</TabsContent>
</Tabs>
);
};
@@ -0,0 +1,128 @@
import { useEffect, useRef } from 'react';
import { SIGNATURE_CANVAS_DPI, isBase64Image } from '@documenso/lib/constants/signatures';
import { cn } from '../../lib/utils';
export type SignatureRenderProps = {
className?: string;
value: string;
};
/**
* Renders a typed, uploaded or drawn signature.
*/
export const SignatureRender = ({ className, value }: SignatureRenderProps) => {
const $el = useRef<HTMLCanvasElement>(null);
const $imageData = useRef<ImageData | null>(null);
const renderTypedSignature = () => {
if (!$el.current) {
return;
}
const ctx = $el.current.getContext('2d');
if (!ctx) {
return;
}
ctx.clearRect(0, 0, $el.current.width, $el.current.height);
const canvasWidth = $el.current.width;
const canvasHeight = $el.current.height;
const fontFamily = 'Caveat';
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// ctx.fillStyle = selectedColor; // Todo: Color not implemented...
// Calculate the desired width (25ch)
const desiredWidth = canvasWidth * 0.85; // 85% of canvas width
// Start with a base font size
let fontSize = 18;
ctx.font = `${fontSize}px ${fontFamily}`;
// Measure 10 characters and calculate scale factor
const characterWidth = ctx.measureText('m'.repeat(10)).width;
const scaleFactor = desiredWidth / characterWidth;
// Apply scale factor to font size
fontSize = fontSize * scaleFactor;
// Adjust font size if it exceeds canvas width
ctx.font = `${fontSize}px ${fontFamily}`;
const textWidth = ctx.measureText(value).width;
if (textWidth > desiredWidth) {
fontSize = fontSize * (desiredWidth / textWidth);
}
// Set final font and render text
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.fillText(value, canvasWidth / 2, canvasHeight / 2);
};
const renderImageSignature = () => {
if (!$el.current || typeof value !== 'string') {
return;
}
const ctx = $el.current.getContext('2d');
if (!ctx) {
return;
}
ctx.clearRect(0, 0, $el.current.width, $el.current.height);
const { width, height } = $el.current;
const img = new Image();
img.onload = () => {
// Calculate the scaled dimensions while maintaining aspect ratio
const scale = Math.min(width / img.width, height / img.height);
const scaledWidth = img.width * scale;
const scaledHeight = img.height * scale;
// Calculate center position
const x = (width - scaledWidth) / 2;
const y = (height - scaledHeight) / 2;
ctx?.drawImage(img, x, y, scaledWidth, scaledHeight);
const defaultImageData = ctx?.getImageData(0, 0, width, height) || null;
$imageData.current = defaultImageData;
};
img.src = value;
};
useEffect(() => {
if ($el.current) {
$el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI;
$el.current.height = $el.current.clientHeight * SIGNATURE_CANVAS_DPI;
}
}, []);
useEffect(() => {
if (isBase64Image(value)) {
renderImageSignature();
} else {
renderTypedSignature();
}
}, [value]);
return (
<canvas
ref={$el}
className={cn('h-full w-full dark:hue-rotate-180 dark:invert', className)}
style={{ touchAction: 'none' }}
/>
);
};
@@ -0,0 +1,147 @@
import * as React from 'react';
import { motion } from 'framer-motion';
import { cn } from '../../lib/utils';
interface TabsProps extends React.HTMLAttributes<HTMLDivElement> {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
children: React.ReactNode;
}
interface TabsContextValue {
value: string;
onValueChange: (value: string) => void;
}
const TabsContext = React.createContext<TabsContextValue | undefined>(undefined);
function useTabs() {
const context = React.useContext(TabsContext);
if (!context) {
throw new Error('useTabs must be used within a Tabs provider');
}
return context;
}
export function Tabs({
defaultValue,
value,
onValueChange,
children,
className,
...props
}: TabsProps) {
const [tabValue, setTabValue] = React.useState(defaultValue || '');
const handleValueChange = React.useCallback(
(newValue: string) => {
setTabValue(newValue);
onValueChange?.(newValue);
},
[onValueChange],
);
const contextValue = React.useMemo(
() => ({
value: value !== undefined ? value : tabValue,
onValueChange: handleValueChange,
}),
[value, tabValue, handleValueChange],
);
return (
<TabsContext.Provider value={contextValue}>
<div className={cn('w-full', className)} {...props}>
{children}
</div>
</TabsContext.Provider>
);
}
interface TabsListProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export function TabsList({ children, className, ...props }: TabsListProps) {
return (
<div
className={cn('border-border flex flex-wrap border-b', className)}
role="tabslist"
{...props}
>
{children}
</div>
);
}
interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
value: string;
icon?: React.ReactNode;
children: React.ReactNode;
}
export function TabsTrigger({ value, icon, children, className, ...props }: TabsTriggerProps) {
const { value: selectedValue, onValueChange } = useTabs();
const isSelected = selectedValue === value;
return (
<button
role="tab"
type="button"
aria-selected={isSelected}
data-state={isSelected ? 'active' : 'inactive'}
onClick={() => onValueChange(value)}
className={cn(
'relative flex items-center px-4 py-3 text-sm font-medium transition-all',
'focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
isSelected ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
className,
)}
{...props}
>
{icon && <span className="flex items-center">{icon}</span>}
{children}
{isSelected && (
<motion.div
layoutId="activeTabIndicator"
className="bg-foreground/40 absolute bottom-0 left-0 h-0.5 w-full rounded-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
type: 'spring',
stiffness: 500,
damping: 50,
}}
/>
)}
</button>
);
}
interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
children: React.ReactNode;
}
export function TabsContent({ value, children, className, ...props }: TabsContentProps) {
const { value: selectedValue } = useTabs();
const isSelected = selectedValue === value;
if (!isSelected) {
return null;
}
return (
<div
role="tabpanel"
data-state={isSelected ? 'active' : 'inactive'}
className={cn('mt-4', className)}
{...props}
>
{children}
</div>
);
}
@@ -54,10 +54,9 @@ import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitive
import { useToast } from '@documenso/ui/primitives/use-toast';
import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors';
import { Checkbox } from '../checkbox';
import type { FieldFormType } from '../document-flow/add-fields';
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
import { Form, FormControl, FormField, FormItem, FormLabel } from '../form/form';
import { Form } from '../form/form';
import { useStep } from '../stepper';
import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
@@ -74,7 +73,6 @@ export type AddTemplateFieldsFormProps = {
fields: Field[];
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
teamId?: number;
typedSignatureEnabled?: boolean;
};
export const AddTemplateFieldsFormPartial = ({
@@ -84,7 +82,6 @@ export const AddTemplateFieldsFormPartial = ({
fields,
onSubmit,
teamId,
typedSignatureEnabled,
}: AddTemplateFieldsFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@@ -119,7 +116,6 @@ export const AddTemplateFieldsFormPartial = ({
recipients.find((recipient) => recipient.id === field.recipientId)?.token ?? '',
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
})),
typedSignatureEnabled: typedSignatureEnabled ?? false,
},
});
@@ -483,12 +479,6 @@ export const AddTemplateFieldsFormPartial = ({
form.setValue('fields', updatedFields);
};
const isTypedSignatureEnabled = form.watch('typedSignatureEnabled');
const handleTypedSignatureChange = (value: boolean) => {
form.setValue('typedSignatureEnabled', value, { shouldDirty: true });
};
return (
<>
{showAdvancedSettings && currentField ? (
@@ -662,31 +652,6 @@ export const AddTemplateFieldsFormPartial = ({
)}
<Form {...form}>
<FormField
control={form.control}
name="typedSignatureEnabled"
render={({ field: { value, ...field } }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="typedSignatureEnabled"
checked={value}
onCheckedChange={(checked) => field.onChange(checked)}
disabled={form.formState.isSubmitting}
/>
</FormControl>
<FormLabel
htmlFor="typedSignatureEnabled"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Enable Typed Signatures</Trans>
</FormLabel>
</FormItem>
)}
/>
<div className="-mx-2 flex-1 overflow-y-auto px-2">
<fieldset className="my-2 grid grid-cols-3 gap-4">
<button
@@ -20,7 +20,6 @@ export const ZAddTemplateFieldsFormSchema = z.object({
fieldMeta: ZFieldMetaSchema,
}),
),
typedSignatureEnabled: z.boolean(),
});
export type TAddTemplateFieldsFormSchema = z.infer<typeof ZAddTemplateFieldsFormSchema>;
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentDistributionMethod, type Field, type Recipient } from '@prisma/client';
@@ -10,12 +10,16 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_DISTRIBUTION_METHODS } from '@documenso/lib/constants/document';
import {
DOCUMENT_DISTRIBUTION_METHODS,
DOCUMENT_SIGNATURE_TYPES,
} from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import type { TTemplate } from '@documenso/lib/types/template';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import type { TDocumentMetaDateFormat } from '@documenso/trpc/server/document-router/schema';
import {
DocumentGlobalAuthAccessSelect,
@@ -46,6 +50,7 @@ import {
} from '@documenso/ui/primitives/form/form';
import { DocumentEmailCheckboxes } from '../../components/document/document-email-checkboxes';
import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip';
import { Combobox } from '../combobox';
import {
DocumentFlowFormContainerActions,
@@ -57,6 +62,7 @@ import {
import { ShowFieldItem } from '../document-flow/show-field-item';
import type { DocumentFlowStep } from '../document-flow/types';
import { Input } from '../input';
import { MultiSelectCombobox } from '../multi-select-combobox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper';
import { Textarea } from '../textarea';
@@ -85,7 +91,7 @@ export const AddTemplateSettingsFormPartial = ({
currentTeamMemberRole,
onSubmit,
}: AddTemplateSettingsFormProps) => {
const { _ } = useLingui();
const { t, i18n } = useLingui();
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
@@ -111,6 +117,7 @@ export const AddTemplateSettingsFormPartial = ({
redirectUrl: template.templateMeta?.redirectUrl ?? '',
language: template.templateMeta?.language ?? 'en',
emailSettings: ZDocumentEmailSettingsSchema.parse(template?.templateMeta?.emailSettings),
signatureTypes: extractTeamSignatureSettings(template?.templateMeta),
},
},
});
@@ -314,7 +321,7 @@ export const AddTemplateSettingsFormPartial = ({
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{_(description)}
{i18n._(description)}
</SelectItem>
),
)}
@@ -325,6 +332,34 @@ export const AddTemplateSettingsFormPartial = ({
)}
/>
<FormField
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: t(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isEnterprise && (
<FormField
control={form.control}
@@ -1,8 +1,10 @@
import { msg } from '@lingui/core/macro';
import { DocumentDistributionMethod } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
@@ -49,6 +51,9 @@ export const ZAddTemplateSettingsFormSchema = z.object({
.optional()
.default('en'),
emailSettings: ZDocumentEmailSettingsSchema,
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
}),
});