Compare commits

...

32 Commits

Author SHA1 Message Date
Mythie
414b1b7287 v1.6.0-rc.3 2024-07-19 13:27:01 +10:00
Mythie
7ac4cd25e7 fix: update template limits 2024-07-19 13:16:26 +10:00
Mythie
40428f40e1 fix: only send cancelled emails to recipients who have received the document 2024-07-19 12:41:04 +10:00
Mythie
3a57f97218 chore: update failing tests 2024-07-19 11:32:49 +10:00
Timur Ercan
6208a9754f Chore/vial-blog (#1231)
vial 21 cfr blogpost
2024-07-18 16:05:29 +02:00
Catalin Pit
7b5c57e8af chore: add more field types (#1141)
Adds a number of new field types and capabilities to existing fields.

A massive change with far too many moving pieces to document in a single commit.
2024-07-18 23:45:44 +10:00
Mythie
a3ee732a9b v1.6.0-rc.2 2024-07-15 10:50:49 +10:00
Lucas Smith
c3035dbd15 feat: add external id to documents and templates (#1227)
## Description

Adds the external ID column to documents and templates with an option to
configure it in the API or UI.

External ID's can be used to link a document or template to an external
system and identify them via webhooks, etc.
2024-07-13 16:45:09 +10:00
Catalin Pit
7f5b27372f feat: resend document via API (#1226)
Allow users to re-send documents via the API.
2024-07-12 21:03:52 +10:00
Rene Steen
b0c081683f feat: allow anonymous smtp authentication (#1204)
Introduces the ability to use anonymous SMTP authentication where no username or password is provided.

Also introduces a new flag to disable TLS avoiding cases also where STARTTLS is used despite `secure` being
set to `false`
2024-07-09 10:39:59 +10:00
Mythie
6b5e4da424 v1.6.0-rc.1 2024-07-05 14:24:40 +10:00
Ephraim Duncan
cb892bcbb2 feat: add user conversion count to admin panel (#1150)
Displays the count of users who signed up after signing at least one
document in the admin panel
2024-07-05 14:02:22 +10:00
Ephraim Duncan
a757ab2303 feat: move template to team (#1217)
Allows users to move templates from their personal account to a team
account.
2024-07-05 13:20:27 +10:00
Ephraim Duncan
2c320e8b92 fix: use team avatar everywhere (#1220)
Expands team avatar support across various components and pages of
the application.
2024-07-05 13:05:22 +10:00
Ephraim Duncan
2eee2b4d2a feat: send custom email to signers of direct template documents (#1215)
Introduces customization options for the document completion email
template to allow for custom email bodies and subjects for documents
created from direct templates.


## Testing Performed
- Verified correct rendering of custom email subject and body for direct
template documents
- Verified the all other completed email types are sent correctly
2024-07-05 13:03:22 +10:00
Timur Ercan
06b1d4835e Update signing-an-nda-faster-with-documenso.mdx
fix nda article after git switch
2024-07-04 13:57:57 +02:00
Timur Ercan
5f2dc1fe31 chore: final touches (#1219)
chore Top 3 Signing Efficiency Hacks for Freelancers

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced a blog post titled "Top 3 Signing Efficiency Hacks for
Freelancers" with tips on streamlining contract signing using Documenso.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-07-04 13:55:08 +02:00
Timur Ercan
b3cb9a10be Chore/freelancer-3-hacks (#1218)
Top 3 Signing Efficiency Hacks for Freelancers article
2024-07-04 13:48:01 +02:00
Timur Ercan
7cff035f8a Chore/changelog-156 (#1214)
fix typo
2024-07-02 15:07:08 +02:00
Timur Ercan
7ac899eb8d chore: changelog 156 (#1213)
changelog for v 1.5.6

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
	- Display document creation time in the documents view.
	- Introduced direct template links for easier access.
- Added support for OpenID Connect (OIDC) for improved authentication
options.

- **Enhancements**
- Transitioned to a new rust-based library for signing documents,
enhancing performance and security.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-07-02 14:42:23 +02:00
Ephraim Duncan
92c09c5850 feat: move document to team (#1210)
Introduces a new dialog component allowing users to move documents
between teams with included audit logging.
2024-07-02 12:47:24 +10:00
Timur Ercan
90c43dcd0a Update announcing-profiles.mdx 2024-07-01 15:27:06 +02:00
Timur Ercan
48bf57d3aa chore: last text touches (#1212)
final touches for profiles announce
2024-07-01 14:59:33 +02:00
Timur Ercan
fc0c0a9754 chore: last text touches 2024-07-01 14:58:30 +02:00
Timur Ercan
455c3a63f9 Update announcing-profiles.mdx 2024-07-01 14:41:06 +02:00
Timur Ercan
780e91b055 Update signing-an-nda-faster-with-documenso.mdx 2024-07-01 14:33:00 +02:00
Timur Ercan
611e495e16 chore: profiles announce blogpost (#1211)
announcing profiles blogpost

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced "Documenso Profiles" that allow users to share digital
signature templates publicly.
- Recipients can sign documents directly via public links, streamlining
the signing process.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-07-01 14:29:37 +02:00
Timur Ercan
6361dd5fe5 Merge branch 'main' into chore/profiles-announce 2024-07-01 14:21:52 +02:00
Timur Ercan
e2674456d4 chore: last text touches 2024-07-01 14:16:29 +02:00
Timur Ercan
b8cc2a2e0f chore: typo 2024-06-26 13:22:43 +02:00
Timur Ercan
68f7a7f090 chore: announcing profiles, first draft 2024-06-07 13:39:08 +02:00
Timur Ercan
aa5beafe59 chore: profiles article file 2024-06-06 17:47:30 +02:00
144 changed files with 6447 additions and 920 deletions

View File

@@ -78,6 +78,8 @@ NEXT_PRIVATE_SMTP_APIKEY_USER=
NEXT_PRIVATE_SMTP_APIKEY=
# OPTIONAL: Defines whether to force the use of TLS.
NEXT_PRIVATE_SMTP_SECURE=
# OPTIONAL: if this is true and NEXT_PRIVATE_SMTP_SECURE is false then TLS is not used even if the server supports STARTTLS extension
NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS=
# REQUIRED: Defines the sender name to use for the from address.
NEXT_PRIVATE_SMTP_FROM_NAME="Documenso"
# REQUIRED: Defines the email address to use as the from address.

View File

@@ -0,0 +1,68 @@
---
title: Documenso Profiles Are Here
description: Today, we are launching Documenso Profiles, a new way to let your peers sign your documents.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-07-01
tags:
- Announcement
- Direct Links
- Profiles
---
<figure>
<MdxNextImage
src="/blog/profile1.png"
width="1260"
height="1260"
alt="Documenso Profile of Timur"
/>
<figcaption className="text-center">Let people sign anytime with Documenso Profiles. Try it [with my profile](https://app.documenso.com/p/timur).</figcaption>
</figure>
> TLDR; We are launching a Documenso Profile where you can display your templates for everyone to sign at any time.
## Introducing Documenso Profiles
Today, Im excited to announce that we are launching Documenso Profiles 🎉 While we have been focussing on the conventional signing experience so far, Direct Links and now profiles are our first steps to bring some long-awaited innovation back to digital signatures.
Documenso Profiles allows you to share any template with a public link in a very easy-to-understand way. Adding templates to your profiles allows everyone to sign your documents just when they need to. Forms, NDAs, Disclaimers, and even contracts are now available anytime they are needed. Profiles turn the classic signing flow on its head by letting the recipient sign first before the documents owner becomes active. Booking links (e.g., Cal.com) and customer self-service portals (e.g., Stripe Billing Portal) are becoming the norm, so its time we did the same for signing.
## The Best Way to Share Your Singing Links
With profiles, we want to offer you the best way to share your standing Documenso Template Links in a public place. You can add your Documenso Profile to your social profiles, Email footer, Video Description, or wherever your audience, customers, and partners interact with you. The profile will be a public, trusted place to ensure you know who you are dealing with.
Looking at the classical social media fake profile problem, we know this is tricky to achieve. We will pay close attention to how profiles are used and how we can help the community use them easily and securely. As a first step towards this direction, we are introducing a trust badge next to your name. There will be 3 levels to start:
- Free Users: No Badge
- Paid Users: Green Badge
- Early Adopters: Gold Badge
<figure>
<MdxNextImage
src="/blog/profile2.png"
width="1260"
height="1260"
alt="Documenso Profile Edit Screen"
/>
<figcaption className="text-center">Add and remove templates anytime.</figcaption>
</figure>
## An Open Economy built on Documenso
We see offering profiles as a first step towards creating a full economy on top of the open signing ecosystem we envision. While we want to keep building the product with the community, we also want an economy to grow on the open tech we create. This includes using the tech (profiles) or offering service on top of the tech (hosting and customization). Our ecosystem is still young, and the goal is not to have Documenso Inc. as the only commercial actor but to fill our own niche in a global and thriving open ecosystem. One of our guiding principles is solving things once and for all. While our focus will be on the core signing product, we want to enable others to offer templates for use on profiles or privately. A lot of contracts, forms, and other paperwork have been recreated countless times. Offering high-quality, peer-reviewed templates is a natural extension of the current platform. There will be free templates, offered as marketing for their creators, and paid templates, offered as actual products.
## What's next
While an open signing economy is really exciting, we know it will take time to mature. Building out a mature ecosystem for builders and entrepreneurs takes time. While this aspect of Documenso matures, we will be focussing on the core of the platform: Letting you integrate and embed Documenso wherever you want it.
If you already have a Documenso account, you can [activate your profile here](https://app.documenso.com/settings/public-profile).
Dont have an account and want to check out profiles? You can do so using our [free plan](https://documen.so/free).
As always, we want to hear from you on [Twitter / X](https://twitter.com/eltimuro) (DMs are open) or [Discord](https://documen.so/discord) if you have any questions, ideas, or comments.
Best from Hamburg\
Timur

View File

@@ -0,0 +1,73 @@
---
title: 21 CFR Part 11 is here, and so is Vial.com
description: We launched Vial.com with a 21 CFR Part 11 compliant setup. Reach out if 21 CFR Part 11 compliance is among your needs.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-07-18
tags:
- Vial.com
- 21 CFR Part 11
- Compliance
---
<video
id="vid"
width="100%"
src="/blog/vial.webm"
autoPlay
loop
muted
></video>
<figcaption className="text-center">
Vial.com uses Documenso for 21 CFR Part 11 compliant signing.
</figcaption>
> TLDR; We launched Vial.com on Documenso and are open for 21 CFR Part 11 business.
# What is 21 CFR
You have never heard of 21 CFR Part 11? You are in good company since most people haven't. If you have, you probably work in an industry regulated by the U.S. Food and Drug Administration (FDA). Title 21 of the Code of Federal Regulations (CFR) is dedicated to detailing FDA-regulated business, and sub-part 11 sets out guidelines for using electronic signatures in this highly regulated field. Hence, 21 CFR Part 11 is highly relevant for regulated industries that aim to employ digital signatures. The guidelines set out in 21 CFR Part 11 aim to provide trustworthy, reliable, and equivalent to paper records and handwritten signatures. All Industries that fall under the FDA's regulation, e.g. pharmaceuticals, biotechnology, medical devices, and biologics, must comply with these rules when choosing or creating systems for electronic signatures.
Compliance with 21 CFR Part 11 is crucial for companies to use electronic records and signatures in their operations legally. It affects how companies manage documentation, conduct audits, and maintain regulatory submissions. Non-compliance can result in legal penalties, rejected submissions, and delays in product approvals, emphasizing the importance of adhering to these guidelines in FDA-regulated activities.
# Vial.com
Vial is a technology company on a mission to advance programs to market through computationally designed therapeutics and cost-effective clinical trials. It is imperative that Vial manages this process securely, effectively, and highly compliant. By leveraging it's modern platform, Vial aims to accelerate drug development and, ultimately, time to market for new therapies. You can learn more about them [here](https://vial.com/about-us).
[Together](https://documen.so/vial-documenso), Documenso and Vial set out to create the first open-source, 21 CFR Part 11 compliant signing solution. After iterating over the product together, Vial moved their operation from DocuSign, a known legacy signing provider, to a Documenso Enterprise plan. We are very happy to be able to support Vials mission by fulfilling our own: bringing open signing and all its innovation to where it's needed.
# 21 CFR Part 11 on Documenso Highlights
21 CFR Part 11 is a highly complex statute, and going into the all design rationales and the following implementation details, deserves its own article later. For now, I want to share a few notable highlights.
## The Full Experience
We implemented 21 CFR Part 11, keeping the main user experience of Documenso intact. Our 21 CFR module is not separate but natively integrated into all Documenso flows, thus not sacrificing usability for compliance. This also means most (if not all) advanced features we offer are usable in a compliant way. This prevents customers from being trapped in an anti-innovation bubble, not allowing access to new features for fear of non-compliance.
## Action Reauth Using Passkeys
<video
id="vid"
width="100%"
src="/blog/vial2.webm"
autoPlay
loop
muted
controls
></video>
<figcaption className="text-center">
Using passkeys (used here via fingerprint scanner) is the smoothest way to re-authenticate.
</figcaption>
One of the requirements affecting day-to-day life the most is the requirement to actually reauthenticate every signature placed on a document. While we can't change that, we can help make the reauthentication as painless as possible. To this end, we opted for passkeys. While Documenso supports passkeys to log in, they are also supported to authenticate signing on a per-signature level as part of the Documenso Enterprise Plan. The user still has to authenticate every signature but can now do so from the comfort of their passkey provider, be that 1Password, their browser, or any other provider.
## Direct Links
We recently launched [Direct Template Links](https://documen.so/direct-links), a new way to let people sign and fill out forms. Links can be completed anytime, creating a new document in the process. Direct Links are also 21 CFR part 11 compliant, using action reauthentication, audit log, and all other compliance requirements.
# Documenso Enterprise Plan
With the successful launch of Vial, we are now open for business. 21 CFR Part 11 compliance is part of the Documenso Enterprise plan, which includes all regulations we currently support and upcoming additions. While the pricing depends heavily on your needs and scale, we offer fixed-price plans for better predictability for both sides. In our experience, volume-based pricing is a legacy headache we want to avoid.
If you are FDA-regulated and looking for a modern signing solution, we are happy to discuss your requirements in detail. You can write us (hi@documenso.com) or contact [our enterprise team](https://documen.so/21cfr) at any time or stage.
If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord).
Best from Hamburg\
Timur

View File

@@ -8,6 +8,7 @@ date: 2024-06-27
tags:
- NDA
- Direct Links
- Productivity
---
<figure>
@@ -24,11 +25,12 @@ tags:
> TLDR; Documenso makes sending NDAs faster, faster with templates, and even still faster using direct links.
## What is an NDA?
An NDA, or non-disclosure agreement, is a legal contract that establishes a confidential relationship. The parties involved agree not to disclose information covered by the agreement. NDAs are often used to protect sensitive information or trade secrets and to ensure that such information isn't made public by the recipient without permission. They are commonly used in business settings, such as during negotiations or when new employees are hired who will have access to proprietary information.
## Do I need an NDA?
> Disclaimer: This is not legal advice, and the most important legal questions are often ultra-case-specific and should be discussed with a legal professional
> Disclaimer: This is not legal advice, and the most important legal questions are often ultra-case-specific and should be discussed with a legal professional
There is a solid amount of debate around the question of whether an NDA actually protects anyone and is worth the friction. Investors scoff at the idea of a startup requiring them to sign an NDA before disclosing their "billion dollar idea" as they see hundreds of them and are aware that without proper execution, there is nothing to protect.
@@ -37,6 +39,7 @@ In another classical example, a big company and a small company e.g. a startup,
That being said, as with most contracts, NDAs can be useful if both parties keep the spirit of the agreement. In this case, detailing in writing what can and cannot be disclosed is good for managing expectations and building trust. NDAs are also common practice in merger and acquisition projects and are often part of hiring critical roles within a company.
### Level 1: Basic Signing
If you need to sign an NDA, signing it with Documenso is incredibly fast already. Let's take a look at how to make it even faster. Simply uploading and sending is the most straightforward way to get this done. It works like this:
- Upload the NDA PDF template
@@ -56,7 +59,9 @@ If you need to sign an NDA, signing it with Documenso is incredibly fast already
></video>
### Level 2: Using Templates
If you have to sign the same NDA multiple times with different people, you can create a template to save time. Creating a template is just as easy as creating a document, just skipping the sending step. After creating the template for your NDA, you can create a signable document with just 1 click. Simply fill in the recipient email, and you are done.
> Pro Tip: Check "Send Document" to immediately send it after filling out the recipient if you are familiar with the template.
<video
@@ -73,6 +78,7 @@ If you have to sign the same NDA multiple times with different people, you can c
</figcaption>
### Level 3: Using a Direct Link
Using a pre-defined template is pretty fast, but can we make it even faster? Yes, we can! By adding a direct link to your NDA template and publishing it (internally or even externally). A [Direct Link](http://documenso.com/blog/announcing-direct-links) lets people sign your NDA without you lifting a finger. Everyone with access to the link can sign it at any time, making discussions of who sends what when a thing of the past. You can use Direct links with a pre-signed template for maximum convenience or with a second signer/ approver from your side to keep control over the process.
You can try it here and [sign a demo NDA](https://documen.so/demo-nda) with me.
@@ -80,9 +86,10 @@ You can try it here and [sign a demo NDA](https://documen.so/demo-nda) with me.
> Pro Tip: Use [Zapier](https://documen.so/zapier) to get notified of that platform of your choice as soon as someone signs your link.
## Conclusion
Signing NDAs is not always effective, but it can be necessary, so be sure to use a tool to make it easy and fast. Documenso is a great DocuSign alternative that helps you get it done. If you need to get an NDA out today, you can use the [Documenso Free plan](https://documen.so/free), which gives you 5 signatures per month and 3 Direct Link templates.
As always, we want to hear from you on [Twitter / X](https://twitter.com/eltimuro) (DMs are open) or [Discord](https://documen.so/discord) if you have any questions, ideas, or comments.
Best from Hamburg\
Timur
Timur

View File

@@ -0,0 +1,51 @@
---
title: Top 3 Signing Efficiency Hacks for Freelancers
description: Streamlining signing contracts and paperwork is crucial for freelancers looking to save time and focus on their business. Take a look at these 3 tips on how Documenso can help you.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-07-04
tags:
- Productivity
- Zapier
- Direct Links
---
<figure>
<MdxNextImage
src="/blog/zen.webp"
width="1400"
height="884"
alt="Direct Links in Templates List View"
/>
<figcaption className="text-center">Documenso helps you reduce your cognitive load while running your business.</figcaption>
</figure>
> TLDR; Set up notifications using Zapier, use a redirect to send users to the next step, and start working async with direct links.
Signing all the paperwork of a freelance business can be a headache. Besides adding to the daily workload, it can pull much focus from your core tasks. Let's take a look at how Documenso can help you keep the cognitive load of admin tasks to a minimum:
## Tip 1: Set Up Notifications
> At Documenso, we pipe almost everything relevant into Discord. This helps us naturally keep track and start conversations.
Getting customers and partners to sign off on critical agreements is no easy feat. It's even harder when keeping track forces you to switch between channels (email, WhatsApp, Discord, Slack, Notion, etc.). Setting up custom notifications for read or signed documents can help you keep track naturally. Using the Documenso [Zapier Integration](https://documen.so/zapier) lets you funnel important document events to the place where your focus is. Pipe send, read, and sign notifications directly to your Telegram account. Keep up to date on the go. Sync document completion events to your CRM. Ideally, a signed document does not give you any "homework”. Besides, you are already busy getting started on the actual work.
## Tip 2: Use Redirects
Your client signed the deal? Your designer signed their statement of work? Great. Don't risk losing steam getting from one step to the next. Your customers and partners have just as much noise around them as you. Help them to stay on task by doing the next thing easily. Documenso lets you set up redirects to send signers to where they need to go next. Send your newest customer to a booking page for onboarding. Forward your designer to the next briefing you prepared. Keeping up flow is critical; sending everyone where they need to go while in focus mode helps get things done faster.
## Tip 3: Go Async with Direct Links
> Try Direct Links by signing our [Supporters Pledge](https://documen.so/pledge)
Your customer wanting to approve an additional budget is great. Its so great. In fact, you want to get on it as soon as possible and not have a calm moment until you send them something to sign. Even if you are fast, you are not mind-ready, though. So, what if your customers could do it whenever they like?
Direct Links lets you set up proposal templates and contracts, which are ready to sign anytime. You can include them in your [initial proposal](https://documen.so/freelance-proposal) or on your [public profile](https://documen.so/profiles). This way, customers can get more of your offering whenever they want.
## Conclusion
Setting up notifications, redirects, and direct signing links using your favorite open source DocuSign alternative helps streamline your business and reduce the cognitive load of managing it. You can get everything mentioned here plus 5 free monthly signatures in the [Documenso Free Plan](https://documen.so/free).
As always, we want to hear from you on [Twitter / X](https://twitter.com/eltimuro) (DMs are open) or [Discord](https://documen.so/discord) if you have any questions, ideas, or comments.
Best from Hamburg\
Timur

View File

@@ -4,11 +4,33 @@ title: Changelog - Documenso
# Changelog
Check out what's new in the latest major version and read what we think about it. You can find our releases on GitHub for more technical details [here](https://github.com/documenso/documenso/releases). You can find our [release candidates here](https://github.com/documenso/documenso/tags).
Check out what's new in the latest version and read our thoughts on it. For more technical details, you can find our releases on GitHub [here](https://github.com/documenso/documenso/releases). You can find our [release candidates here](https://github.com/documenso/documenso/tags).
---
## v1.5.5 (latest)
## v1.5.6 (latest)
### <small>Released 28th June 2024</small>
> This release contains [11 fixes](https://github.com/documenso/documenso/releases/tag/v1.5.6)
### 🕗 Show Creation Time
We are now displaying the document creation time in the documents view. This allows for easier identification of multiple documents created on the same day.
### 🔗 Direct Template Links
With this release, we are introducing direct link templates. This allows you to statically link to any template and let anyone with the link sign it at any time. A new document is created in your account when a template is signed. Templates with direct links still support all other template features, allowing you to create intricate workflows triggered by the signers.
Learn more about Direct Links [on our blog](https://documenso.com/blog/announcing-direct-links) or try them by signing the [Documenso Supporters Pledge](https://documen.so/pledge).
### 🛂 OpenID Connect (OIDC) Support
Thanks to [Matt Kilgore](https://github.com/tankerkiller125), Documenso now supports OIDC as an authentication provider. This allows self-hosted users to define whatever identity provider they want as long as it supports the OIDC. Azure, Zitadel, Authentik, KeyCloak, and Google all support OIDC.
---
## v1.5.5
### <small>Released 6th May 2024</small>
@@ -51,5 +73,3 @@ On the security/ compliance side, we also added Signing Certificates and Audit L
We are pretty hyped about this one: Since version 0.9, we relied on https://github.com/vbuch/node-signpdf to add the digital signatures to our documents. Since signing is at the heart of Documenso, we created our own rust-based library for signing. As of 1.5.4, Documenso's signing runs on @documenso/pdf-sign. The library offers a better architecture to enable signing with private keys that are not stored locally (e.g. via HSM). We are in the process of cleaning up the library to open source it like the rest of Documenso 🌱 The library will also help us to offer Long Term Validation (LTV) for signatures soon. While we are currently limited to signing with PKCS7-B, eventually, we plan to support all common signing standards like PAdES, CAdES, and XAdES.
---
´

View File

@@ -1,6 +1,6 @@
{
"name": "@documenso/marketing",
"version": "1.2.3",
"version": "1.6.0-rc.3",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -58,4 +58,4 @@
"next": "$next"
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -98,6 +98,7 @@ export const SinglePlayerClient = () => {
height: new Prisma.Decimal(field.pageHeight),
customText: '',
inserted: false,
fieldMeta: field.fieldMeta ?? {},
})),
);
@@ -131,7 +132,9 @@ export const SinglePlayerClient = () => {
positionY: field.positionY.toNumber(),
width: field.width.toNumber(),
height: field.height.toNumber(),
fieldMeta: field.fieldMeta,
})),
fieldMeta: { type: undefined },
});
analytics.capture('Marketing: SPM - Document signed', {

View File

@@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.2.3",
"version": "1.6.0-rc.3",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -76,4 +76,4 @@
"next": "$next"
}
}
}
}

View File

@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { BarChart3, FileStack, Settings, User2, Wallet2 } from 'lucide-react';
import { BarChart3, FileStack, Settings, Users, Wallet2 } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -46,7 +46,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
asChild
>
<Link href="/admin/users">
<User2 className="mr-2 h-5 w-5" />
<Users className="mr-2 h-5 w-5" />
Users
</Link>
</Button>

View File

@@ -2,27 +2,28 @@ import {
File,
FileCheck,
FileClock,
FileCog,
FileEdit,
Mail,
MailOpen,
PenTool,
User as UserIcon,
UserPlus2,
UserPlus,
UserSquare2,
Users,
} from 'lucide-react';
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
import {
getUserWithAtLeastOneDocumentPerMonth,
getUserWithAtLeastOneDocumentSignedPerMonth,
getUserWithSignedDocumentMonthlyGrowth,
getUsersCount,
getUsersWithSubscriptionsCount,
} from '@documenso/lib/server-only/admin/get-users-stats';
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
import { SignerConversionChart } from './signer-conversion-chart';
import { UserWithDocumentChart } from './user-with-document';
export default async function AdminStatsPage() {
@@ -31,16 +32,18 @@ export default async function AdminStatsPage() {
usersWithSubscriptionsCount,
docStats,
recipientStats,
userWithAtLeastOneDocumentPerMonth,
userWithAtLeastOneDocumentSignedPerMonth,
signerConversionMonthly,
// userWithAtLeastOneDocumentPerMonth,
// userWithAtLeastOneDocumentSignedPerMonth,
MONTHLY_USERS_SIGNED,
] = await Promise.all([
getUsersCount(),
getUsersWithSubscriptionsCount(),
getDocumentStats(),
getRecipientsStats(),
getUserWithAtLeastOneDocumentPerMonth(),
getUserWithAtLeastOneDocumentSignedPerMonth(),
getSignerConversionMonthly(),
// getUserWithAtLeastOneDocumentPerMonth(),
// getUserWithAtLeastOneDocumentSignedPerMonth(),
getUserWithSignedDocumentMonthlyGrowth(),
]);
@@ -49,14 +52,15 @@ export default async function AdminStatsPage() {
<h2 className="text-4xl font-semibold">Instance Stats</h2>
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<CardMetric icon={UserIcon} title="Total Users" value={usersCount} />
<CardMetric icon={Users} title="Total Users" value={usersCount} />
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
<CardMetric
icon={UserPlus2}
icon={UserPlus}
title="Active Subscriptions"
value={usersWithSubscriptionsCount}
/>
<CardMetric icon={UserPlus2} title="App Version" value={`v${process.env.APP_VERSION}`} />
<CardMetric icon={FileCog} title="App Version" value={`v${process.env.APP_VERSION}`} />
</div>
<div className="mt-16 gap-8">
@@ -88,7 +92,7 @@ export default async function AdminStatsPage() {
<div className="mt-16">
<h3 className="text-3xl font-semibold">Charts</h3>
<div className="mt-5 grid grid-cols-2 gap-10">
<div className="mt-5 grid grid-cols-2 gap-8">
<UserWithDocumentChart
data={MONTHLY_USERS_SIGNED}
title="MAU (created document)"
@@ -100,6 +104,12 @@ export default async function AdminStatsPage() {
title="MAU (had document completed)"
tooltip="Monthly Active Users: Users that had at least one of their documents completed"
/>
<SignerConversionChart title="Signers that Signed Up" data={signerConversionMonthly} />
<SignerConversionChart
title="Total Signers that Signed Up"
data={signerConversionMonthly}
cummulative
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,64 @@
'use client';
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetSignerConversionMonthlyResult } from '@documenso/lib/server-only/user/get-signer-conversion';
export type SignerConversionChartProps = {
className?: string;
title: string;
cummulative?: boolean;
data: GetSignerConversionMonthlyResult;
};
export const SignerConversionChart = ({
className,
data,
title,
cummulative = false,
}: SignerConversionChartProps) => {
const formattedData = [...data].reverse().map(({ month, count, cume_count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('MMM yyyy'),
count: Number(count),
signed_count: Number(cume_count),
};
});
return (
<div className={className}>
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">{title}</h3>
</div>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
formatter={(value, name) => [
Number(value).toLocaleString('en-US'),
name === 'Recipients',
]}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
<Bar
dataKey={cummulative ? 'signed_count' : 'count'}
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={60}
label="Recipients"
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@@ -68,7 +68,7 @@ export const UserWithDocumentChart = ({
</div>
<ResponsiveContainer width="100%" height={400}>
<BarChart className="bg-white" data={formattedData(data, completed)}>
<BarChart data={formattedData(data, completed)}>
<XAxis dataKey="month" />
<YAxis />

View File

@@ -172,6 +172,7 @@ export const EditDocumentForm = ({
teamId: team?.id,
data: {
title: data.title,
externalId: data.externalId || null,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
@@ -231,6 +232,14 @@ export const EditDocumentForm = ({
fields: data.fields,
});
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('field_')) {
localStorage.removeItem(key);
}
}
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
@@ -240,7 +249,7 @@ export const EditDocumentForm = ({
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
description: 'An error occurred while adding the fields.',
variant: 'destructive',
});
}
@@ -350,6 +359,7 @@ export const EditDocumentForm = ({
fields={fields}
onSubmit={onAddFieldsFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
teamId={team?.id}
/>
<AddSubjectFormPartial

View File

@@ -150,7 +150,7 @@ export const ResendDocumentActionItem = ({
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black"
checkClassName="text-white"
value={recipient.id}
checked={value.includes(recipient.id)}

View File

@@ -12,6 +12,7 @@ import {
EyeIcon,
Loader,
MoreHorizontal,
MoveRight,
Pencil,
Share,
Trash2,
@@ -37,6 +38,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { ResendDocumentActionItem } from './_action-items/resend-document';
import { DeleteDocumentDialog } from './delete-document-dialog';
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
import { MoveDocumentDialog } from './move-document-dialog';
export type DataTableActionDropdownProps = {
row: Document & {
@@ -53,6 +55,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
if (!session) {
return null;
@@ -157,6 +160,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
Duplicate
</DropdownMenuItem>
{/* We don't want to allow teams moving documents across at the moment. */}
{!team && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<MoveRight className="mr-2 h-4 w-4" />
Move to Team
</DropdownMenuItem>
)}
{/* No point displaying this if there's no functionality. */}
{/* <DropdownMenuItem disabled>
<XCircle className="mr-2 h-4 w-4" />
@@ -199,6 +210,12 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
canManageDocument={canManageDocument}
/>
<MoveDocumentDialog
documentId={row.id}
open={isMoveDialogOpen}
onOpenChange={setMoveDialogOpen}
/>
{isDuplicateDialogOpen && (
<DuplicateDocumentDialog
id={row.id}

View File

@@ -1,5 +1,6 @@
import Link from 'next/link';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
@@ -10,7 +11,7 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team, TeamEmail } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
@@ -94,6 +95,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && (
<AvatarImage src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`} />
)}
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>

View File

@@ -0,0 +1,117 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
type MoveDocumentDialogProps = {
documentId: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveDocument, isLoading } = trpc.document.moveDocumentToTeam.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Document moved',
description: 'The document has been successfully moved to the selected team.',
duration: 5000,
});
onOpenChange(false);
},
onError: (error) => {
toast({
title: 'Error',
description: error.message || 'An error occurred while moving the document.',
variant: 'destructive',
duration: 7500,
});
},
});
const onMove = async () => {
if (!selectedTeamId) return;
await moveDocument({ documentId, teamId: selectedTeamId });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Document to Team</DialogTitle>
<DialogDescription>
Select a team to move this document to. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
<SelectTrigger>
<SelectValue placeholder="Select a team" />
</SelectTrigger>
<SelectContent>
{isLoadingTeams ? (
<SelectItem value="loading" disabled>
Loading teams...
</SelectItem>
) : (
teams?.map((team) => (
<SelectItem key={team.id} value={team.id.toString()}>
<div className="flex items-center gap-4">
<Avatar className="h-8 w-8">
{team.avatarImageId && (
<AvatarImage
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
/>
)}
<AvatarFallback className="text-sm text-gray-400">
{team.name.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<span>{team.name}</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={onMove} loading={isLoading} disabled={!selectedTeamId || isLoading}>
{isLoading ? 'Moving...' : 'Move'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -3,6 +3,7 @@
import { AnimatePresence } from 'framer-motion';
import { BellIcon } from 'lucide-react';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { formatTeamUrl } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
@@ -55,6 +56,9 @@ export const TeamInvitations = () => {
{data.map((invitation) => (
<li key={invitation.teamId}>
<AvatarWithText
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${
invitation.team.avatarImageId
}`}
className="w-full max-w-none py-4"
avatarFallback={invitation.team.name.slice(0, 1)}
primaryText={

View File

@@ -12,10 +12,7 @@ import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
@@ -132,6 +129,7 @@ export const EditTemplateForm = ({
teamId: team?.id,
data: {
title: data.title,
externalId: data.externalId || null,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
@@ -183,6 +181,14 @@ export const EditTemplateForm = ({
fields: data.fields,
});
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('field_')) {
localStorage.removeItem(key);
}
}
toast({
title: 'Template saved',
description: 'Your templates has been saved successfully.',
@@ -231,11 +237,6 @@ export const EditTemplateForm = ({
className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()}
>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
@@ -268,6 +269,7 @@ export const EditTemplateForm = ({
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
teamId={team?.id}
/>
</Stepper>
</DocumentFlowFormContainer>

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import Link from 'next/link';
import { Copy, Edit, MoreHorizontal, Share2Icon, Trash2 } from 'lucide-react';
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
@@ -18,6 +18,7 @@ import {
import { DeleteTemplateDialog } from './delete-template-dialog';
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
import { MoveTemplateDialog } from './move-template-dialog';
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
export type DataTableActionDropdownProps = {
@@ -36,6 +37,7 @@ export const DataTableActionDropdown = ({
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
if (!session) {
return null;
@@ -73,6 +75,13 @@ export const DataTableActionDropdown = ({
Direct link
</DropdownMenuItem>
{!teamId && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<MoveRight className="mr-2 h-4 w-4" />
Move to Team
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)}
@@ -95,6 +104,12 @@ export const DataTableActionDropdown = ({
onOpenChange={setTemplateDirectLinkDialogOpen}
/>
<MoveTemplateDialog
templateId={row.id}
open={isMoveDialogOpen}
onOpenChange={setMoveDialogOpen}
/>
<DeleteTemplateDialog
id={row.id}
teamId={row.teamId || undefined}

View File

@@ -0,0 +1,120 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
type MoveTemplateDialogProps = {
templateId: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTemplateDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveTemplate, isLoading } = trpc.template.moveTemplateToTeam.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Template moved',
description: 'The template has been successfully moved to the selected team.',
duration: 5000,
});
onOpenChange(false);
},
onError: (error) => {
toast({
title: 'Error',
description: error.message || 'An error occurred while moving the template.',
variant: 'destructive',
duration: 7500,
});
},
});
const onMove = async () => {
if (!selectedTeamId) {
return;
}
await moveTemplate({ templateId, teamId: selectedTeamId });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Template to Team</DialogTitle>
<DialogDescription>
Select a team to move this template to. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
<SelectTrigger>
<SelectValue placeholder="Select a team" />
</SelectTrigger>
<SelectContent>
{isLoadingTeams ? (
<SelectItem value="loading" disabled>
Loading teams...
</SelectItem>
) : (
teams?.map((team) => (
<SelectItem key={team.id} value={team.id.toString()}>
<div className="flex items-center gap-4">
<Avatar className="h-8 w-8">
{team.avatarImageId && (
<AvatarImage
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
/>
)}
<AvatarFallback className="text-sm text-gray-400">
{team.name.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<span>{team.name}</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={onMove} loading={isLoading} disabled={!selectedTeamId || isLoading}>
{isLoading ? 'Moving...' : 'Move'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,10 +1,11 @@
import React from 'react';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { TemplatesDataTable } from './data-table-templates';
import { EmptyTemplateState } from './empty-state';
@@ -39,6 +40,9 @@ export const TemplatesPageView = async ({ searchParams = {}, team }: TemplatesPa
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && (
<AvatarImage src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`} />
)}
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>

View File

@@ -42,7 +42,7 @@ export const DirectTemplatePageView = ({
const { toast } = useToast();
const { email, setEmail } = useRequiredSigningContext();
const { email, fullName, setEmail } = useRequiredSigningContext();
const { recipient, setRecipient } = useRequiredDocumentAuthContext();
const [step, setStep] = useState<DirectTemplateStep>('configure');
@@ -84,6 +84,7 @@ export const DirectTemplatePageView = ({
try {
const token = await createDocumentFromDirectTemplate({
directTemplateToken,
directRecipientName: fullName,
directRecipientEmail: recipient.email,
templateUpdatedAt: template.updatedAt,
signedFieldValues: fields.map((field) => {

View File

@@ -6,6 +6,13 @@ import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Field, Recipient, Signature } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
@@ -30,10 +37,14 @@ import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useStep } from '@documenso/ui/primitives/stepper';
import { CheckboxField } from '~/app/(signing)/sign/[token]/checkbox-field';
import { DateField } from '~/app/(signing)/sign/[token]/date-field';
import { DropdownField } from '~/app/(signing)/sign/[token]/dropdown-field';
import { EmailField } from '~/app/(signing)/sign/[token]/email-field';
import { NameField } from '~/app/(signing)/sign/[token]/name-field';
import { NumberField } from '~/app/(signing)/sign/[token]/number-field';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { RadioField } from '~/app/(signing)/sign/[token]/radio-field';
import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog';
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
@@ -200,15 +211,96 @@ export const SignDirectTemplateForm = ({
onUnsignField={onUnsignField}
/>
))
.with(FieldType.TEXT, () => (
<TextField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.TEXT, () => {
const parsedFieldMeta = field.fieldMeta
? ZTextFieldMeta.parse(field.fieldMeta)
: null;
return (
<TextField
key={field.id}
field={{
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.with(FieldType.NUMBER, () => {
const parsedFieldMeta = field.fieldMeta
? ZNumberFieldMeta.parse(field.fieldMeta)
: null;
return (
<NumberField
key={field.id}
field={{
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.with(FieldType.DROPDOWN, () => {
const parsedFieldMeta = field.fieldMeta
? ZDropdownFieldMeta.parse(field.fieldMeta)
: null;
return (
<DropdownField
key={field.id}
field={{
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.with(FieldType.RADIO, () => {
const parsedFieldMeta = field.fieldMeta
? ZRadioFieldMeta.parse(field.fieldMeta)
: null;
return (
<RadioField
key={field.id}
field={{
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.with(FieldType.CHECKBOX, () => {
const parsedFieldMeta = field.fieldMeta
? ZCheckboxFieldMeta.parse(field.fieldMeta)
: null;
return (
<CheckboxField
key={field.id}
field={{
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
);
})
.otherwise(() => null),
)}
</ElementVisible>

View File

@@ -0,0 +1,292 @@
'use client';
import { useEffect, useMemo, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { SigningFieldContainer } from './signing-field-container';
export type CheckboxFieldProps = {
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const CheckboxField = ({
field,
recipient,
onSignField,
onUnsignField,
}: CheckboxFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const values = parsedFieldMeta.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const [checkedValues, setCheckedValues] = useState(
values
?.map((item) =>
item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '',
)
.filter(Boolean) || [],
);
const isReadOnly = parsedFieldMeta.readOnly;
const checkboxValidationRule = parsedFieldMeta.validationRule;
const checkboxValidationLength = parsedFieldMeta.validationLength;
const validationSign = checkboxValidationSigns.find(
(sign) => sign.label === checkboxValidationRule,
);
const isLengthConditionMet = useMemo(() => {
if (!validationSign) return true;
return (
(validationSign.value === '>=' && checkedValues.length >= (checkboxValidationLength || 0)) ||
(validationSign.value === '=' && checkedValues.length === (checkboxValidationLength || 0)) ||
(validationSign.value === '<=' && checkedValues.length <= (checkboxValidationLength || 0))
);
}, [checkedValues, validationSign, checkboxValidationLength]);
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const shouldAutoSignField =
(!field.inserted && checkedValues.length > 0 && isLengthConditionMet) ||
(!field.inserted && isReadOnly && isLengthConditionMet);
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: checkedValues.join(','),
isBase64: true,
authOptions,
};
if (onSignField) {
await onSignField(payload);
} else {
await signFieldWithToken(payload);
}
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onRemove = async (fieldType?: string) => {
try {
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
};
if (onUnsignField) {
await onUnsignField(payload);
} else {
await removeSignedFieldWithToken(payload);
}
if (fieldType === 'Checkbox') {
setCheckedValues([]);
}
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the signature.',
variant: 'destructive',
});
}
};
const handleCheckboxChange = (value: string, itemId: number) => {
const updatedValue = value || `empty-value-${itemId}`;
const updatedValues = checkedValues.includes(updatedValue)
? checkedValues.filter((v) => v !== updatedValue)
: [...checkedValues, updatedValue];
setCheckedValues(updatedValues);
};
const handleCheckboxOptionClick = async (item: {
id: number;
checked: boolean;
value: string;
}) => {
let updatedValues: string[] = [];
try {
const isChecked = checkedValues.includes(
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
);
if (!isChecked) {
updatedValues = [
...checkedValues,
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
];
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
});
if (isLengthConditionMet) {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: updatedValues.join(','),
isBase64: true,
});
}
} else {
updatedValues = checkedValues.filter(
(v) => v !== item.value && v !== `empty-value-${item.id}`,
);
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
});
}
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while updating the signature.',
variant: 'destructive',
});
} finally {
setCheckedValues(updatedValues);
startTransition(() => router.refresh());
}
};
useEffect(() => {
if (shouldAutoSignField) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, [checkedValues, isLengthConditionMet, field.inserted]);
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Checkbox">
{isLoading && (
<div className="bg-background absolute inset-0 z-20 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<>
{!isLengthConditionMet && (
<FieldToolTip key={field.id} field={field} color="warning" className="">
{validationSign?.label} {checkboxValidationLength}
</FieldToolTip>
)}
<div className="z-50 flex flex-col gap-y-2">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`;
return (
<div key={index} className="flex items-center gap-x-1.5">
<Checkbox
className="h-4 w-4"
checkClassName="text-white"
id={`checkbox-${index}`}
checked={checkedValues.includes(itemValue)}
onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
/>
<Label htmlFor={`checkbox-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
);
})}
</div>
</>
)}
{field.inserted && (
<div className="flex flex-col gap-y-2">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`;
return (
<div key={index} className="flex items-center gap-x-1.5">
<Checkbox
className="h-4 w-4"
checkClassName="text-white"
id={`checkbox-${index}`}
checked={field.customText
.split(',')
.some((customValue) => customValue === itemValue)}
disabled={isLoading}
onCheckedChange={() => void handleCheckboxOptionClick(item)}
/>
<Label htmlFor={`checkbox-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
);
})}
</div>
)}
</SigningFieldContainer>
);
};

View File

@@ -139,11 +139,15 @@ export const DateField = ({
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Date</p>
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
Date
</p>
)}
{field.inserted && (
<p className="text-muted-foreground text-sm duration-200">{localDateString}</p>
<p className="text-muted-foreground dark:text-background/80 text-sm duration-200">
{localDateString}
</p>
)}
</SigningFieldContainer>
);

View File

@@ -0,0 +1,209 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZDropdownFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { SigningFieldContainer } from './signing-field-container';
export type DropdownFieldProps = {
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DropdownField = ({
field,
recipient,
onSignField,
onUnsignField,
}: DropdownFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const parsedFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const isReadOnly = parsedFieldMeta?.readOnly;
const defaultValue = parsedFieldMeta?.defaultValue;
const [localChoice, setLocalChoice] = useState(parsedFieldMeta.defaultValue ?? '');
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const shouldAutoSignField =
(!field.inserted && localChoice) || (!field.inserted && isReadOnly && defaultValue);
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
if (!localChoice) {
return;
}
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: localChoice,
isBase64: true,
authOptions,
};
if (onSignField) {
await onSignField(payload);
} else {
await signFieldWithToken(payload);
}
setLocalChoice('');
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onPreSign = () => {
return true;
};
const onRemove = async () => {
try {
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
};
if (onUnsignField) {
await onUnsignField(payload);
return;
} else {
await removeSignedFieldWithToken(payload);
}
setLocalChoice(parsedFieldMeta.defaultValue ?? '');
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the signature.',
variant: 'destructive',
});
}
};
const handleSelectItem = (val: string) => {
setLocalChoice(val);
};
useEffect(() => {
if (!field.inserted && localChoice) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, [localChoice]);
useEffect(() => {
if (shouldAutoSignField) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, []);
return (
<div className="pointer-events-none">
<SigningFieldContainer
field={field}
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Dropdown"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200">
<Select value={parsedFieldMeta.defaultValue} onValueChange={handleSelectItem}>
<SelectTrigger
className={cn(
'text-muted-foreground z-10 h-full w-full border-none ring-0 focus:ring-0',
{
'hover:text-red-300': parsedFieldMeta.required,
'hover:text-yellow-300': !parsedFieldMeta.required,
},
)}
>
<SelectValue placeholder={'-- Select --'} />
</SelectTrigger>
<SelectContent className="w-full ring-0 focus:ring-0" position="popper">
{parsedFieldMeta?.values?.map((item, index) => (
<SelectItem key={index} value={item.value} className="ring-0 focus:ring-0">
{item.value}
</SelectItem>
))}
</SelectContent>
</Select>
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
{field.customText}
</p>
)}
</SigningFieldContainer>
</div>
);
};

View File

@@ -119,10 +119,16 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Email</p>
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
Email
</p>
)}
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 truncate duration-200">
{field.customText}
</p>
)}
</SigningFieldContainer>
);
};

View File

@@ -163,10 +163,16 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Name</p>
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
Name
</p>
)}
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 truncate duration-200">
{field.customText}
</p>
)}
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
<DialogContent>

View File

@@ -0,0 +1,337 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Hash, Loader } from 'lucide-react';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZNumberFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { SigningFieldContainer } from './signing-field-container';
type ValidationErrors = {
isNumber: string[];
required: string[];
minValue: string[];
maxValue: string[];
numberFormat: string[];
};
export type NumberFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const NumberField = ({ field, recipient, onSignField, onUnsignField }: NumberFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const [showRadioModal, setShowRadioModal] = useState(false);
const parsedFieldMeta = field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null;
const isReadOnly = parsedFieldMeta?.readOnly;
const defaultValue = parsedFieldMeta?.value;
const [localNumber, setLocalNumber] = useState(
parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0',
);
const initialErrors: ValidationErrors = {
isNumber: [],
required: [],
minValue: [],
maxValue: [],
numberFormat: [],
};
const [errors, setErrors] = useState(initialErrors);
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value;
setLocalNumber(text);
if (parsedFieldMeta) {
const validationErrors = validateNumberField(text, parsedFieldMeta, true);
setErrors({
isNumber: validationErrors.filter((error) => error.includes('valid number')),
required: validationErrors.filter((error) => error.includes('required')),
minValue: validationErrors.filter((error) => error.includes('minimum value')),
maxValue: validationErrors.filter((error) => error.includes('maximum value')),
numberFormat: validationErrors.filter((error) => error.includes('number format')),
});
} else {
const validationErrors = validateNumberField(text);
setErrors((prevErrors) => ({
...prevErrors,
isNumber: validationErrors.filter((error) => error.includes('valid number')),
}));
}
};
const onDialogSignClick = () => {
setShowRadioModal(false);
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
};
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
if (!localNumber || Object.values(errors).some((error) => error.length > 0)) {
return;
}
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: localNumber,
isBase64: true,
authOptions,
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
setLocalNumber('');
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onPreSign = () => {
setShowRadioModal(true);
if (localNumber && parsedFieldMeta) {
const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true);
setErrors({
isNumber: validationErrors.filter((error) => error.includes('valid number')),
required: validationErrors.filter((error) => error.includes('required')),
minValue: validationErrors.filter((error) => error.includes('minimum value')),
maxValue: validationErrors.filter((error) => error.includes('maximum value')),
numberFormat: validationErrors.filter((error) => error.includes('number format')),
});
}
return false;
};
const onRemove = async () => {
try {
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta?.value) : '');
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the signature.',
variant: 'destructive',
});
}
};
useEffect(() => {
if (!showRadioModal) {
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0');
setErrors(initialErrors);
}
}, [showRadioModal]);
useEffect(() => {
if (
(!field.inserted && defaultValue && localNumber) ||
(!field.inserted && isReadOnly && defaultValue)
) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, []);
let fieldDisplayName = 'Number';
if (parsedFieldMeta?.label) {
fieldDisplayName = parsedFieldMeta.label.length > 10 ? parsedFieldMeta.label.substring(0, 10) + '...' : parsedFieldMeta.label;
}
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
return (
<SigningFieldContainer
field={field}
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Signature"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p
className={cn(
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
{
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
},
)}
>
<span className="flex items-center justify-center gap-x-1 text-sm">
<Hash className='h-4 w-4' /> {fieldDisplayName}
</span>
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
{field.customText}
</p>
)}
<Dialog open={showRadioModal} onOpenChange={setShowRadioModal}>
<DialogContent>
<DialogTitle>
{parsedFieldMeta?.label ? parsedFieldMeta?.label : 'Add number'}
</DialogTitle>
<div>
<Input
type="text"
placeholder={parsedFieldMeta?.placeholder ?? ''}
className={cn('mt-2 w-full rounded-md', {
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
userInputHasErrors,
})}
value={localNumber}
onChange={handleNumberChange}
/>
</div>
{userInputHasErrors && (
<div>
{errors.isNumber?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
{errors.required?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
{errors.minValue?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
{errors.maxValue?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
{errors.numberFormat?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
</div>
)}
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowRadioModal(false);
setLocalNumber('');
}}
>
Cancel
</Button>
<Button
type="button"
className="flex-1"
disabled={!localNumber || userInputHasErrors}
onClick={() => onDialogSignClick()}
>
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</SigningFieldContainer>
);
};

View File

@@ -0,0 +1,190 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { SigningFieldContainer } from './signing-field-container';
export type RadioFieldProps = {
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const RadioField = ({ field, recipient, onSignField, onUnsignField }: RadioFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const parsedFieldMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const values = parsedFieldMeta.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const checkedItem = values?.find((item) => item.checked);
const defaultValue = !field.inserted && !!checkedItem ? checkedItem.value : '';
const [selectedOption, setSelectedOption] = useState(defaultValue);
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const shouldAutoSignField =
(!field.inserted && selectedOption) ||
(!field.inserted && defaultValue) ||
(!field.inserted && parsedFieldMeta.readOnly && defaultValue);
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
if (!selectedOption) {
return;
}
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: selectedOption,
isBase64: true,
authOptions,
};
if (onSignField) {
await onSignField(payload);
} else {
await signFieldWithToken(payload);
}
setSelectedOption('');
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onRemove = async () => {
try {
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
};
if (onUnsignField) {
await onUnsignField(payload);
} else {
await removeSignedFieldWithToken(payload);
}
setSelectedOption('');
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the signature.',
variant: 'destructive',
});
}
};
const handleSelectItem = (selectedOption: string) => {
setSelectedOption(selectedOption);
};
useEffect(() => {
if (shouldAutoSignField) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, [selectedOption, field]);
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Radio">
{isLoading && (
<div className="bg-background absolute inset-0 z-20 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10">
{values?.map((item, index) => (
<div key={index} className="flex items-center gap-x-1.5">
<RadioGroupItem
className="h-4 w-4 shrink-0"
value={item.value}
id={`option-${index}`}
checked={item.checked}
/>
<Label htmlFor={`option-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
))}
</RadioGroup>
)}
{field.inserted && (
<RadioGroup>
{values?.map((item, index) => (
<div key={index} className="flex items-center gap-x-1.5">
<RadioGroupItem
className=""
value={item.value}
id={`option-${index}`}
checked={item.value === field.customText}
/>
<Label htmlFor={`option-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
))}
</RadioGroup>
)}
</SigningFieldContainer>
);
};

View File

@@ -190,7 +190,7 @@ export const SignatureField = ({
)}
{state === 'empty' && (
<p className="group-hover:text-primary font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
<p className="group-hover:text-primary font-signature text-muted-foreground duration-200 group-hover:text-yellow-300 text-xl">
Signature
</p>
)}
@@ -199,12 +199,12 @@ export const SignatureField = ({
<img
src={signature.signatureImageAsBase64}
alt={`Signature for ${recipient.name}`}
className="h-full w-full object-contain dark:invert"
className="h-full w-full object-contain"
/>
)}
{state === 'signed-text' && (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
<p className="font-signature text-muted-foreground dark:text-background text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{/* This optional chaining is intentional, we don't want to move the check into the condition above */}
{signature?.typedSignature}
</p>

View File

@@ -2,10 +2,14 @@
import React from 'react';
import { X } from 'lucide-react';
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { cn } from '@documenso/ui/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
@@ -34,8 +38,8 @@ export type SignatureFieldProps = {
* The auth values will be passed in if available.
*/
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
onRemove?: () => Promise<void> | void;
type?: 'Date' | 'Email' | 'Name' | 'Signature';
onRemove?: (fieldType?: string) => Promise<void> | void;
type?: 'Date' | 'Email' | 'Name' | 'Signature' | 'Radio' | 'Dropdown' | 'Number' | 'Checkbox';
tooltipText?: string | null;
};
@@ -51,6 +55,9 @@ export const SigningFieldContainer = ({
}: SignatureFieldProps) => {
const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext();
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
const readOnlyField = parsedFieldMeta?.readOnly || false;
const handleInsertField = async () => {
if (field.inserted || !onSign) {
return;
@@ -102,41 +109,70 @@ export const SigningFieldContainer = ({
await onRemove?.();
};
const onClearCheckBoxValues = async (fieldType?: string) => {
if (!field.inserted) {
return;
}
await onRemove?.(fieldType);
};
return (
<FieldRootContainer field={field}>
{!field.inserted && !loading && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full"
onClick={async () => handleInsertField()}
/>
)}
<div className={cn(type === 'Checkbox' ? 'group' : '')}>
<FieldRootContainer field={field}>
{!field.inserted && !loading && !readOnlyField && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full rounded-md border"
onClick={async () => handleInsertField()}
/>
)}
{type === 'Date' && field.inserted && !loading && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
</TooltipTrigger>
{readOnlyField && (
<button className="bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100">
<span className="bg-foreground/50 dark:bg-background/50 text-background dark:text-foreground rounded-xl p-2">
Read only field
</span>
</button>
)}
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
</Tooltip>
)}
{type === 'Date' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
</TooltipTrigger>
{type !== 'Date' && field.inserted && !loading && (
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
)}
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
</Tooltip>
)}
{children}
</FieldRootContainer>
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="dark:bg-background absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
onClick={() => void onClearCheckBoxValues(type)}
>
<span className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<X className="h-4 w-4" />
</span>
</button>
)}
{type !== 'Date' && type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="text-destructive bg-background/50 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
)}
{children}
</FieldRootContainer>
</div>
);
};

View File

@@ -4,9 +4,17 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { CompletedField } from '@documenso/lib/types/fields';
import type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@@ -14,10 +22,14 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { truncateTitle } from '~/helpers/truncate-title';
import { CheckboxField } from './checkbox-field';
import { DateField } from './date-field';
import { DropdownField } from './dropdown-field';
import { EmailField } from './email-field';
import { SigningForm } from './form';
import { NameField } from './name-field';
import { NumberField } from './number-field';
import { RadioField } from './radio-field';
import { SignatureField } from './signature-field';
import { TextField } from './text-field';
@@ -101,9 +113,41 @@ export const SigningPageView = ({
.with(FieldType.EMAIL, () => (
<EmailField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.TEXT, () => (
<TextField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.TEXT, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
};
return <TextField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.with(FieldType.NUMBER, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
};
return <NumberField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.with(FieldType.RADIO, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
};
return <RadioField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.with(FieldType.CHECKBOX, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
};
return <CheckboxField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.with(FieldType.DROPDOWN, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
};
return <DropdownField key={field.id} field={fieldWithMeta} recipient={recipient} />;
})
.otherwise(() => null),
)}
</ElementVisible>

View File

@@ -4,29 +4,31 @@ import { useEffect, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { Loader, Type } from 'lucide-react';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZTextFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { SigningFieldContainer } from './signing-field-container';
export type TextFieldProps = {
field: FieldWithSignature;
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
@@ -34,9 +36,16 @@ export type TextFieldProps = {
export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const initialErrors: Record<string, string[]> = {
required: [],
characterLimit: [],
};
const [errors, setErrors] = useState(initialErrors);
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const [isPending, startTransition] = useTransition();
@@ -49,21 +58,52 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const parsedFieldMeta = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const shouldAutoSignField =
(!field.inserted && parsedFieldMeta?.text) ||
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly);
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
const [localText, setLocalCustomText] = useState('');
const [localText, setLocalCustomText] = useState(parsedFieldMeta?.text ?? '');
useEffect(() => {
if (!showCustomTextModal) {
setLocalCustomText('');
setLocalCustomText(parsedFieldMeta?.text ?? '');
setErrors(initialErrors);
}
}, [showCustomTextModal]);
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value;
setLocalCustomText(text);
if (parsedFieldMeta) {
const validationErrors = validateTextField(text, parsedFieldMeta, true);
setErrors({
required: validationErrors.filter((error) => error.includes('required')),
characterLimit: validationErrors.filter((error) => error.includes('character limit')),
});
}
};
/**
* When the user clicks the sign button in the dialog where they enter the text field.
*/
const onDialogSignClick = () => {
if (parsedFieldMeta) {
const validationErrors = validateTextField(localText, parsedFieldMeta, true);
if (validationErrors.length > 0) {
setErrors({
required: validationErrors.filter((error) => error.includes('required')),
characterLimit: validationErrors.filter((error) => error.includes('character limit')),
});
return;
}
}
setShowCustomTextModal(false);
void executeActionAuthProcedure({
@@ -73,17 +113,22 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
};
const onPreSign = () => {
if (!localText) {
setShowCustomTextModal(true);
return false;
setShowCustomTextModal(true);
if (localText && parsedFieldMeta) {
const validationErrors = validateTextField(localText, parsedFieldMeta, true);
setErrors({
required: validationErrors.filter((error) => error.includes('required')),
characterLimit: validationErrors.filter((error) => error.includes('character limit')),
});
}
return true;
return false;
};
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
if (!localText) {
if (!localText || userInputHasErrors) {
return;
}
@@ -136,6 +181,8 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
await removeSignedFieldWithToken(payload);
setLocalCustomText(parsedFieldMeta?.text ?? '');
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
@@ -148,6 +195,34 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
}
};
useEffect(() => {
if (shouldAutoSignField) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, []);
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
const labelDisplay =
parsedField?.label && parsedField.label.length < 20
? parsedField.label
: parsedField?.label
? parsedField?.label.substring(0, 20) + '...'
: undefined;
const textDisplay =
parsedField?.text && parsedField.text.length < 20
? parsedField.text
: parsedField?.text
? parsedField?.text.substring(0, 20) + '...'
: undefined;
const fieldDisplayName = labelDisplay ? labelDisplay : textDisplay ? textDisplay : 'Add text';
const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0);
return (
<SigningFieldContainer
field={field}
@@ -163,27 +238,69 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Text</p>
<p
className={cn(
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
{
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
},
)}
>
<span className="flex items-center justify-center gap-x-1">
<Type />
{fieldDisplayName}
</span>
</p>
)}
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 15) + '...'}
</p>
)}
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
<DialogContent>
<DialogTitle>
Enter your Text <span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle>
<DialogTitle>{parsedFieldMeta?.label ? parsedFieldMeta?.label : 'Add Text'}</DialogTitle>
<div className="">
<Label htmlFor="custom-text">Custom Text</Label>
<Input
<div>
<Textarea
id="custom-text"
className="border-border mt-2 w-full rounded-md border"
onChange={(e) => setLocalCustomText(e.target.value)}
placeholder={parsedFieldMeta?.placeholder ?? 'Enter your text here'}
className={cn('mt-2 w-full rounded-md', {
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
userInputHasErrors,
})}
value={localText}
onChange={handleTextChange}
/>
</div>
{parsedFieldMeta?.characterLimit !== undefined && parsedFieldMeta?.characterLimit > 0 && !userInputHasErrors && (
<div className="text-muted-foreground text-sm">
{charactersRemaining} characters remaining
</div>
)}
{userInputHasErrors && (
<div className="text-sm">
{errors.required.map((error, index) => (
<p key={index} className="text-red-500">
{error}
</p>
))}
{errors.characterLimit.map((error, index) => (
<p key={index} className="text-red-500">
{error}{' '}
{charactersRemaining < 0 && `(${Math.abs(charactersRemaining)} characters over)`}
</p>
))}
</div>
)}
<DialogFooter>
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
<Button
@@ -201,10 +318,10 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
<Button
type="button"
className="flex-1"
disabled={!localText}
disabled={!localText || userInputHasErrors}
onClick={() => onDialogSignClick()}
>
Save Text
Save
</Button>
</div>
</DialogFooter>

View File

@@ -1,6 +1,7 @@
import { CheckCircle2, Clock } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
@@ -64,6 +65,7 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro
<div className="flex flex-row items-center justify-between pt-4">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
avatarFallback={extractInitials(
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
)}

View File

@@ -25,7 +25,7 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
</div>
)}
<h3 className="text-primary-forground flex items-end text-sm font-medium leading-tight">
<h3 className="text-primary-forground mb-2 flex items-end text-sm font-medium leading-tight">
{title}
</h3>
</div>

View File

@@ -2,6 +2,7 @@
import { useState } from 'react';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import type { TeamMemberRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@@ -22,11 +23,18 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type LeaveTeamDialogProps = {
teamId: number;
teamName: string;
teamAvatarImageId?: string | null;
role: TeamMemberRole;
trigger?: React.ReactNode;
};
export const LeaveTeamDialog = ({ trigger, teamId, teamName, role }: LeaveTeamDialogProps) => {
export const LeaveTeamDialog = ({
trigger,
teamId,
teamName,
teamAvatarImageId,
role,
}: LeaveTeamDialogProps) => {
const [open, setOpen] = useState(false);
const { toast } = useToast();
@@ -70,6 +78,7 @@ export const LeaveTeamDialog = ({ trigger, teamId, teamName, role }: LeaveTeamDi
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${teamAvatarImageId}`}
avatarFallback={teamName.slice(0, 1).toUpperCase()}
primaryText={teamName}
secondaryText={TEAM_MEMBER_ROLE_MAP[role]}

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL, WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
@@ -62,6 +62,7 @@ export const CurrentUserTeamsDataTable = () => {
cell: ({ row }) => (
<Link href={`/t/${row.original.url}`} scroll={false}>
<AvatarWithText
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${row.original.avatarImageId}`}
avatarClass="h-12 w-12"
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
primaryText={
@@ -98,6 +99,7 @@ export const CurrentUserTeamsDataTable = () => {
<LeaveTeamDialog
teamId={row.original.id}
teamName={row.original.name}
teamAvatarImageId={row.original.avatarImageId}
role={row.original.currentTeamMember.role}
trigger={
<Button

View File

@@ -157,6 +157,7 @@ export const DocumentHistorySheet = ({
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM },
() => null,
)
.with(
@@ -270,6 +271,23 @@ export const DocumentHistorySheet = ({
]}
/>
))
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED },
({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Old',
value: data.from,
},
{
key: 'New',
value: data.to,
},
]}
/>
),
)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
@@ -304,7 +322,6 @@ export const DocumentHistorySheet = ({
]}
/>
))
.exhaustive()}
{isUserDetailsVisible && (

View File

@@ -42,12 +42,12 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
<FieldRootContainer
field={field}
key={field.id}
cardClassName="border-gray-100/50 !shadow-none backdrop-blur-[1px] bg-background/90"
cardClassName="border-gray-300/50 !shadow-none backdrop-blur-[1px] bg-gray-50 ring-0 ring-offset-0"
>
<div className="absolute -right-3 -top-3">
<PopoverHover
trigger={
<Avatar className="dark:border-border h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<Avatar className="dark:border-foreground h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
{extractInitials(field.Recipient.name || field.Recipient.email)}
</AvatarFallback>
@@ -78,7 +78,7 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
</PopoverHover>
</div>
<div className="text-muted-foreground break-all text-sm">
<div className="text-muted-foreground dark:text-background/70 break-all text-sm">
{field.Recipient.signingStatus === SigningStatus.SIGNED &&
match(field)
.with({ type: FieldType.SIGNATURE }, (field) =>
@@ -95,9 +95,19 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
),
)
.with(
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
{
type: P.union(
FieldType.NAME,
FieldType.EMAIL,
FieldType.NUMBER,
FieldType.RADIO,
FieldType.CHECKBOX,
FieldType.DROPDOWN,
),
},
() => field.customText,
)
.with({ type: FieldType.TEXT }, () => field.customText.substring(0, 20) + '...')
.with({ type: FieldType.DATE }, () =>
convertToLocalSystemFormat(
field.customText,

View File

@@ -128,6 +128,7 @@ Here's a markdown table documenting all the provided environment variables:
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |

22
package-lock.json generated
View File

@@ -1,10 +1,12 @@
{
"name": "@documenso/root",
"version": "1.6.0-rc.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.6.0-rc.3",
"workspaces": [
"apps/*",
"packages/*"
@@ -37,7 +39,7 @@
},
"apps/marketing": {
"name": "@documenso/marketing",
"version": "1.2.3",
"version": "1.6.0-rc.3",
"license": "AGPL-3.0",
"dependencies": {
"@documenso/assets": "*",
@@ -98,7 +100,7 @@
},
"apps/web": {
"name": "@documenso/web",
"version": "1.2.3",
"version": "1.6.0-rc.3",
"license": "AGPL-3.0",
"dependencies": {
"@documenso/api": "*",
@@ -30227,7 +30229,7 @@
},
"packages/api": {
"name": "@documenso/api",
"version": "1.0.0",
"version": "0.0.0",
"license": "MIT",
"dependencies": {
"@documenso/lib": "*",
@@ -30456,7 +30458,7 @@
},
"packages/app-tests": {
"name": "@documenso/app-tests",
"version": "1.0.0",
"version": "0.0.0",
"license": "to-update",
"dependencies": {
"start-server-and-test": "^2.0.1"
@@ -30488,7 +30490,7 @@
},
"packages/ee": {
"name": "@documenso/ee",
"version": "1.0.0",
"version": "0.0.0",
"license": "COMMERCIAL",
"dependencies": {
"@documenso/lib": "*",
@@ -30504,7 +30506,7 @@
},
"packages/email": {
"name": "@documenso/email",
"version": "1.0.0",
"version": "0.0.0",
"license": "MIT",
"dependencies": {
"@documenso/nodemailer-resend": "2.0.0",
@@ -31665,7 +31667,7 @@
},
"packages/lib": {
"name": "@documenso/lib",
"version": "1.0.0",
"version": "0.0.0",
"license": "MIT",
"dependencies": {
"@auth/kysely-adapter": "^0.6.0",
@@ -31762,7 +31764,7 @@
},
"packages/prisma": {
"name": "@documenso/prisma",
"version": "1.0.0",
"version": "0.0.0",
"license": "MIT",
"dependencies": {
"@prisma/client": "5.4.2",
@@ -31799,7 +31801,7 @@
},
"packages/signing": {
"name": "@documenso/signing",
"version": "1.0.0",
"version": "0.0.0",
"license": "AGPLv3",
"dependencies": {
"@documenso/pdf-sign": "^0.1.0",
@@ -31854,7 +31856,7 @@
},
"packages/trpc": {
"name": "@documenso/trpc",
"version": "1.0.0",
"version": "0.0.0",
"license": "MIT",
"dependencies": {
"@documenso/lib": "*",

View File

@@ -1,5 +1,6 @@
{
"private": true,
"version": "1.6.0-rc.3",
"scripts": {
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",
@@ -26,7 +27,8 @@
"reset:hard": "npm run clean && npm i && npm run prisma:generate",
"precommit": "npm install && git add package.json package-lock.json",
"trigger:dev": "npm run with:env -- npx trigger-cli dev --handler-path=\"/api/jobs\"",
"inngest:dev": "inngest dev -u http://localhost:3000/api/jobs"
"inngest:dev": "inngest dev -u http://localhost:3000/api/jobs",
"make:version": " npm version --workspace @documenso/web --workspace @documenso/marketing --include-workspace-root --no-git-tag-version -m \"v%s\""
},
"packageManager": "npm@10.7.0",
"engines": {

View File

@@ -1,6 +1,6 @@
{
"name": "@documenso/api",
"version": "1.0.0",
"version": "0.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "MIT",
@@ -27,4 +27,4 @@
"ts-pattern": "^5.0.5",
"zod": "^3.22.4"
}
}
}

View File

@@ -17,6 +17,7 @@ import {
ZGetDocumentsQuerySchema,
ZGetTemplatesQuerySchema,
ZNoBodyMutationSchema,
ZResendDocumentForSigningMutationSchema,
ZSendDocumentForSigningMutationSchema,
ZSuccessfulDeleteTemplateResponseSchema,
ZSuccessfulDocumentResponseSchema,
@@ -25,6 +26,7 @@ import {
ZSuccessfulGetTemplateResponseSchema,
ZSuccessfulGetTemplatesResponseSchema,
ZSuccessfulRecipientResponseSchema,
ZSuccessfulResendDocumentResponseSchema,
ZSuccessfulResponseSchema,
ZSuccessfulSigningResponseSchema,
ZUnsuccessfulResponseSchema,
@@ -161,6 +163,20 @@ export const ApiContractV1 = c.router(
summary: 'Send a document for signing',
},
resendDocument: {
method: 'POST',
path: '/api/v1/documents/:id/resend',
body: ZResendDocumentForSigningMutationSchema,
responses: {
200: ZSuccessfulResendDocumentResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Re-send a document for signing',
},
deleteDocument: {
method: 'DELETE',
path: '/api/v1/documents/:id',

View File

@@ -9,6 +9,7 @@ import { createDocument } from '@documenso/lib/server-only/document/create-docum
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { createField } from '@documenso/lib/server-only/field/create-field';
@@ -232,6 +233,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const document = await createDocument({
title: body.title,
externalId: body.externalId || null,
userId: user.id,
teamId: team?.id,
formValues: body.formValues,
@@ -397,6 +399,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
teamId: team?.id,
data: {
title: fileName,
externalId: body.externalId || null,
formValues: body.formValues,
documentData: {
connect: {
@@ -453,6 +456,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
try {
document = await createDocumentFromTemplate({
templateId,
externalId: body.externalId || null,
userId: user.id,
teamId: team?.id,
recipients: body.recipients,
@@ -600,6 +604,35 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
resendDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
const { recipients } = args.body;
try {
await resendDocument({
userId: user.id,
documentId: Number(documentId),
recipients,
teamId: team?.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
return {
status: 200,
body: {
message: 'Document resend successfully initiated',
},
};
} catch (err) {
return {
status: 500,
body: {
message: 'An error has occured while resending the document',
},
};
}
}),
createRecipient: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
const { name, email, role } = args.body;
@@ -1001,6 +1034,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
const field = await getFieldById({
userId: user.id,
teamId: team?.id,
fieldId: Number(fieldId),
documentId: Number(documentId),
}).catch(() => null);

View File

@@ -29,6 +29,7 @@ export type TDeleteDocumentMutationSchema = typeof ZDeleteDocumentMutationSchema
export const ZSuccessfulDocumentResponseSchema = z.object({
id: z.number(),
externalId: z.string().nullish(),
userId: z.number(),
teamId: z.number().nullish(),
title: z.string(),
@@ -57,6 +58,20 @@ export const ZSendDocumentForSigningMutationSchema = z
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
export const ZResendDocumentForSigningMutationSchema = z.object({
recipients: z.array(z.number()),
});
export type TResendDocumentForSigningMutationSchema = z.infer<
typeof ZResendDocumentForSigningMutationSchema
>;
export const ZSuccessfulResendDocumentResponseSchema = z.object({
message: z.string(),
});
export type TResendDocumentResponseSchema = z.infer<typeof ZSuccessfulResendDocumentResponseSchema>;
export const ZUploadDocumentSuccessfulSchema = z.object({
url: z.string(),
key: z.string(),
@@ -70,6 +85,7 @@ export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSucc
export const ZCreateDocumentMutationSchema = z.object({
title: z.string().min(1),
externalId: z.string().nullish(),
recipients: z.array(
z.object({
name: z.string().min(1),
@@ -94,6 +110,7 @@ export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutati
export const ZCreateDocumentMutationResponseSchema = z.object({
uploadUrl: z.string().min(1),
documentId: z.number(),
externalId: z.string().nullish(),
recipients: z.array(
z.object({
recipientId: z.number(),
@@ -113,6 +130,7 @@ export type TCreateDocumentMutationResponseSchema = z.infer<
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
title: z.string().min(1),
externalId: z.string().nullish(),
recipients: z.array(
z.object({
name: z.string().min(1),
@@ -139,6 +157,7 @@ export type TCreateDocumentFromTemplateMutationSchema = z.infer<
export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
documentId: z.number(),
externalId: z.string().nullish(),
recipients: z.array(
z.object({
recipientId: z.number(),
@@ -158,6 +177,7 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
title: z.string().optional(),
externalId: z.string().nullish(),
recipients: z
.array(
z.object({
@@ -194,6 +214,7 @@ export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
documentId: z.number(),
externalId: z.string().nullish(),
recipients: z.array(
z.object({
recipientId: z.number(),
@@ -332,6 +353,7 @@ export const ZTemplateMetaSchema = z.object({
export const ZTemplateSchema = z.object({
id: z.number(),
externalId: z.string().nullish(),
type: z.nativeEnum(TemplateType),
title: z.string(),
userId: z.number(),

View File

@@ -49,8 +49,8 @@ test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.getByLabel('Custom Text').fill('TEXT');
await page.getByRole('button', { name: 'Save Text' }).click();
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
@@ -109,8 +109,8 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.getByLabel('Custom Text').fill('TEXT');
await page.getByRole('button', { name: 'Save Text' }).click();
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
@@ -287,8 +287,8 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.getByLabel('Custom Text').fill('TEXT');
await page.getByRole('button', { name: 'Save Text' }).click();
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', {
@@ -398,8 +398,8 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.getByLabel('Custom Text').fill('TEXT');
await page.getByRole('button', { name: 'Save Text' }).click();
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', {

View File

@@ -81,7 +81,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
// Add fields
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'User 1 Signature' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
@@ -89,7 +89,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
},
});
await page.getByRole('button', { name: 'Email Email' }).click();
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
position: {
x: 100,
@@ -147,7 +147,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
// Add fields
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'User 1 Signature' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
@@ -155,7 +155,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
},
});
await page.getByRole('button', { name: 'Email Email' }).click();
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
position: {
x: 100,
@@ -166,7 +166,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByText('User 1 (user1@example.com)').click();
await page.getByText('User 2 (user2@example.com)').click();
await page.getByRole('button', { name: 'User 2 Signature' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 500,
@@ -174,7 +174,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
},
});
await page.getByRole('button', { name: 'Email Email' }).click();
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
position: {
x: 500,
@@ -243,7 +243,10 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
// Add fields
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'User 1 Signature' }).click();
await page.locator('button[role="combobox"]').nth(0).click();
await page.getByTitle('User 1 (user1@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
@@ -251,7 +254,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
},
});
await page.getByRole('button', { name: 'Email Email' }).click();
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
position: {
x: 100,
@@ -259,10 +262,10 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
},
});
await page.getByText('User 1 (user1@example.com)').click();
await page.getByText('User 3 (user3@example.com)').click();
await page.locator('button[role="combobox"]').nth(0).click();
await page.getByTitle('User 3 (user3@example.com)').click();
await page.getByRole('button', { name: 'User 3 Signature' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 500,
@@ -270,7 +273,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
},
});
await page.getByRole('button', { name: 'Email Email' }).click();
await page.getByRole('button', { name: 'Email' }).click();
await page.locator('canvas').click({
position: {
x: 500,
@@ -465,7 +468,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', async ({ page }) => {
const user = await seedUser();
const customDate = DateTime.local().toFormat('yyyy-MM-dd hh:mm a');
const customDate = DateTime.utc().toFormat('yyyy-MM-dd hh:mm a');
const { document, recipients } = await seedPendingDocumentWithFullFields({
owner: user,

View File

@@ -1,6 +1,6 @@
{
"name": "@documenso/app-tests",
"version": "1.0.0",
"version": "0.0.0",
"license": "to-update",
"description": "",
"main": "index.js",
@@ -20,4 +20,4 @@
"dependencies": {
"start-server-and-test": "^2.0.1"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@documenso/ee",
"version": "1.0.0",
"version": "0.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "COMMERCIAL",

View File

@@ -46,10 +46,13 @@ const getTransport = () => {
host: process.env.NEXT_PRIVATE_SMTP_HOST ?? 'localhost:2500',
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
auth: {
user: process.env.NEXT_PRIVATE_SMTP_USERNAME ?? '',
pass: process.env.NEXT_PRIVATE_SMTP_PASSWORD ?? '',
},
ignoreTLS: process.env.NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS === 'true',
auth: process.env.NEXT_PRIVATE_SMTP_USERNAME
? {
user: process.env.NEXT_PRIVATE_SMTP_USERNAME,
pass: process.env.NEXT_PRIVATE_SMTP_PASSWORD ?? '',
}
: undefined,
});
};

View File

@@ -1,6 +1,6 @@
{
"name": "@documenso/email",
"version": "1.0.0",
"version": "0.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "MIT",
@@ -45,4 +45,4 @@
"@types/nodemailer": "^6.4.14",
"tsup": "^7.1.0"
}
}
}

View File

@@ -5,12 +5,14 @@ export interface TemplateDocumentCompletedProps {
downloadLink: string;
documentName: string;
assetBaseUrl: string;
customBody?: string;
}
export const TemplateDocumentCompleted = ({
downloadLink,
documentName,
assetBaseUrl,
customBody,
}: TemplateDocumentCompletedProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
@@ -34,7 +36,7 @@ export const TemplateDocumentCompleted = ({
</Section>
<Text className="text-primary mb-0 text-center text-lg font-semibold">
{documentName} was signed by all signers
{customBody ?? `${documentName}” was signed by all signers`}
</Text>
<Text className="my-1 text-center text-base text-slate-400">

View File

@@ -5,12 +5,15 @@ import type { TemplateDocumentCompletedProps } from '../template-components/temp
import { TemplateDocumentCompleted } from '../template-components/template-document-completed';
import { TemplateFooter } from '../template-components/template-footer';
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps>;
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps> & {
customBody?: string;
};
export const DocumentCompletedEmailTemplate = ({
downloadLink = 'https://documenso.com',
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
customBody,
}: DocumentCompletedEmailTemplateProps) => {
const previewText = `Completed Document`;
@@ -45,6 +48,7 @@ export const DocumentCompletedEmailTemplate = ({
downloadLink={downloadLink}
documentName={documentName}
assetBaseUrl={assetBaseUrl}
customBody={customBody}
/>
</Section>
</Container>

View File

@@ -0,0 +1,82 @@
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
interface CheckboxFieldMeta {
readOnly?: boolean;
required?: boolean;
validationRule?: string;
validationLength?: number;
}
export const validateCheckboxField = (
values: string[],
fieldMeta: CheckboxFieldMeta,
isSigningPage: boolean = false,
): string[] => {
const errors = [];
const { readOnly, required, validationRule, validationLength } = fieldMeta;
if (readOnly && required) {
errors.push('A field cannot be both read-only and required');
}
if (values.length === 0) {
errors.push('At least one option must be added');
}
if (readOnly && values.length === 0) {
errors.push('A read-only field must have at least one value');
}
if (isSigningPage && required && values.length === 0) {
errors.push('Selecting an option is required');
}
if (validationRule && !validationLength) {
errors.push('You need to specify the number of options for validation');
}
if (validationLength && !validationRule) {
errors.push('You need to specify the validation rule');
}
if (validationRule && validationLength) {
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
if (validation) {
let lengthCondition = false;
switch (validation.value) {
case '=':
lengthCondition = isSigningPage
? values.length !== validationLength
: values.length < validationLength;
break;
case '>=':
lengthCondition = values.length < validationLength;
break;
case '<=':
lengthCondition = isSigningPage
? values.length > validationLength
: values.length < validationLength;
break;
}
if (lengthCondition) {
let errorMessage;
if (isSigningPage) {
errorMessage = `You need to ${validationRule.toLowerCase()} ${validationLength} options`;
} else {
errorMessage =
validation.value === '<='
? `You need to select at least ${validationLength} options`
: `You need to add at least ${validationLength} options`;
}
errors.push(errorMessage);
}
}
}
return errors;
};

View File

@@ -0,0 +1,54 @@
interface DropdownFieldMeta {
readOnly?: boolean;
required?: boolean;
values?: { value: string }[];
defaultValue?: string;
}
export const validateDropdownField = (
value: string | undefined,
fieldMeta: DropdownFieldMeta,
isSigningPage: boolean = false,
): string[] => {
const errors = [];
const { readOnly, required, values, defaultValue } = fieldMeta;
if (readOnly && required) {
errors.push('A field cannot be both read-only and required');
}
if (readOnly && (!values || values.length === 0)) {
errors.push('A read-only field must have at least one value');
}
if (isSigningPage && required && !value) {
errors.push('Choosing an option is required');
}
if (values && values.length === 0) {
errors.push('Select field must have at least one option');
}
if (values && values.length === 0 && defaultValue) {
errors.push('Default value must be one of the available options');
}
if (value && values && !values.find((item) => item.value === value)) {
errors.push('Selected value must be one of the available options');
}
if (values && defaultValue && !values.find((item) => item.value === defaultValue)) {
errors.push('Default value must be one of the available options');
}
if (values && values.some((item) => item.value.length < 1)) {
errors.push('Option value cannot be empty');
}
if (values && new Set(values.map((item) => item.value)).size !== values.length) {
errors.push('Duplicate values are not allowed');
}
return errors;
};

View File

@@ -0,0 +1,67 @@
// import { numberFormatValues } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
interface NumberFieldMeta {
minValue?: number;
maxValue?: number;
readOnly?: boolean;
required?: boolean;
numberFormat?: string;
}
export const validateNumberField = (
value: string,
fieldMeta?: NumberFieldMeta,
isSigningPage: boolean = false,
): string[] => {
const errors = [];
const { minValue, maxValue, readOnly, required, numberFormat } = fieldMeta || {};
const formatRegex: { [key: string]: RegExp } = {
'123,456,789.00': /^(?:\d{1,3}(?:,\d{3})*|\d+)(?:\.\d{1,2})?$/,
'123.456.789,00': /^(?:\d{1,3}(?:\.\d{3})*|\d+)(?:,\d{1,2})?$/,
'123456,789.00': /^(?:\d+)(?:,\d{1,3}(?:\.\d{1,2})?)?$/,
};
const isValidFormat = numberFormat ? formatRegex[numberFormat].test(value) : true;
if (!isValidFormat) {
errors.push(`Value ${value} does not match the number format - ${numberFormat}`);
}
const numberValue = parseFloat(value);
if (isSigningPage && required && !value) {
errors.push('Value is required');
}
if (!/^[0-9,.]+$/.test(value.trim())) {
errors.push(`Value is not a valid number`);
}
if (minValue !== undefined && minValue > 0 && numberValue < minValue) {
errors.push(`Value ${value} is less than the minimum value of ${minValue}`);
}
if (maxValue !== undefined && maxValue > 0 && numberValue > maxValue) {
errors.push(`Value ${value} is greater than the maximum value of ${maxValue}`);
}
if (minValue !== undefined && maxValue !== undefined && minValue > maxValue) {
errors.push('Minimum value cannot be greater than maximum value');
}
if (maxValue !== undefined && minValue !== undefined && maxValue < minValue) {
errors.push('Maximum value cannot be less than minimum value');
}
if (readOnly && numberValue < 1) {
errors.push('A read-only field must have a value greater than 0');
}
if (readOnly && required) {
errors.push('A field cannot be both read-only and required');
}
return errors;
};

View File

@@ -0,0 +1,41 @@
interface RadioFieldMeta {
readOnly?: boolean;
required?: boolean;
values?: { checked: boolean; value: string }[];
}
export const validateRadioField = (
value: string | undefined,
fieldMeta: RadioFieldMeta,
isSigningPage: boolean = false,
): string[] => {
const errors = [];
const { readOnly, required, values } = fieldMeta;
if (readOnly && required) {
errors.push('A field cannot be both read-only and required');
}
if (readOnly && (!values || values.length === 0)) {
errors.push('A read-only field must have at least one value');
}
if (isSigningPage && required && !value) {
errors.push('Choosing an option is required');
}
if (values) {
const checkedRadioFieldValues = values.filter((option) => option.checked);
if (values.length === 0) {
errors.push('Radio field must have at least one option');
}
if (checkedRadioFieldValues.length > 1) {
errors.push('There cannot be more than one checked option');
}
}
return errors;
};

View File

@@ -0,0 +1,33 @@
interface TextFieldMeta {
characterLimit?: number;
readOnly?: boolean;
required?: boolean;
}
export const validateTextField = (
value: string,
fieldMeta: TextFieldMeta,
isSigningPage: boolean = false,
): string[] => {
const errors = [];
const { characterLimit, readOnly, required } = fieldMeta;
if (required && !value && isSigningPage) {
errors.push('Value is required');
}
if (characterLimit !== undefined && characterLimit > 0 && value.length > characterLimit) {
errors.push(`Value length (${value.length}) exceeds the character limit (${characterLimit})`);
}
if (readOnly && value.length < 1) {
errors.push('A read-only field must have text');
}
if (readOnly && required) {
errors.push('A field cannot be both read-only and required');
}
return errors;
};

View File

@@ -0,0 +1,25 @@
import { useMemo } from 'react';
import type { CombinedStylesKey } from '../../../ui/primitives/document-flow/add-fields';
import { combinedStyles } from '../../../ui/primitives/document-flow/field-item';
const defaultFieldItemStyles = {
borderClass: 'border-field-card-border',
activeBorderClass: 'border-field-card-border/80',
initialsBGClass: 'text-field-card-foreground/50 bg-slate-900/10',
fieldBackground: 'bg-field-card-background',
};
export const useFieldItemStyles = (color: CombinedStylesKey | null) => {
return useMemo(() => {
if (!color) return defaultFieldItemStyles;
const selectedColorVariant = combinedStyles[color];
return {
activeBorderClass: selectedColorVariant?.borderActive,
borderClass: selectedColorVariant?.border,
initialsBGClass: selectedColorVariant?.initialsBG,
fieldBackground: selectedColorVariant?.fieldBackground,
};
}, [color]);
};

View File

@@ -1,6 +1,6 @@
{
"name": "@documenso/lib",
"version": "1.0.0",
"version": "0.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "MIT",

View File

@@ -38,7 +38,7 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
const senderName = NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
const senderAdress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
const senderAddress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
assetBaseUrl,
@@ -52,7 +52,7 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
},
from: {
name: senderName,
address: senderAdress,
address: senderAddress,
},
subject: 'Please confirm your email',
html: render(confirmationTemplate),

View File

@@ -11,6 +11,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
title: string;
externalId?: string | null;
userId: number;
teamId?: number;
documentDataId: string;
@@ -21,6 +22,7 @@ export type CreateDocumentOptions = {
export const createDocument = async ({
userId,
title,
externalId,
documentDataId,
teamId,
formValues,
@@ -50,6 +52,7 @@ export const createDocument = async ({
const document = await tx.document.create({
data: {
title,
externalId,
documentDataId,
userId,
teamId,

View File

@@ -7,7 +7,7 @@ import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
@@ -178,6 +178,10 @@ const handleDocumentOwnerDelete = async ({
// Send cancellation emails to recipients.
await Promise.all(
document.Recipient.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {

View File

@@ -31,7 +31,7 @@ export const findDocumentAuditLogs = async ({
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
await prisma.document.findFirstOrThrow({
const documentFilter = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
@@ -67,6 +67,7 @@ export const findDocumentAuditLogs = async ({
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM,
],
},
},

View File

@@ -0,0 +1,81 @@
import { TRPCError } from '@trpc/server';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type MoveDocumentToTeamOptions = {
documentId: number;
teamId: number;
userId: number;
requestMetadata?: RequestMetadata;
};
export const moveDocumentToTeam = async ({
documentId,
teamId,
userId,
requestMetadata,
}: MoveDocumentToTeamOptions) => {
return await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({
where: { id: userId },
});
const document = await tx.document.findFirst({
where: {
id: documentId,
userId,
teamId: null,
},
});
if (!document) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Document not found or already associated with a team.',
});
}
const team = await tx.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
if (!team) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not a member of this team.',
});
}
const updatedDocument = await tx.document.update({
where: { id: documentId },
data: { teamId },
});
const log = await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM,
documentId: updatedDocument.id,
user,
requestMetadata,
data: {
movedByUserId: userId,
fromPersonalAccount: true,
toTeamId: teamId,
},
}),
});
return updatedDocument;
});
};

View File

@@ -117,6 +117,9 @@ export const sealDocument = async ({
await insertFieldInPDF(doc, field);
}
// Re-flatten post-insertion to handle fields that create arcoFields
flattenForm(doc);
const pdfBytes = await doc.save();
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });

View File

@@ -4,12 +4,14 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
import { prisma } from '@documenso/prisma';
import { DocumentSource } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
export interface SendDocumentOptions {
documentId: number;
@@ -23,6 +25,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
},
include: {
documentData: true,
documentMeta: true,
Recipient: true,
User: true,
team: {
@@ -38,6 +41,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
throw new Error('Document not found');
}
const isDirectTemplate = document?.source === DocumentSource.TEMPLATE_DIRECT_LINK;
if (document.Recipient.length === 0) {
throw new Error('Document has no recipients');
}
@@ -106,12 +111,22 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
await Promise.all(
document.Recipient.map(async (recipient) => {
const customEmailTemplate = {
'signer.name': recipient.name,
'signer.email': recipient.email,
'document.name': document.title,
};
const downloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}/complete`;
const template = createElement(DocumentCompletedEmailTemplate, {
documentName: document.title,
assetBaseUrl,
downloadLink: recipient.email === owner.email ? documentOwnerDownloadLink : downloadLink,
customBody:
isDirectTemplate && document.documentMeta?.message
? renderCustomEmailTemplate(document.documentMeta.message, customEmailTemplate)
: undefined,
});
await mailer.sendMail({
@@ -125,7 +140,10 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
subject:
isDirectTemplate && document.documentMeta?.subject
? renderCustomEmailTemplate(document.documentMeta.subject, customEmailTemplate)
: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
attachments: [

View File

@@ -1,5 +1,4 @@
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
@@ -161,14 +160,7 @@ export const sendDocument = async ({
);
if (allRecipientsHaveNoActionToTake) {
const updatedDocument = await updateDocument({
documentId,
userId,
teamId,
data: { status: DocumentStatus.COMPLETED },
});
await sealDocument({ documentId: updatedDocument.id, requestMetadata });
await sealDocument({ documentId, requestMetadata });
// Keep the return type the same for the `sendDocument` method
return await prisma.document.findFirstOrThrow({

View File

@@ -6,7 +6,7 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
@@ -41,6 +41,10 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
await Promise.all(
document.Recipient.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,

View File

@@ -18,6 +18,7 @@ export type UpdateDocumentSettingsOptions = {
documentId: number;
data: {
title?: string;
externalId?: string | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
};
@@ -91,6 +92,7 @@ export const updateDocumentSettings = async ({
}
const isTitleSame = data.title === document.title;
const isExternalIdSame = data.externalId === document.externalId;
const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth;
const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth;
@@ -118,6 +120,21 @@ export const updateDocumentSettings = async ({
);
}
if (!isExternalIdSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.externalId,
to: data.externalId || '',
},
}),
);
}
if (!isGlobalAccessSame) {
auditLogs.push(
createDocumentAuditLogData({
@@ -165,6 +182,7 @@ export const updateDocumentSettings = async ({
},
data: {
title: data.title,
externalId: data.externalId || null,
authOptions,
},
});

View File

@@ -1,15 +1,47 @@
import { prisma } from '@documenso/prisma';
export type GetFieldByIdOptions = {
userId: number;
teamId?: number;
fieldId: number;
documentId: number;
documentId?: number;
templateId?: number;
};
export const getFieldById = async ({ fieldId, documentId }: GetFieldByIdOptions) => {
export const getFieldById = async ({
userId,
teamId,
fieldId,
documentId,
templateId,
}: GetFieldByIdOptions) => {
const field = await prisma.field.findFirst({
where: {
id: fieldId,
documentId,
templateId,
Document: {
OR:
teamId === undefined
? [
{
userId,
teamId: null,
},
]
: [
{
teamId,
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
});

View File

@@ -1,12 +1,26 @@
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import {
type TFieldMetaSchema as FieldMeta,
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldMetaSchema,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffFieldChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { Field, FieldType } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetFieldsForDocumentOptions {
userId: number;
@@ -20,6 +34,7 @@ export interface SetFieldsForDocumentOptions {
pageY: number;
pageWidth: number;
pageHeight: number;
fieldMeta?: FieldMeta;
}[];
requestMetadata?: RequestMetadata;
}
@@ -103,6 +118,83 @@ export const setFieldsForDocument = async ({
linkedFields.map(async (field) => {
const fieldSignerEmail = field.signerEmail.toLowerCase();
const parsedFieldMeta = field.fieldMeta
? ZFieldMetaSchema.parse(field.fieldMeta)
: undefined;
if (field.type === FieldType.TEXT && field.fieldMeta) {
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
const errors = validateTextField(textFieldParsedMeta.text || '', textFieldParsedMeta);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value),
numberFieldParsedMeta,
);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.CHECKBOX) {
if (field.fieldMeta) {
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const errors = validateCheckboxField(
checkboxFieldParsedMeta?.values?.map((item) => item.value) ?? [],
checkboxFieldParsedMeta,
);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
} else {
throw new Error(
'To proceed further, please set at least one value for the Checkbox field',
);
}
}
if (field.type === FieldType.RADIO) {
if (field.fieldMeta) {
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedRadioFieldValue = radioFieldParsedMeta.values?.find(
(option) => option.checked,
)?.value;
const errors = validateRadioField(checkedRadioFieldValue, radioFieldParsedMeta);
if (errors.length > 0) {
throw new Error(errors.join('. '));
}
} else {
throw new Error(
'To proceed further, please set at least one value for the Radio field',
);
}
}
if (field.type === FieldType.DROPDOWN) {
if (field.fieldMeta) {
const dropdownFieldParsedMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(undefined, dropdownFieldParsedMeta);
if (errors.length > 0) {
throw new Error(errors.join('. '));
}
} else {
throw new Error(
'To proceed further, please set at least one value for the Dropdown field',
);
}
}
const upsertedField = await tx.field.upsert({
where: {
id: field._persisted?.id ?? -1,
@@ -114,6 +206,7 @@ export const setFieldsForDocument = async ({
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
fieldMeta: parsedFieldMeta,
},
create: {
type: field.type,
@@ -124,6 +217,7 @@ export const setFieldsForDocument = async ({
height: field.pageHeight,
customText: '',
inserted: false,
fieldMeta: parsedFieldMeta,
Document: {
connect: {
id: documentId,

View File

@@ -1,5 +1,19 @@
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import {
type TFieldMetaSchema as FieldMeta,
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldMetaSchema,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
export type SetFieldsForTemplateOptions = {
userId: number;
@@ -13,6 +27,7 @@ export type SetFieldsForTemplateOptions = {
pageY: number;
pageWidth: number;
pageHeight: number;
fieldMeta?: FieldMeta;
}[];
};
@@ -70,8 +85,60 @@ export const setFieldsForTemplate = async ({
const persistedFields = await prisma.$transaction(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedFields.map((field) =>
prisma.field.upsert({
linkedFields.map((field) => {
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
if (field.type === FieldType.TEXT && field.fieldMeta) {
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
const errors = validateTextField(textFieldParsedMeta.text || '', textFieldParsedMeta);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value),
numberFieldParsedMeta,
);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.CHECKBOX && field.fieldMeta) {
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const errors = validateCheckboxField(
checkboxFieldParsedMeta?.values?.map((item) => item.value) ?? [],
checkboxFieldParsedMeta,
);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.RADIO && field.fieldMeta) {
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedRadioFieldValue = radioFieldParsedMeta.values?.find(
(option) => option.checked,
)?.value;
const errors = validateRadioField(checkedRadioFieldValue, radioFieldParsedMeta);
if (errors.length > 0) {
throw new Error(errors.join('. '));
}
}
if (field.type === FieldType.DROPDOWN && field.fieldMeta) {
const dropdownFieldParsedMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(undefined, dropdownFieldParsedMeta);
if (errors.length > 0) {
throw new Error(errors.join('. '));
}
}
// Proceed with upsert operation
return prisma.field.upsert({
where: {
id: field._persisted?.id ?? -1,
templateId,
@@ -82,6 +149,7 @@ export const setFieldsForTemplate = async ({
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
fieldMeta: parsedFieldMeta,
},
create: {
type: field.type,
@@ -92,6 +160,7 @@ export const setFieldsForTemplate = async ({
height: field.pageHeight,
customText: '',
inserted: false,
fieldMeta: parsedFieldMeta,
Template: {
connect: {
id: templateId,
@@ -106,8 +175,8 @@ export const setFieldsForTemplate = async ({
},
},
},
}),
),
});
}),
);
if (removedFields.length > 0) {

View File

@@ -3,6 +3,11 @@
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
@@ -10,6 +15,13 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuth } from '../../types/document-auth';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { validateFieldAuth } from '../document/validate-field-auth';
@@ -87,6 +99,52 @@ export const signFieldWithToken = async ({
throw new Error(`Field ${fieldId} has no recipientId`);
}
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(value, numberFieldParsedMeta, true);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.TEXT && field.fieldMeta) {
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
const errors = validateTextField(value, textFieldParsedMeta, true);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.CHECKBOX && field.fieldMeta) {
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkboxFieldValues = value.split(',');
const errors = validateCheckboxField(checkboxFieldValues, checkboxFieldParsedMeta, true);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.RADIO && field.fieldMeta) {
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const errors = validateRadioField(value, radioFieldParsedMeta, true);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
if (field.type === FieldType.DROPDOWN && field.fieldMeta) {
const dropdownFieldParsedMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(value, dropdownFieldParsedMeta, true);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
}
const derivedRecipientActionAuth = await validateFieldAuth({
documentAuthOptions: document.authOptions,
recipient,
@@ -177,6 +235,16 @@ export const signFieldWithToken = async ({
type,
data: updatedField.customText,
}))
.with(
FieldType.NUMBER,
FieldType.RADIO,
FieldType.CHECKBOX,
FieldType.DROPDOWN,
(type) => ({
type,
data: updatedField.customText,
}),
)
.exhaustive(),
fieldSecurity: derivedRecipientActionAuth
? {

View File

@@ -1,3 +1,4 @@
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import type { FieldType, Team } from '@documenso/prisma/client';
@@ -18,6 +19,7 @@ export type UpdateFieldOptions = {
pageWidth?: number;
pageHeight?: number;
requestMetadata?: RequestMetadata;
fieldMeta?: FieldMeta;
};
export const updateField = async ({
@@ -33,6 +35,7 @@ export const updateField = async ({
pageWidth,
pageHeight,
requestMetadata,
fieldMeta,
}: UpdateFieldOptions) => {
const oldField = await prisma.field.findFirstOrThrow({
where: {
@@ -71,6 +74,7 @@ export const updateField = async ({
positionY: pageY,
width: pageWidth,
height: pageHeight,
fieldMeta,
},
include: {
Recipient: true,

View File

@@ -1,7 +1,7 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { PDFDocument, RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
import { match } from 'ts-pattern';
import { P, match } from 'ts-pattern';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
@@ -13,6 +13,8 @@ import { FieldType } from '@documenso/prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const fontCaveat = await fetch(process.env.FONT_CAVEAT_URI).then(async (res) =>
res.arrayBuffer(),
@@ -77,86 +79,163 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
await pdf.embedFont(fontCaveat);
}
const isInsertingImage =
isSignatureField && typeof field.Signature?.signatureImageAsBase64 === 'string';
await match(field)
.with(
{
type: P.union(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE),
Signature: { signatureImageAsBase64: P.string },
},
async (field) => {
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
if (isSignatureField && isInsertingImage) {
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
let imageWidth = image.width;
let imageHeight = image.height;
let imageWidth = image.width;
let imageHeight = image.height;
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
imageY = pageHeight - imageY - imageHeight;
// Invert the Y axis since PDFs use a bottom-left coordinate system
imageY = pageHeight - imageY - imageHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
imageX,
imageY,
pageRotationInDegrees,
);
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
imageX,
imageY,
pageRotationInDegrees,
);
imageX = adjustedPosition.xPos;
imageY = adjustedPosition.yPos;
}
imageX = adjustedPosition.xPos;
imageY = adjustedPosition.yPos;
}
page.drawImage(image, {
x: imageX,
y: imageY,
width: imageWidth,
height: imageHeight,
rotate: degrees(pageRotationInDegrees),
});
},
)
.with({ type: FieldType.CHECKBOX }, (field) => {
const meta = ZCheckboxFieldMeta.safeParse(field.fieldMeta);
page.drawImage(image, {
x: imageX,
y: imageY,
width: imageWidth,
height: imageHeight,
rotate: degrees(pageRotationInDegrees),
if (!meta.success) {
console.error(meta.error);
throw new Error('Invalid checkbox field meta');
}
const selected = field.customText.split(',');
for (const [index, item] of (meta.data.values ?? []).entries()) {
const offsetY = index * 16;
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
if (selected.includes(item.value)) {
checkbox.check();
}
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
checkbox.addToPage(page, {
x: fieldX,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
}
})
.with({ type: FieldType.RADIO }, (field) => {
const meta = ZRadioFieldMeta.safeParse(field.fieldMeta);
if (!meta.success) {
console.error(meta.error);
throw new Error('Invalid radio field meta');
}
const selected = field.customText.split(',');
for (const [index, item] of (meta.data.values ?? []).entries()) {
const offsetY = index * 16;
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
radio.addOptionToPage(item.value, page, {
x: fieldX,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
if (selected.includes(item.value)) {
radio.select(item.value);
}
}
})
.otherwise((field) => {
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textHeight = font.heightAtSize(fontSize);
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
let textX = fieldX + (fieldWidth - textWidth) / 2;
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
}
page.drawText(field.customText, {
x: textX,
y: textY,
size: fontSize,
font,
rotate: degrees(pageRotationInDegrees),
});
});
} else {
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textHeight = font.heightAtSize(fontSize);
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
let textX = fieldX + (fieldWidth - textWidth) / 2;
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
}
page.drawText(field.customText, {
x: textX,
y: textY,
size: fontSize,
font,
rotate: degrees(pageRotationInDegrees),
});
}
return pdf;
};

View File

@@ -15,6 +15,7 @@ export const getTeamInvitations = async ({ email }: GetTeamInvitationsOptions) =
id: true,
name: true,
url: true,
avatarImageId: true,
},
},
},

View File

@@ -13,6 +13,7 @@ import {
DocumentSource,
DocumentStatus,
FieldType,
Prisma,
RecipientRole,
SendStatus,
SigningStatus,
@@ -26,6 +27,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { ZFieldMetaSchema } from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
@@ -39,6 +41,7 @@ import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
export type CreateDocumentFromDirectTemplateOptions = {
directRecipientName?: string;
directRecipientEmail: string;
directTemplateToken: string;
signedFieldValues: TSignFieldWithTokenMutationSchema[];
@@ -57,6 +60,7 @@ type CreatedDirectRecipientField = {
};
export const createDocumentFromDirectTemplate = async ({
directRecipientName: initialDirectRecipientName,
directRecipientEmail,
directTemplateToken,
signedFieldValues,
@@ -110,7 +114,7 @@ export const createDocumentFromDirectTemplate = async ({
documentAuth: template.authOptions,
});
const directRecipientName = user?.name;
const directRecipientName = user?.name || initialDirectRecipientName;
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth)
@@ -132,6 +136,8 @@ export const createDocumentFromDirectTemplate = async ({
const metaTimezone = template.templateMeta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE;
const metaDateFormat = template.templateMeta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT;
const metaEmailMessage = template.templateMeta?.message || '';
const metaEmailSubject = template.templateMeta?.subject || '';
// Associate, validate and map to a query every direct template recipient field with the provided fields.
const createDirectRecipientFieldArgs = await Promise.all(
@@ -250,6 +256,14 @@ export const createDocumentFromDirectTemplate = async ({
}),
},
},
documentMeta: {
create: {
timezone: metaTimezone,
dateFormat: metaDateFormat,
message: metaEmailMessage,
subject: metaEmailSubject,
},
},
},
include: {
Recipient: true,
@@ -284,12 +298,16 @@ export const createDocumentFromDirectTemplate = async ({
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
})),
);
});
await tx.field.createMany({
data: nonDirectRecipientFieldsToCreate,
data: nonDirectRecipientFieldsToCreate.map((field) => ({
...field,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
})),
});
// Create the direct recipient and their non signature fields.
@@ -319,6 +337,7 @@ export const createDocumentFromDirectTemplate = async ({
height: templateField.height,
customText,
inserted: true,
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
})),
},
},
@@ -349,6 +368,7 @@ export const createDocumentFromDirectTemplate = async ({
height: templateField.height,
customText: '',
inserted: true,
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
Signature: {
create: {
recipientId: createdDirectRecipient.id,
@@ -442,10 +462,20 @@ export const createDocumentFromDirectTemplate = async ({
data:
field.Signature?.signatureImageAsBase64 || field.Signature?.typedSignature || '',
}))
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({
type,
data: field.customText,
}))
.with(
FieldType.DATE,
FieldType.EMAIL,
FieldType.NAME,
FieldType.TEXT,
FieldType.NUMBER,
FieldType.CHECKBOX,
FieldType.DROPDOWN,
FieldType.RADIO,
(type) => ({
type,
data: field.customText,
}),
)
.exhaustive(),
fieldSecurity: derivedRecipientActionAuth
? {

View File

@@ -13,6 +13,7 @@ import {
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { ZFieldMetaSchema } from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
@@ -33,6 +34,7 @@ export type CreateDocumentFromTemplateResponse = Awaited<
export type CreateDocumentFromTemplateOptions = {
templateId: number;
externalId?: string | null;
userId: number;
teamId?: number;
recipients: {
@@ -58,6 +60,7 @@ export type CreateDocumentFromTemplateOptions = {
export const createDocumentFromTemplate = async ({
templateId,
externalId,
userId,
teamId,
recipients,
@@ -147,6 +150,7 @@ export const createDocumentFromTemplate = async ({
const document = await tx.document.create({
data: {
source: DocumentSource.TEMPLATE,
externalId,
templateId: template.id,
userId,
teamId: template.teamId,
@@ -222,12 +226,16 @@ export const createDocumentFromTemplate = async ({
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
})),
);
});
await tx.field.createMany({
data: fieldsToCreate,
data: fieldsToCreate.map((field) => ({
...field,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
})),
});
await tx.documentAuditLog.create({

View File

@@ -0,0 +1,57 @@
import { TRPCError } from '@trpc/server';
import { prisma } from '@documenso/prisma';
export type MoveTemplateToTeamOptions = {
templateId: number;
teamId: number;
userId: number;
};
export const moveTemplateToTeam = async ({
templateId,
teamId,
userId,
}: MoveTemplateToTeamOptions) => {
return await prisma.$transaction(async (tx) => {
const template = await tx.template.findFirst({
where: {
id: templateId,
userId,
teamId: null,
},
});
if (!template) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Template not found or already associated with a team.',
});
}
const team = await tx.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
if (!team) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not a member of this team.',
});
}
const updatedTemplate = await tx.template.update({
where: { id: templateId },
data: { teamId },
});
return updatedTemplate;
});
};

View File

@@ -15,6 +15,7 @@ export type UpdateTemplateSettingsOptions = {
templateId: number;
data: {
title?: string;
externalId?: string | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
publicTitle?: string;
@@ -99,6 +100,7 @@ export const updateTemplateSettings = async ({
},
data: {
title: data.title,
externalId: data.externalId || null,
type: data.type,
publicDescription: data.publicDescription,
publicTitle: data.publicTitle,

View File

@@ -0,0 +1,34 @@
import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
export const getSignerConversionMonthly = async () => {
const qb = kyselyPrisma.$kysely
.selectFrom('Recipient')
.innerJoin('User', 'Recipient.email', 'User.email')
.select(({ fn }) => [
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']).as('month'),
fn.count('Recipient.email').distinct().as('count'),
fn
.sum(fn.count('Recipient.email').distinct())
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']) as any))
.as('cume_count'),
])
.where('Recipient.signedAt', 'is not', null)
.where('Recipient.signedAt', '<', (eb) => eb.ref('User.createdAt'))
.groupBy(({ fn }) => fn('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']))
.orderBy('month', 'desc');
const result = await qb.execute();
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
cume_count: Number(row.cume_count),
}));
};
export type GetSignerConversionMonthlyResult = Awaited<
ReturnType<typeof getSignerConversionMonthly>
>;

View File

@@ -35,6 +35,8 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
]);
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
@@ -251,6 +253,22 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.RADIO),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.CHECKBOX),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.DROPDOWN),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.NUMBER),
data: z.string(),
}),
]),
fieldSecurity: z.preprocess(
(input) => {
@@ -354,6 +372,17 @@ export const ZDocumentAuditLogEventDocumentTitleUpdatedSchema = z.object({
}),
});
/**
* Event: Document external ID updated.
*/
export const ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED),
data: z.object({
from: z.string().nullish(),
to: z.string().nullish(),
}),
});
/**
* Event: Field created.
*/
@@ -410,6 +439,18 @@ export const ZDocumentAuditLogEventRecipientRemovedSchema = z.object({
data: ZBaseRecipientDataSchema,
});
/**
* Event: Document moved to team.
*/
export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM),
data: z.object({
movedByUserId: z.number(),
fromPersonalAccount: z.boolean(),
toTeamId: z.number(),
}),
});
export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
@@ -427,6 +468,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentDeletedSchema,
ZDocumentAuditLogEventDocumentMovedToTeamSchema,
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema,
@@ -436,6 +478,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
ZDocumentAuditLogEventDocumentSentSchema,
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,
ZDocumentAuditLogEventFieldCreatedSchema,
ZDocumentAuditLogEventFieldRemovedSchema,
ZDocumentAuditLogEventFieldUpdatedSchema,

View File

@@ -0,0 +1,80 @@
import { z } from 'zod';
export const ZBaseFieldMeta = z.object({
label: z.string().optional(),
placeholder: z.string().optional(),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
});
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
export const ZTextFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('text').default('text'),
text: z.string().optional(),
characterLimit: z.number().optional(),
});
export type TTextFieldMeta = z.infer<typeof ZTextFieldMeta>;
export const ZNumberFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('number').default('number'),
numberFormat: z.string().optional(),
value: z.string().optional(),
minValue: z.number().optional(),
maxValue: z.number().optional(),
});
export type TNumberFieldMeta = z.infer<typeof ZNumberFieldMeta>;
export const ZRadioFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('radio').default('radio'),
values: z
.array(
z.object({
id: z.number(),
checked: z.boolean(),
value: z.string(),
}),
)
.optional(),
});
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
export const ZCheckboxFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('checkbox').default('checkbox'),
values: z
.array(
z.object({
id: z.number(),
checked: z.boolean(),
value: z.string(),
}),
)
.optional(),
validationRule: z.string().optional(),
validationLength: z.number().optional(),
});
export type TCheckboxFieldMeta = z.infer<typeof ZCheckboxFieldMeta>;
export const ZDropdownFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('dropdown').default('dropdown'),
values: z.array(z.object({ value: z.string() })).optional(),
defaultValue: z.string().optional(),
});
export type TDropdownFieldMeta = z.infer<typeof ZDropdownFieldMeta>;
export const ZFieldMetaSchema = z
.union([
ZTextFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
])
.optional();
export type TFieldMetaSchema = z.infer<typeof ZFieldMetaSchema>;

View File

@@ -332,10 +332,18 @@ export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId
anonymous: 'Document title updated',
identified: 'updated the document title',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED }, () => ({
anonymous: 'Document external ID updated',
identified: 'updated the document external ID',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
anonymous: 'Document sent',
identified: 'sent the document',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, () => ({
anonymous: 'Document moved to team',
identified: 'moved the document to team',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const action = RECIPIENT_ROLES_DESCRIPTION[data.recipientRole as RecipientRole]?.actioned;

View File

@@ -1,4 +1,4 @@
import { Field } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
/**
* Sort the fields by the Y position on the document.

View File

@@ -0,0 +1,12 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "FieldType" ADD VALUE 'NUMBER';
ALTER TYPE "FieldType" ADD VALUE 'RADIO';
ALTER TYPE "FieldType" ADD VALUE 'CHECKBOX';
ALTER TYPE "FieldType" ADD VALUE 'DROPDOWN';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Field" ADD COLUMN "fieldMeta" JSONB;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "externalId" TEXT;
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "externalId" TEXT;

View File

@@ -1,6 +1,6 @@
{
"name": "@documenso/prisma",
"version": "1.0.0",
"version": "0.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "MIT",
@@ -34,4 +34,4 @@
"tsx": "^4.11.0",
"typescript": "5.2.2"
}
}
}

View File

@@ -283,6 +283,7 @@ enum DocumentSource {
model Document {
id Int @id @default(autoincrement())
externalId String?
userId Int
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
authOptions Json?
@@ -412,6 +413,10 @@ enum FieldType {
EMAIL
DATE
TEXT
NUMBER
RADIO
CHECKBOX
DROPDOWN
}
model Field {
@@ -432,6 +437,7 @@ model Field {
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Signature Signature?
fieldMeta Json?
@@index([documentId])
@@index([templateId])
@@ -589,6 +595,7 @@ model TemplateMeta {
model Template {
id Int @id @default(autoincrement())
externalId String?
type TemplateType @default(PRIVATE)
title String
userId Int

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