mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
175 Commits
v1.5.3-rc.
...
feat/updat
| Author | SHA1 | Date | |
|---|---|---|---|
| dc035ed08c | |||
| fb2fd17ad8 | |||
| 6285ef2cc0 | |||
| e2987b3ef1 | |||
| d97ab04d57 | |||
| 665c943d8f | |||
| 8fe6533ef5 | |||
| fd170f095b | |||
| 1400c335a5 | |||
| 03bf16522d | |||
| 627265f016 | |||
| 08b693ff95 | |||
| 97ce3530e0 | |||
| 33f3565715 | |||
| 950a697115 | |||
| fc70f78e61 | |||
| aa52316ee3 | |||
| ea64ccae29 | |||
| b87154001a | |||
| d4a7eb299e | |||
| 2ef619226e | |||
| 65c07032de | |||
| 56c550c9d2 | |||
| d1ffcb00f3 | |||
| 58481f66b8 | |||
| 484f603a6b | |||
| 48a8f5fe07 | |||
| cbe6270494 | |||
| b436331d7d | |||
| 81ee582f1c | |||
| 81ab220f1e | |||
| cc60437dcd | |||
| 171b8008f8 | |||
| 5c00b82894 | |||
| 369357aadd | |||
| 117d9427c3 | |||
| 7a689aecae | |||
| 1c54f69a5a | |||
| a56bf6a192 | |||
| a54eb54ef7 | |||
| 956562d3b4 | |||
| f386dd31a7 | |||
| c644d527df | |||
| 47cf20931a | |||
| b491bd4db9 | |||
| 038370012f | |||
| 4d2228f679 | |||
| 0aa111cd6e | |||
| ba30d4368d | |||
| 899205dde8 | |||
| 9eaecfcef2 | |||
| 26141050b7 | |||
| 5b4152ffc5 | |||
| bd703fb620 | |||
| 2296924ef6 | |||
| 6603aa6f2e | |||
| a6ddc114d9 | |||
| abb49c349c | |||
| 006b732edb | |||
| 5210fe2963 | |||
| 994368156f | |||
| 3eddfcc805 | |||
| 43400c07de | |||
| 715c14a6ae | |||
| 606966b357 | |||
| 24852f3c68 | |||
| 48ee977617 | |||
| fc34f1c045 | |||
| 1725af71b6 | |||
| c71347aeb9 | |||
| 007687bdee | |||
| f5a1d9a625 | |||
| 72fd1eead2 | |||
| 5289ae2fbc | |||
| c4c6e67249 | |||
| 5377d27c6a | |||
| 1cd7dd236b | |||
| 67beb8225c | |||
| 94198e7584 | |||
| facafe0997 | |||
| 8c1686f113 | |||
| a8752098f6 | |||
| 3e15b5d745 | |||
| 0dfdf36bda | |||
| 574cd176c2 | |||
| 48858cfdd0 | |||
| 2facc0e331 | |||
| e7071f1f5a | |||
| b95f7176e2 | |||
| 6d754acfcd | |||
| 796456929e | |||
| de9c9f4aab | |||
| b972056c8f | |||
| 69871e7d39 | |||
| 9cfd769356 | |||
| bd20c5499f | |||
| 3c6cc7fd46 | |||
| 4ac800fa78 | |||
| 3598bd0139 | |||
| d62838f4a0 | |||
| 8de8139b85 | |||
| a7594c9b3c | |||
| f012826b6b | |||
| 38752f95f3 | |||
| 4379b43ad9 | |||
| 8859b2779f | |||
| e29bfbf5e0 | |||
| 17c6a4bd55 | |||
| d6668ad204 | |||
| 91e1fe5e8f | |||
| fd4d5468cf | |||
| d5c4885c67 | |||
| 564f0dd945 | |||
| 524a7918d5 | |||
| 0db2e6643d | |||
| f5967e28c3 | |||
| 4926b6de50 | |||
| d6c8a3d32c | |||
| a9bb559568 | |||
| 8d1da3df72 | |||
| 00c71fd66c | |||
| df8d394c28 | |||
| bc3c9424c4 | |||
| 6643c4b9fc | |||
| ec7b69f1a4 | |||
| 0488442652 | |||
| cc483016d8 | |||
| 025af6e9f4 | |||
| e5497efe7c | |||
| bba1ea81d6 | |||
| 364aaa4cb6 | |||
| af6ec5df42 | |||
| 35c1b0bcee | |||
| 487bc026f9 | |||
| 3fb57c877e | |||
| 27e7e51789 | |||
| 52afae331e | |||
| 27a69819f9 | |||
| 4dc9e1295b | |||
| a8413fa031 | |||
| 3b65447b0f | |||
| d8911ee97b | |||
| c10cfbf6e1 | |||
| 884eab36eb | |||
| d0b9cee500 | |||
| a178e1d86f | |||
| 1fd29f7e89 | |||
| d3f4e20f1c | |||
| 3ebeb347c5 | |||
| f6c2b6c1c5 | |||
| efb90ca5fb | |||
| 9ac346443d | |||
| f2aa0cd714 | |||
| bbcb90d8a5 | |||
| 63c23301a9 | |||
| 62b4a13d4d | |||
| 19714fb807 | |||
| 7631c6e90e | |||
| d462ca0b46 | |||
| b433225762 | |||
| ad92b1ac23 | |||
| a4806f933e | |||
| 0d41c6babf | |||
| 9b5346efef | |||
| e8b209eb82 | |||
| 0fdb7f7a8d | |||
| 61ca34eee1 | |||
| 41843691e8 | |||
| c463d5a0ed | |||
| 8afe669978 | |||
| ee2cb0eedf | |||
| 6fc3803ad2 | |||
| ab8c8e2a57 | |||
| 6e22eff5a1 | |||
| 98667dac15 |
@ -1,13 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
# Start the database and mailserver
|
|
||||||
docker compose -f ./docker/compose-without-app.yml up -d
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Copy the env file
|
# Copy the env file
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# Run the migrations
|
# Run the dev setup
|
||||||
npm run prisma:migrate-dev
|
npm run dx
|
||||||
|
|||||||
32
.env.example
32
.env.example
@ -22,26 +22,32 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
|
|||||||
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
||||||
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||||
|
|
||||||
# [[E2E Tests]]
|
|
||||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
|
||||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
|
||||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
|
||||||
|
|
||||||
# [[SIGNING]]
|
# [[SIGNING]]
|
||||||
# OPTIONAL: Defines the signing transport to use. Available options: local (default)
|
# The transport to use for document signing. Available options: local (default) | gcloud-hsm
|
||||||
NEXT_PRIVATE_SIGNING_TRANSPORT="local"
|
NEXT_PRIVATE_SIGNING_TRANSPORT="local"
|
||||||
# OPTIONAL: Defines the passphrase for the signing certificate.
|
# OPTIONAL: The passphrase to use for the local file-based signing transport.
|
||||||
NEXT_PRIVATE_SIGNING_PASSPHRASE=
|
NEXT_PRIVATE_SIGNING_PASSPHRASE=
|
||||||
# OPTIONAL: Defines the file contents for the signing certificate as a base64 encoded string.
|
# OPTIONAL: The local file path to the .p12 file to use for the local signing transport.
|
||||||
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS=
|
|
||||||
# OPTIONAL: Defines the file path for the signing certificate. defaults to ./example/cert.p12
|
|
||||||
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=
|
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=
|
||||||
|
# OPTIONAL: The base64-encoded contents of the .p12 file to use for the local signing transport.
|
||||||
|
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS=
|
||||||
|
# OPTIONAL: The path to the Google Cloud HSM key to use for the gcloud-hsm signing transport.
|
||||||
|
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH=
|
||||||
|
# OPTIONAL: The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport.
|
||||||
|
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH=
|
||||||
|
# OPTIONAL: The base64-encoded contents of the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport.
|
||||||
|
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS=
|
||||||
|
# OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport.
|
||||||
|
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS=
|
||||||
|
|
||||||
# [[STORAGE]]
|
# [[STORAGE]]
|
||||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||||
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
||||||
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
|
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
|
||||||
NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
|
NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
|
||||||
|
# OPTIONAL: Defines the force path style to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
|
||||||
|
# This will change it from using virtual hosts <bucket>.domain.com/<path> to fully qualified paths domain.com/<bucket>/<path>
|
||||||
|
NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE="false"
|
||||||
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
|
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
|
||||||
NEXT_PRIVATE_UPLOAD_REGION="unknown"
|
NEXT_PRIVATE_UPLOAD_REGION="unknown"
|
||||||
# REQUIRED: Defines the bucket to use for the S3 storage transport.
|
# REQUIRED: Defines the bucket to use for the S3 storage transport.
|
||||||
@ -91,6 +97,7 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
|
|||||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
||||||
|
NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID=
|
||||||
|
|
||||||
# [[FEATURES]]
|
# [[FEATURES]]
|
||||||
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
||||||
@ -100,6 +107,11 @@ NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
|||||||
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
||||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||||
|
|
||||||
|
# [[E2E Tests]]
|
||||||
|
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||||
|
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||||
|
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||||
|
|
||||||
# This is only required for the marketing site
|
# This is only required for the marketing site
|
||||||
# [[REDIS]]
|
# [[REDIS]]
|
||||||
NEXT_PRIVATE_REDIS_URL=
|
NEXT_PRIVATE_REDIS_URL=
|
||||||
|
|||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.eslint": "explicit"
|
"source.fixAll": "explicit"
|
||||||
},
|
},
|
||||||
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
||||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||||
|
|||||||
15
README.md
15
README.md
@ -30,17 +30,8 @@
|
|||||||
<a href="CODE_OF_CONDUCT.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg" alt="Contributor Covenant"></a>
|
<a href="CODE_OF_CONDUCT.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg" alt="Contributor Covenant"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div align="center">
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
<img src="https://github.com/documenso/documenso/assets/13398220/d96ed533-6f34-4a97-be9b-442bdb189c69" style="width: 80%;" />
|
||||||
src="https://github.com/documenso/documenso/assets/1309312/67e08c98-c153-4115-aa2d-77979bb12c94)">
|
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
|
||||||
src="https://github.com/documenso/documenso/assets/1309312/040cfbae-3438-4ca3-87f2-ce52c793dcaf">
|
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
|
||||||
src="https://github.com/documenso/documenso/assets/1309312/72d445be-41e5-4936-bdba-87ef8e70fa09">
|
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
|
||||||
src="https://github.com/documenso/documenso/assets/1309312/d7b86c0f-a755-4476-a022-a608db2c4633">
|
|
||||||
<img style="display: block; height: 120px; width: 24%"
|
|
||||||
src=https://github.com/documenso/documenso/assets/1309312/c0f55116-ab82-433f-a266-f3fc8571d69f">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## About this project
|
## About this project
|
||||||
@ -91,7 +82,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
|||||||
- [NextAuth.js](https://next-auth.js.org/) - Authentication
|
- [NextAuth.js](https://next-auth.js.org/) - Authentication
|
||||||
- [react-email](https://react.email/) - Email Templates
|
- [react-email](https://react.email/) - Email Templates
|
||||||
- [tRPC](https://trpc.io/) - API
|
- [tRPC](https://trpc.io/) - API
|
||||||
- [Node SignPDF](https://github.com/vbuch/node-signpdf) - Digital Signature
|
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures
|
||||||
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
||||||
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
||||||
- [Stripe](https://stripe.com/) - Payments
|
- [Stripe](https://stripe.com/) - Payments
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: 'Building Documenso — Part 1: Certificates'
|
title: 'Building Documenso — Part 1: Certificates'
|
||||||
description: In today's fast-paced world, productivity and efficiency are crucial for success, both in personal and professional endeavors. We all strive to make the most of our time and energy to achieve our goals effectively. However, it's not always easy to stay on track and maintain peak performance. In this blog post, we'll explore 10 valuable tips to help you boost productivity and efficiency in your daily life.
|
description: Let's take a look why you need a signing certificate and how Documenso does it.
|
||||||
authorName: 'Timur Ercan'
|
authorName: 'Timur Ercan'
|
||||||
authorImage: '/blog/blog-author-timur.jpeg'
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
authorRole: 'Co-Founder'
|
authorRole: 'Co-Founder'
|
||||||
|
|||||||
117
apps/marketing/content/blog/building-documenso-pt2.mdx
Normal file
117
apps/marketing/content/blog/building-documenso-pt2.mdx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
---
|
||||||
|
title: 'Building Documenso — Part 2: Signature Validity'
|
||||||
|
description: Is a signature valid? And what does that mean? It's a surprisingly complex question; let's take a look.
|
||||||
|
authorName: 'Timur Ercan'
|
||||||
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2024-04-05
|
||||||
|
tags:
|
||||||
|
- Document Signature
|
||||||
|
- Certificates
|
||||||
|
- Signing
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/eu-validate-1.png"
|
||||||
|
width= "650"
|
||||||
|
height= "650"
|
||||||
|
alt= "A report card for signature validity."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
If a tree does not comply with the EU trust list, does it make a sound when validating?r
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
> TLDR; Signatures can be valid and compliant for different signature levels, even if some validators show higher-level errors. Not all helpful security measures are mandated by law.
|
||||||
|
|
||||||
|
# A valid question
|
||||||
|
|
||||||
|
A few days ago, an early adopter brought up this question in our [Discord](https://documen.so/discord):
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/eu-validate-2.png"
|
||||||
|
width= "650"
|
||||||
|
height= "650"
|
||||||
|
alt= "A report card for signature validity."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
You can check out the validator here: [https://documen.so/eu-validator](https://documen.so/eu-validator)
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
For those unfamiliar with the tool, he used the validator tool of the EU's Digital Signature Service (DSS) Framework to check the signature of a document signed with Documenso. The EU provides this tool to help users and providers check the validity level of their signatures.
|
||||||
|
|
||||||
|
A short refresher from [Building Documenso — Part 1: Certificates](https://documen.so/certs):
|
||||||
|
|
||||||
|
> Documenso inserts all visual signatures into the document and then seals it using the "Documenso Inc." corporate certificate. This makes the resulting PDF document tamper-proof and guarantees it hasn't changed since signing.
|
||||||
|
|
||||||
|
Before we answer if the document was signed correctly, we need to understand what the goal was.
|
||||||
|
|
||||||
|
There are three signature levels in the European eIDAS regulation:
|
||||||
|
|
||||||
|
1. **Simple Electronic Signatures (Level 1/ SES):** This is just a visual signature or even a checkbox on a document.
|
||||||
|
|
||||||
|
2. **Advanded Electronic Signatures (Level 2/ AES)**: An actual crypographic signature (not just a seal on the whole document, but a specific signature), using a certificate linked to the identification data of the signer.
|
||||||
|
|
||||||
|
3. **Qualified Electronic Signatures (Level 3/ QES):** Same as 2. but done by a government-certified entity on certified hardware and after identifying the signer with an official ID document (e.g., passport)
|
||||||
|
|
||||||
|
> 💡 Side Note: Number 2 (AES) is how most people imagine digital signatures. But most of the market uses 1. plus a seal on the whole document under the name of the signing provider (e.g., Documenso). The signer's data is only inserted visually, not in the actual signature. Why? One of the reasons is that it's much easier, and without a readily available open source framework to draw from, it is quite tricky to build. This is something we aim to build (which many have done) and open source (which no one has done).
|
||||||
|
|
||||||
|
From the perspective of eIDAS, Documenso offers Level 1/ SES signatures since it does not adhere to all of the requirements of Level 2/ AES. This means that, technically, there is no legal need to seal the document to achieve this level of validity (at least within eIDAS). We do it anyway since it improves the level of confidence users can have in the signed document. Sealing the document, even though not legally required, is a great example of Documenso's approach to signatures. First, we aim to provide all legal requirements for a given use case. Then, we add any protection that can be added without unwarranted friction to the creation of the signature.
|
||||||
|
|
||||||
|
## Not if valid, but how valid
|
||||||
|
|
||||||
|
**Q: So, is the signature in the image valid?**
|
||||||
|
|
||||||
|
A: Yes, as an eidas Level 1 SES.
|
||||||
|
|
||||||
|
**Q: Then why does it say "Unable to build a certificate chain up to a trusted list"**
|
||||||
|
|
||||||
|
A: The certificate we use to seal the document after inserting the signatures is not on the EU Trust list.
|
||||||
|
|
||||||
|
**Q: Does that mean it is less secure?**
|
||||||
|
|
||||||
|
A: No, it means the provider (Wisekey) is not on a list maintained by the EU. The cryptographic signature is just as strong as any other
|
||||||
|
|
||||||
|
For someone who does not deal with this stuff daily, this can be hard to comprehend. Whether you use a certificate you generated yourself, one generated by a certificate authority (CA) like Wisekey, or one by another on the EU trust list (e.g., Bundesdruckerei), the cryptographic security guaranteeing that the document has not been tampered with is always the same. Many providers like Documenso, DocuSign, PandaDoc, and Digisigner all use this method for their regular plans. That means if you were to run a document signed by them through the validator above, the result would be the same[1]. The interesting question is why? Why do it like this?
|
||||||
|
|
||||||
|
## Certificate Infrastructure is broken
|
||||||
|
|
||||||
|
While there are some actual expenses involved in providing AES and QES, the blunt reality is that it's just good business to charge for them per signature, making it unsuitable for the "standard offerings"; almost no one has the resources to set this up themselves. While this initial process of becoming a QES-certified entity is really expensive, selling the certificates afterward is very lucrative. This leads to less innovation in the space and only big players providing these high-compliance services. Even certificates only used to seal documents without being QES certified are sold for a large range of prices, and they cost almost nothing to produce.
|
||||||
|
|
||||||
|
## Why Though?
|
||||||
|
|
||||||
|
**Q: Why do people buy a certificate for money and not just generate one themselves? Isn't the cryptographic security the same?**
|
||||||
|
|
||||||
|
A: Self-generated certificates are not recognized for higher-level compliance signatures like QES
|
||||||
|
|
||||||
|
**Q: So if you don't need higher-level signatures, you could just generate one yourself?**
|
||||||
|
|
||||||
|
A: Yes, you could. Since eIDAS Level 1 does not require a cert, you could use your own.
|
||||||
|
|
||||||
|
**Q: Why don't more people?**
|
||||||
|
|
||||||
|
A: One reason is that apart from the EU trust list, there are others, like the Adobe trust list. While not legally required, being on that one (like Wisekey) gives you a green checkmark in Adobe PDF, which is how most people check signature validity.
|
||||||
|
|
||||||
|
**Q: Not a question, but all of this sounds weird**
|
||||||
|
|
||||||
|
A: It is. This is one of the reasons why Documenso exists. We plan to make this easier.
|
||||||
|
|
||||||
|
**Q: How?**
|
||||||
|
|
||||||
|
A: By explaining and providing easy-to-use tools and eventually free, highly compliant signature certificates for everyone.
|
||||||
|
|
||||||
|
Eventually, we plan to start a free certificate authority called Let's Sign, named after another instituion that broke the paid certificate paradigm to the benefit of the internet: [Let's Encrypt](https://letsencrypt.org/).
|
||||||
|
|
||||||
|
As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments.
|
||||||
|
|
||||||
|
Best from Hamburg\
|
||||||
|
Timur
|
||||||
|
\
|
||||||
|
\
|
||||||
|
\
|
||||||
|
[1] The signature format (e.g. PKCS7-B) will vary. It's the format what the signature inserted into the document looks like. eIDAS itself does not specifically require any given format, but the PAdES defined by the EU is mostly used by european providers.
|
||||||
292
apps/marketing/content/blog/public-api.mdx
Normal file
292
apps/marketing/content/blog/public-api.mdx
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
---
|
||||||
|
title: 'Building the Documenso Public API - The Why and How'
|
||||||
|
description: 'This article talks about the need for the public API and the process of building it. It also discusses the requirements we had to meet and the constraints we had to work within.'
|
||||||
|
authorName: 'Catalin'
|
||||||
|
authorImage: '/blog/blog-author-catalin.webp'
|
||||||
|
authorRole: 'I like to code and write'
|
||||||
|
date: 2024-03-08
|
||||||
|
tags:
|
||||||
|
- Development
|
||||||
|
- API
|
||||||
|
---
|
||||||
|
|
||||||
|
This article covers the process of building the public API for Documenso. It starts by explaining why the API was needed for a digital document signing company in the first place. Then, it'll dive into the steps we took to build it. Lastly, it'll present the requirements we had to meet and the constraints we had to work within.
|
||||||
|
|
||||||
|
## Why the public API
|
||||||
|
|
||||||
|
We decided to build the public API to open a new way of interacting with Documenso. While the web app does the job well, there are use cases where it's not enough. In those cases, the users might want to interact with the platform programmatically. Usually, that's for integrating Documenso with other applications.
|
||||||
|
|
||||||
|
With the new public API that's now possible. You can integrate Documenso's functionalities within other applications to automate tasks, create custom solutions, and build custom workflows, to name just a few.
|
||||||
|
|
||||||
|
The API provides 12 endpoints at the time of writing this article:
|
||||||
|
|
||||||
|
- (GET) `/api/v1/documents` - retrieve all the documents
|
||||||
|
- (POST) `/api/v1/documents` - upload a new document and getting a presigned URL
|
||||||
|
- (GET) `/api/v1/documents/{id}` - fetch a specific document
|
||||||
|
- (DELETE) `/api/v1/documents/{id}` - delete a specific document
|
||||||
|
- (POST) `/api/v1/templates/{templateId}/create-document` - create a new document from an existing template
|
||||||
|
- (POST) `/api/v1/documents/{id}/send` - send a document for signing
|
||||||
|
- (POST) `/api/v1/documents/{id}/recipients` - create a document recipient
|
||||||
|
- (PATCH) `/api/v1/documents/{id}/recipients/{recipientId}` - update the details of a document recipient
|
||||||
|
- (DELETE) `/api/v1/documents/{id}/recipients/{recipientId}` - delete a specific recipient from a document
|
||||||
|
- (POST) `/api/v1/documents/{id}/fields` - create a field for a document
|
||||||
|
- (PATCH) `/api/v1/documents/{id}/fields` - update the details of a document field
|
||||||
|
- (DELETE) `/api/v1/documents/{id}/fields` - delete a field from a document
|
||||||
|
|
||||||
|
> Check out the [API documentation](https://app.documenso.com/api/v1/openapi).
|
||||||
|
|
||||||
|
Moreover, it also enables us to enhance the platform by bringing other integrations to Documenso, such as Zapier.
|
||||||
|
|
||||||
|
In conclusion, the new public API extends Documenso's capabilities, provides more flexibility for users, and opens up a broader world of possibilities.
|
||||||
|
|
||||||
|
## Picking the right approach & tech
|
||||||
|
|
||||||
|
Once we decided to build the API, we had to choose the approach and technologies to use. There were 2 options:
|
||||||
|
|
||||||
|
1. Build an additional application
|
||||||
|
2. Launch the API in the existing codebase
|
||||||
|
|
||||||
|
### 1. Build an additional application
|
||||||
|
|
||||||
|
That would mean creating a new codebase and building the API from scratch. Having a separate app for the API would result in benefits such as:
|
||||||
|
|
||||||
|
- lower latency responses
|
||||||
|
- supporting larger field uploads
|
||||||
|
- separation between the apps (Documenso and the API)
|
||||||
|
- customizability and flexibility
|
||||||
|
- easier testing and debugging
|
||||||
|
|
||||||
|
This approach has significant benefits. However, one major drawback is that it requires additional resources.
|
||||||
|
|
||||||
|
We'd have to spend a lot of time just on the core stuff, such as building and configuring the basic server. After that, we'd spend time implementing the endpoints and authorization, among other things. When the building is done, there will be another application to deploy and manage. All of this would stretch our already limited resources.
|
||||||
|
|
||||||
|
So, we asked ourselves if there is another way of doing it without sacrificing the API quality and the developer experience.
|
||||||
|
|
||||||
|
### 2. Launch the API in the existing codebase
|
||||||
|
|
||||||
|
The other option was to launch the API in the existing codebase. Rather than writing everything from scratch, we could use most of our existing code.
|
||||||
|
|
||||||
|
Since we're using tRPC for our internal API (backend), we looked for solutions that work well with tRPC. We narrowed down the choices to:
|
||||||
|
|
||||||
|
- [trpc-openapi](https://github.com/jlalmes/trpc-openapi)
|
||||||
|
- [ts-rest](https://ts-rest.com/)
|
||||||
|
|
||||||
|
Both technologies allow you to build public APIs. The `trpc-openapi` technology allows you to easily turn tRPC procedures into REST endpoints. It's more like a plugin for tRPC.
|
||||||
|
|
||||||
|
On the other hand, `ts-rest` is more of a standalone solution. `ts-rest` enables you to create a contract for the API, which can be used both on the client and server. You can consume and implement the contract in your application, thus providing end-to-end type safety and RPC-like client.
|
||||||
|
|
||||||
|
> You can see a [comparison between trpc-openapi and ts-rest](https://catalins.tech/public-api-trpc/) here.
|
||||||
|
|
||||||
|
So, the main difference between the 2 is that `trpc-openapi` is like a plugin that extends tRPC's capabilities, whereas `ts-rest` provides the tools for building a standalone API.
|
||||||
|
|
||||||
|
### Our choice
|
||||||
|
|
||||||
|
After analyzing and comparing the 2 options, we decided to go with `ts-rest` because of its benefits. Here's a paragraph from the `ts-rest` documentation that hits the nail on the head:
|
||||||
|
|
||||||
|
> tRPC has many plugins to solve this issue by mapping the API implementation to a REST-like API, however, these approaches are often a bit clunky and reduce the safety of the system overall, ts-rest does this heavy lifting in the client and server implementations rather than requiring a second layer of abstraction and API endpoint(s) to be defined.
|
||||||
|
|
||||||
|
## API Requirements
|
||||||
|
|
||||||
|
We defined the following requirements for the API:
|
||||||
|
|
||||||
|
- The API should use path-based versioning (e.g. `/v1`)
|
||||||
|
- The system should use bearer tokens for API authentication
|
||||||
|
- The API token should be a random string of 32 to 40 characters
|
||||||
|
- The system should hash the token and store the hashed value
|
||||||
|
- The system should only display the API token when it's created
|
||||||
|
- The API should have self-generated documentation like Swagger
|
||||||
|
- Users should be able to create an API key
|
||||||
|
- Users should be able to choose a token name
|
||||||
|
- Users should be able to choose an expiration date for the token
|
||||||
|
- User should be able to choose between 7 days, 1 month, 3 months, 6 months, 12 months, never
|
||||||
|
- System should display all the user's tokens in the settings page
|
||||||
|
- System should display the token name, creation date, expiration date and a delete button
|
||||||
|
- Users should be able to delete an API key
|
||||||
|
- Users should be able to retrieve all the documents from their account
|
||||||
|
- Users should be able to upload a new document
|
||||||
|
- Users should receive an S3 pre-signed URL after a successful upload
|
||||||
|
- Users should be able to retrieve a specific document from their account by its id
|
||||||
|
- Users should be able to delete a specific document from their account by its id
|
||||||
|
- Users should be able to create a new document from an existing document template
|
||||||
|
- Users should be able to send a document for signing to 1 or more recipients
|
||||||
|
- Users should be able to create a recipient for a document
|
||||||
|
- Users should be able to update the details of a recipient
|
||||||
|
- Users should be able to delete a recipient from a document
|
||||||
|
- Users should be able to create a field (e.g. signature, email, name, date) for a document
|
||||||
|
- Users should be able to update a field for a document
|
||||||
|
- Users should be able to delete a field from a document
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
We also faced the following constraints while developing the API:
|
||||||
|
|
||||||
|
**1. Resources**
|
||||||
|
|
||||||
|
Limited resources were one of the main constraints. We're a new startup with a relatively small team. Building and maintaining an additional application would strain our limited resources.
|
||||||
|
|
||||||
|
**2. Technology stack**
|
||||||
|
|
||||||
|
Another constraint was the technology stack. Our tech stack includes TypeScript, Prisma, and tRPC, among others. We also use Vercel for hosting.
|
||||||
|
|
||||||
|
As a result, we wanted to use technologies we are comfortable with. This allowed us to leverage our existing knowledge and ensured consistency across our applications.
|
||||||
|
|
||||||
|
Using familiar technologies also meant we could develop the API faster, as we didn't have to spend time learning new technologies. We could also leverage existing code and tools used in our main application.
|
||||||
|
|
||||||
|
It's worth mentioning that this is not a permanent decision. We're open to moving the API to another codebase/tech stack when it makes sense (e.g. API is heavily used and needs better performance).
|
||||||
|
|
||||||
|
**3. File uploads**
|
||||||
|
|
||||||
|
Due to our current architecture, we support file uploads with a maximum size of 50 MB. To circumvent this, we created an additional step for uploading documents.
|
||||||
|
|
||||||
|
Users make a POST request to the `/api/v1/documents` endpoint and the API responds with an S3 pre-signed URL. The users then make a 2nd request to the pre-signed URL with their document.
|
||||||
|
|
||||||
|
## How we built the API
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Our codebase is a monorepo, so we created a new API package in the `packages` directory. It contains both the API implementation and its documentation. The main 2 blocks of the implementation consist of the API contract and the code for the API endpoints.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
In a few words, the API contract defines the API structure, the format of the requests and responses, how to authenticate API calls, the available endpoints and their associated HTTP verbs. You can explore the [API contract](https://github.com/documenso/documenso/blob/main/packages/api/v1/contract.ts) on GitHub.
|
||||||
|
|
||||||
|
Then, there's the implementation part, which is the actual code for each endpoint defined in the API contract. The implementation is where the API contract is brought to life and made functional.
|
||||||
|
|
||||||
|
Let's take the endpoint `/api/v1/documents` as an example.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const ApiContractV1 = c.router(
|
||||||
|
{
|
||||||
|
getDocuments: {
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/documents',
|
||||||
|
query: ZGetDocumentsQuerySchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Get all documents',
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The API contract specifies the following things for `getDocuments`:
|
||||||
|
|
||||||
|
- the allowed HTTP request method is GET, so trying to make a POST request, for example, results in an error
|
||||||
|
- the path is `/api/v1/documents`
|
||||||
|
- the query parameters the user can pass with the request
|
||||||
|
- in this case - `page` and `perPage`
|
||||||
|
- the allowed responses and their schema
|
||||||
|
- `200` returns an object containing an array of all documents and a field `totalPages`, which is self-explanatory
|
||||||
|
- `401` returns an object with a message such as "Unauthorized"
|
||||||
|
- `404` returns an object with a message such as "Not found"
|
||||||
|
|
||||||
|
The implementation of this endpoint needs to match the contract completely; otherwise, `ts-rest` will complain, and your API might not work as intended.
|
||||||
|
|
||||||
|
The `getDocuments` function from the `implementation.ts` file runs when the user hits the endpoint.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||||
|
getDocuments: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const page = Number(args.query.page) || 1;
|
||||||
|
const perPage = Number(args.query.perPage) || 10;
|
||||||
|
|
||||||
|
const { data: documents, totalPages } = await findDocuments({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
documents,
|
||||||
|
totalPages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
There is a middleware, too, `authenticatedMiddleware`, that handles the authentication for API requests. It ensures that the API token exists and the token used has the appropriate privileges for the resource it accesses.
|
||||||
|
|
||||||
|
That's how the other endpoints work as well. The code differs, but the principles are the same. You can explore the [API implementation](https://github.com/documenso/documenso/blob/main/packages/api/v1/implementation.ts) and the [middleware code](https://github.com/documenso/documenso/blob/main/packages/api/v1/middleware/authenticated.ts) on GitHub.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
For the documentation, we decided to use Swagger UI, which automatically generates the documentation from the OpenAPI specification.
|
||||||
|
|
||||||
|
The OpenAPI specification describes an API containing the available endpoints and their HTTP request methods, authentication methods, and so on. Its purpose is to help both machines and humans understand the API without having to look at the code.
|
||||||
|
The Documenso OpenAPI specification is live [here](https://documenso.com/api/v1/openapi.json).
|
||||||
|
|
||||||
|
Thankfully, `ts-rest` makes it seamless to generate the OpenAPI specification.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { generateOpenApi } from '@ts-rest/open-api';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from './contract';
|
||||||
|
|
||||||
|
export const OpenAPIV1 = generateOpenApi(
|
||||||
|
ApiContractV1,
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
title: 'Documenso API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setOperationId: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, the Swagger UI takes the OpenAPI specification as a prop and generates the documentation. The code below shows the component responsible for generating the documentation.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import SwaggerUI from 'swagger-ui-react';
|
||||||
|
import 'swagger-ui-react/swagger-ui.css';
|
||||||
|
|
||||||
|
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
|
||||||
|
|
||||||
|
export const OpenApiDocsPage = () => {
|
||||||
|
return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OpenApiDocsPage;
|
||||||
|
```
|
||||||
|
|
||||||
|
Lastly, we create an API endpoint to display the Swagger documentation. The code below dynamically imports the `OpenApiDocsPage` component and displays it.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const Docs = dynamic(async () => import('@documenso/api/v1/api-documentation'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function OpenApiDocsPage() {
|
||||||
|
return <Docs />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can access and play around with the documentation at [documenso.com/api/v1/openapi](https://documenso.com/api/v1/openapi). You should see a page like the one shown in the screenshot below.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> This article shows how to [generate Swagger documentation for a Next.js API](https://catalins.tech/generate-swagger-documentation-next-js-api/).
|
||||||
|
|
||||||
|
So, that's how we went about building the first iteration of the public API after taking into consideration all the constraints and the current needs. The [GitHub pull request for the API](https://github.com/documenso/documenso/pull/674) is publicly available on GitHub. You can go through it at your own pace.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The current architecture and approach work well for our current stage and needs. However, as we continue to grow and evolve, our architecture and approach will likely need to adapt. We monitor API usage and performance regularly and collect feedback from users. This enables us to find areas for improvement, understand our users' needs, and make informed decisions about the next steps.
|
||||||
@ -12,6 +12,7 @@ export const BlogPost = defineDocumentType(() => ({
|
|||||||
authorName: { type: 'string', required: true },
|
authorName: { type: 'string', required: true },
|
||||||
authorImage: { type: 'string', required: false },
|
authorImage: { type: 'string', required: false },
|
||||||
authorRole: { type: 'string', required: true },
|
authorRole: { type: 'string', required: true },
|
||||||
|
cta: { type: 'boolean', required: false, default: true },
|
||||||
},
|
},
|
||||||
computedFields: {
|
computedFields: {
|
||||||
href: { type: 'string', resolve: (post) => `/${post._raw.flattenedPath}` },
|
href: { type: 'string', resolve: (post) => `/${post._raw.flattenedPath}` },
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { withContentlayer } = require('next-contentlayer');
|
const { withContentlayer } = require('next-contentlayer');
|
||||||
|
const { withAxiom } = require('next-axiom');
|
||||||
|
|
||||||
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
||||||
|
|
||||||
@ -21,7 +22,7 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
|||||||
const config = {
|
const config = {
|
||||||
experimental: {
|
experimental: {
|
||||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||||
serverComponentsExternalPackages: ['@node-rs/bcrypt'],
|
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '50mb',
|
bodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
@ -95,4 +96,4 @@ const config = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withContentlayer(config);
|
module.exports = withAxiom(withContentlayer(config));
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
|
"@openstatus/react": "^0.0.3",
|
||||||
"contentlayer": "^0.3.4",
|
"contentlayer": "^0.3.4",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
@ -26,6 +27,7 @@
|
|||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "14.0.3",
|
"next": "14.0.3",
|
||||||
"next-auth": "4.24.5",
|
"next-auth": "4.24.5",
|
||||||
|
"next-axiom": "^1.1.1",
|
||||||
"next-contentlayer": "^0.3.4",
|
"next-contentlayer": "^0.3.4",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
|
|||||||
BIN
apps/marketing/public/blog/api-implementation.webp
Normal file
BIN
apps/marketing/public/blog/api-implementation.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/marketing/public/blog/api-package.webp
Normal file
BIN
apps/marketing/public/blog/api-package.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
apps/marketing/public/blog/blog-author-catalin.webp
Normal file
BIN
apps/marketing/public/blog/blog-author-catalin.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
apps/marketing/public/blog/docs.webp
Normal file
BIN
apps/marketing/public/blog/docs.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
BIN
apps/marketing/public/blog/eu-validate-1.png
Normal file
BIN
apps/marketing/public/blog/eu-validate-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
BIN
apps/marketing/public/blog/eu-validate-2.png
Normal file
BIN
apps/marketing/public/blog/eu-validate-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
@ -1,25 +1,21 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
import { allBlogPosts } from 'contentlayer/generated';
|
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
export const contentType = 'image/png';
|
const IMAGE_SIZE = {
|
||||||
|
|
||||||
export const IMAGE_SIZE = {
|
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
};
|
};
|
||||||
|
|
||||||
type BlogPostOpenGraphImageProps = {
|
export async function GET(_request: Request) {
|
||||||
params: { post: string };
|
const url = new URL(_request.url);
|
||||||
};
|
|
||||||
|
|
||||||
export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGraphImageProps) {
|
const title = url.searchParams.get('title');
|
||||||
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
|
const author = url.searchParams.get('author');
|
||||||
|
|
||||||
if (!blogPost) {
|
if (!title || !author) {
|
||||||
return null;
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// The long urls are needed for a compiler optimisation on the Next.js side, lifting this up
|
// The long urls are needed for a compiler optimisation on the Next.js side, lifting this up
|
||||||
@ -49,10 +45,10 @@ export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGra
|
|||||||
<img src={logoImage} alt="logo" tw="h-8" />
|
<img src={logoImage} alt="logo" tw="h-8" />
|
||||||
|
|
||||||
<h1 tw="mt-8 text-6xl text-center flex items-center justify-center w-full max-w-[800px] font-bold text-center mx-auto">
|
<h1 tw="mt-8 text-6xl text-center flex items-center justify-center w-full max-w-[800px] font-bold text-center mx-auto">
|
||||||
{blogPost.title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p tw="font-normal">Written by {blogPost.authorName}</p>
|
<p tw="font-normal">Written by {author}</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
@ -7,6 +7,8 @@ import { ChevronLeft } from 'lucide-react';
|
|||||||
import type { MDXComponents } from 'mdx/types';
|
import type { MDXComponents } from 'mdx/types';
|
||||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||||
|
|
||||||
|
import { CallToAction } from '~/components/(marketing)/call-to-action';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||||
@ -18,11 +20,23 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the url constructor to ensure that things are escaped as they should be
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
title: blogPost.title,
|
||||||
|
author: blogPost.authorName,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: {
|
title: {
|
||||||
absolute: `${blogPost.title} - Documenso Blog`,
|
absolute: `${blogPost.title} - Documenso Blog`,
|
||||||
},
|
},
|
||||||
description: blogPost.description,
|
description: blogPost.description,
|
||||||
|
openGraph: {
|
||||||
|
images: [`${blogPost.href}/opengraph?${searchParams.toString()}`],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
images: [`${blogPost.href}/opengraph?${searchParams.toString()}`],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -42,53 +56,57 @@ export default function BlogPostPage({ params }: { params: { post: string } }) {
|
|||||||
const MDXContent = useMDXComponent(post.body.code);
|
const MDXContent = useMDXComponent(post.body.code);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="prose dark:prose-invert mx-auto py-8">
|
<div>
|
||||||
<div className="mb-6 text-center">
|
<article className="prose dark:prose-invert mx-auto py-8">
|
||||||
<time dateTime={post.date} className="text-muted-foreground mb-1 text-xs">
|
<div className="mb-6 text-center">
|
||||||
{new Date(post.date).toLocaleDateString()}
|
<time dateTime={post.date} className="text-muted-foreground mb-1 text-xs">
|
||||||
</time>
|
{new Date(post.date).toLocaleDateString()}
|
||||||
|
</time>
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold">{post.title}</h1>
|
<h1 className="text-3xl font-bold">{post.title}</h1>
|
||||||
|
|
||||||
<div className="not-prose relative -mt-2 flex items-center gap-x-4 border-b border-t py-4">
|
<div className="not-prose relative -mt-2 flex items-center gap-x-4 border-b border-t py-4">
|
||||||
<div className="bg-foreground h-10 w-10 rounded-full">
|
<div className="bg-foreground h-10 w-10 rounded-full">
|
||||||
{post.authorImage && (
|
{post.authorImage && (
|
||||||
<img
|
<img
|
||||||
src={post.authorImage}
|
src={post.authorImage}
|
||||||
alt={`Image of ${post.authorName}`}
|
alt={`Image of ${post.authorName}`}
|
||||||
className="bg-foreground/10 h-10 w-10 rounded-full"
|
className="bg-foreground/10 h-10 w-10 rounded-full"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm leading-6">
|
<div className="text-sm leading-6">
|
||||||
<p className="text-foreground text-left font-semibold">{post.authorName}</p>
|
<p className="text-foreground text-left font-semibold">{post.authorName}</p>
|
||||||
<p className="text-muted-foreground">{post.authorRole}</p>
|
<p className="text-muted-foreground">{post.authorRole}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<MDXContent components={mdxComponents} />
|
<MDXContent components={mdxComponents} />
|
||||||
|
|
||||||
{post.tags.length > 0 && (
|
{post.tags.length > 0 && (
|
||||||
<ul className="not-prose flex list-none flex-row space-x-2 px-0">
|
<ul className="not-prose flex list-none flex-row space-x-2 px-0">
|
||||||
{post.tags.map((tag, i) => (
|
{post.tags.map((tag, i) => (
|
||||||
<li
|
<li
|
||||||
key={`tag-${i}`}
|
key={`tag-${i}`}
|
||||||
className="bg-muted hover:bg-muted/60 text-foreground relative z-10 whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-medium"
|
className="bg-muted hover:bg-muted/60 text-foreground relative z-10 whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-medium"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<Link href="/blog" className="text-muted-foreground flex items-center hover:opacity-60">
|
<Link href="/blog" className="text-muted-foreground flex items-center hover:opacity-60">
|
||||||
<ChevronLeft className="mr-2 h-6 w-6" />
|
<ChevronLeft className="mr-2 h-6 w-6" />
|
||||||
Back to all posts
|
Back to all posts
|
||||||
</Link>
|
</Link>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{post.cta && <CallToAction className="mt-8" utmSource={`blog_${params.post}`} />}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png';
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -38,7 +36,8 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', {
|
className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', {
|
||||||
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
|
'overflow-y-auto overflow-x-hidden':
|
||||||
|
pathname && !['/singleplayer', '/pricing'].includes(pathname),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -47,16 +46,8 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{showProfilesAnnouncementBar && (
|
{showProfilesAnnouncementBar && (
|
||||||
<div className="relative inline-flex w-full items-center justify-center overflow-hidden px-4 py-2.5">
|
<div className="relative inline-flex w-full items-center justify-center overflow-hidden bg-[#e7f3df] px-4 py-2.5">
|
||||||
<div className="absolute inset-0 -z-[1]">
|
<div className="text-foreground text-center text-sm font-medium">
|
||||||
<Image
|
|
||||||
src={launchWeekTwoImage}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
alt="Launch Week 2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-background text-center text-sm text-white">
|
|
||||||
Claim your documenso public profile username now!{' '}
|
Claim your documenso public profile username now!{' '}
|
||||||
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
|
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
|
||||||
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
|
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
import { formatMonth } from '@documenso/lib/client-only/format-month';
|
import { formatMonth } from '@documenso/lib/client-only/format-month';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type BarMetricProps<T extends Record<string, unknown>> = HTMLAttributes<HTMLDivElement> & {
|
export type BarMetricProps<T extends Record<string, unknown>> = HTMLAttributes<HTMLDivElement> & {
|
||||||
data: T;
|
data: T;
|
||||||
@ -34,13 +33,13 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
|
|||||||
.reverse();
|
.reverse();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)} {...props}>
|
<div className={className} {...props}>
|
||||||
<div className="flex items-center px-4">
|
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
<h3 className="text-lg font-semibold">{title}</h3>
|
<div className="mb-6 flex px-4">
|
||||||
<span>{extraInfo}</span>
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
</div>
|
<span>{extraInfo}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
|
||||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||||
<BarChart data={formattedData}>
|
<BarChart data={formattedData}>
|
||||||
<XAxis dataKey="month" />
|
<XAxis dataKey="month" />
|
||||||
|
|||||||
@ -5,8 +5,6 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts';
|
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
import { CAP_TABLE } from './data';
|
import { CAP_TABLE } from './data';
|
||||||
|
|
||||||
const COLORS = ['#7fd843', '#a2e771', '#c6f2a4'];
|
const COLORS = ['#7fd843', '#a2e771', '#c6f2a4'];
|
||||||
@ -49,10 +47,12 @@ export const CapTable = ({ className, ...props }: CapTableProps) => {
|
|||||||
setIsSSR(false);
|
setIsSSR(false);
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)} {...props}>
|
<div className={className} {...props}>
|
||||||
<h3 className="px-4 text-lg font-semibold">Cap Table</h3>
|
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
|
<div className="mb-6 flex px-4">
|
||||||
|
<h3 className="text-lg font-semibold">Cap Table</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border shadow-sm hover:shadow">
|
|
||||||
{!isSSR && (
|
{!isSSR && (
|
||||||
<PieChart width={400} height={400}>
|
<PieChart width={400} height={400}>
|
||||||
<Pie
|
<Pie
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
import { formatMonth } from '@documenso/lib/client-only/format-month';
|
import { formatMonth } from '@documenso/lib/client-only/format-month';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
|
export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
data: Record<string, string | number>[];
|
data: Record<string, string | number>[];
|
||||||
@ -18,10 +17,12 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps)
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)} {...props}>
|
<div className={className} {...props}>
|
||||||
<h3 className="px-4 text-lg font-semibold">Total Funding Raised</h3>
|
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
|
<div className="mb-6 flex px-4">
|
||||||
|
<h3 className="text-lg font-semibold">Total Funding Raised</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 flex-col items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
<BarChart data={formattedData} margin={{ top: 40, right: 40, bottom: 20, left: 40 }}>
|
<BarChart data={formattedData} margin={{ top: 40, right: 40, bottom: 20, left: 40 }}>
|
||||||
<XAxis dataKey="date" />
|
<XAxis dataKey="date" />
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
|
|
||||||
|
export type MonthlyCompletedDocumentsChartProps = {
|
||||||
|
className?: string;
|
||||||
|
data: GetUserMonthlyGrowthResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MonthlyCompletedDocumentsChart = ({
|
||||||
|
className,
|
||||||
|
data,
|
||||||
|
}: MonthlyCompletedDocumentsChartProps) => {
|
||||||
|
const formattedData = [...data].reverse().map(({ month, count }) => {
|
||||||
|
return {
|
||||||
|
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
|
||||||
|
count: Number(count),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
|
<div className="mb-6 flex px-4">
|
||||||
|
<h3 className="text-lg font-semibold">Completed Documents per Month</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<BarChart data={formattedData}>
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
labelStyle={{
|
||||||
|
color: 'hsl(var(--primary-foreground))',
|
||||||
|
}}
|
||||||
|
formatter={(value) => [Number(value).toLocaleString('en-US'), 'Completed Documents']}
|
||||||
|
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Bar
|
||||||
|
dataKey="count"
|
||||||
|
fill="hsl(var(--primary))"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={60}
|
||||||
|
label="Completed Documents"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -4,7 +4,6 @@ import { DateTime } from 'luxon';
|
|||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type MonthlyNewUsersChartProps = {
|
export type MonthlyNewUsersChartProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -20,12 +19,12 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<div className={className}>
|
||||||
<div className="flex items-center px-4">
|
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
<h3 className="text-lg font-semibold">New Users</h3>
|
<div className="mb-6 flex px-4">
|
||||||
</div>
|
<h3 className="text-lg font-semibold">New Users</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
<BarChart data={formattedData}>
|
<BarChart data={formattedData}>
|
||||||
<XAxis dataKey="month" />
|
<XAxis dataKey="month" />
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { DateTime } from 'luxon';
|
|||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type MonthlyTotalUsersChartProps = {
|
export type MonthlyTotalUsersChartProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -20,12 +19,12 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<div className={className}>
|
||||||
<div className="flex items-center px-4">
|
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
<h3 className="text-lg font-semibold">Total Users</h3>
|
<div className="mb-6 flex px-4">
|
||||||
</div>
|
<h3 className="text-lg font-semibold">Total Users</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
<BarChart data={formattedData}>
|
<BarChart data={formattedData}>
|
||||||
<XAxis dataKey="month" />
|
<XAxis dataKey="month" />
|
||||||
|
|||||||
@ -2,19 +2,23 @@ import type { Metadata } from 'next';
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { getCompletedDocumentsMonthly } from '@documenso/lib/server-only/user/get-monthly-completed-document';
|
||||||
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
|
|
||||||
import { FUNDING_RAISED } from '~/app/(marketing)/open/data';
|
import { FUNDING_RAISED } from '~/app/(marketing)/open/data';
|
||||||
import { MetricCard } from '~/app/(marketing)/open/metric-card';
|
import { CallToAction } from '~/components/(marketing)/call-to-action';
|
||||||
import { SalaryBands } from '~/app/(marketing)/open/salary-bands';
|
|
||||||
|
|
||||||
import { BarMetric } from './bar-metrics';
|
import { BarMetric } from './bar-metrics';
|
||||||
import { CapTable } from './cap-table';
|
import { CapTable } from './cap-table';
|
||||||
import { FundingRaised } from './funding-raised';
|
import { FundingRaised } from './funding-raised';
|
||||||
|
import { MetricCard } from './metric-card';
|
||||||
|
import { MonthlyCompletedDocumentsChart } from './monthly-completed-documents-chart';
|
||||||
import { MonthlyNewUsersChart } from './monthly-new-users-chart';
|
import { MonthlyNewUsersChart } from './monthly-new-users-chart';
|
||||||
import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
|
import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
|
||||||
|
import { SalaryBands } from './salary-bands';
|
||||||
import { TeamMembers } from './team-members';
|
import { TeamMembers } from './team-members';
|
||||||
import { OpenPageTooltip } from './tooltip';
|
import { OpenPageTooltip } from './tooltip';
|
||||||
|
import { TotalSignedDocumentsChart } from './total-signed-documents-chart';
|
||||||
import { Typefully } from './typefully';
|
import { Typefully } from './typefully';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -130,125 +134,149 @@ export default async function OpenPage() {
|
|||||||
{ total_count: mergedPullRequests },
|
{ total_count: mergedPullRequests },
|
||||||
STARGAZERS_DATA,
|
STARGAZERS_DATA,
|
||||||
EARLY_ADOPTERS_DATA,
|
EARLY_ADOPTERS_DATA,
|
||||||
|
MONTHLY_USERS,
|
||||||
|
MONTHLY_COMPLETED_DOCUMENTS,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetchGithubStats(),
|
fetchGithubStats(),
|
||||||
fetchOpenIssues(),
|
fetchOpenIssues(),
|
||||||
fetchMergedPullRequests(),
|
fetchMergedPullRequests(),
|
||||||
fetchStargazers(),
|
fetchStargazers(),
|
||||||
fetchEarlyAdopters(),
|
fetchEarlyAdopters(),
|
||||||
|
getUserMonthlyGrowth(),
|
||||||
|
getCompletedDocumentsMonthly(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const MONTHLY_USERS = await getUserMonthlyGrowth();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
|
<div>
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
|
||||||
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4 max-w-[60ch] text-center text-lg leading-normal">
|
<p className="text-muted-foreground mt-4 max-w-[60ch] text-center text-lg leading-normal">
|
||||||
All our metrics, finances, and learnings are public. We believe in transparency and want
|
All our metrics, finances, and learnings are public. We believe in transparency and want
|
||||||
to share our journey with you. You can read more about why here:{' '}
|
to share our journey with you. You can read more about why here:{' '}
|
||||||
<a
|
<a
|
||||||
className="font-bold"
|
className="font-bold"
|
||||||
href="https://documenso.com/blog/pre-seed"
|
href="https://documenso.com/blog/pre-seed"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
Announcing Open Metrics
|
Announcing Open Metrics
|
||||||
</a>
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-12 grid grid-cols-12 gap-8">
|
||||||
|
<div className="col-span-12 grid grid-cols-4 gap-4">
|
||||||
|
<MetricCard
|
||||||
|
className="col-span-2 lg:col-span-1"
|
||||||
|
title="Stargazers"
|
||||||
|
value={stargazersCount.toLocaleString('en-US')}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
className="col-span-2 lg:col-span-1"
|
||||||
|
title="Forks"
|
||||||
|
value={forksCount.toLocaleString('en-US')}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
className="col-span-2 lg:col-span-1"
|
||||||
|
title="Open Issues"
|
||||||
|
value={openIssues.toLocaleString('en-US')}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
className="col-span-2 lg:col-span-1"
|
||||||
|
title="Merged PR's"
|
||||||
|
value={mergedPullRequests.toLocaleString('en-US')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TeamMembers className="col-span-12" />
|
||||||
|
|
||||||
|
<SalaryBands className="col-span-12" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="px-4 text-2xl font-semibold">Finances</h2>
|
||||||
|
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
|
||||||
|
<FundingRaised data={FUNDING_RAISED} className="col-span-12 lg:col-span-6" />
|
||||||
|
|
||||||
|
<CapTable className="col-span-12 lg:col-span-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="px-4 text-2xl font-semibold">Community</h2>
|
||||||
|
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
|
||||||
|
<BarMetric<StargazersType>
|
||||||
|
data={STARGAZERS_DATA}
|
||||||
|
metricKey="stars"
|
||||||
|
title="GitHub: Total Stars"
|
||||||
|
label="Stars"
|
||||||
|
className="col-span-12 lg:col-span-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BarMetric<StargazersType>
|
||||||
|
data={STARGAZERS_DATA}
|
||||||
|
metricKey="mergedPRs"
|
||||||
|
title="GitHub: Total Merged PRs"
|
||||||
|
label="Merged PRs"
|
||||||
|
chartHeight={400}
|
||||||
|
className="col-span-12 lg:col-span-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BarMetric<StargazersType>
|
||||||
|
data={STARGAZERS_DATA}
|
||||||
|
metricKey="forks"
|
||||||
|
title="GitHub: Total Forks"
|
||||||
|
label="Forks"
|
||||||
|
chartHeight={400}
|
||||||
|
className="col-span-12 lg:col-span-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BarMetric<StargazersType>
|
||||||
|
data={STARGAZERS_DATA}
|
||||||
|
metricKey="openIssues"
|
||||||
|
title="GitHub: Total Open Issues"
|
||||||
|
label="Open Issues"
|
||||||
|
chartHeight={400}
|
||||||
|
className="col-span-12 lg:col-span-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typefully className="col-span-12 lg:col-span-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="px-4 text-2xl font-semibold">Growth</h2>
|
||||||
|
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
|
||||||
|
<BarMetric<EarlyAdoptersType>
|
||||||
|
data={EARLY_ADOPTERS_DATA}
|
||||||
|
metricKey="earlyAdopters"
|
||||||
|
title="Early Adopters"
|
||||||
|
label="Early Adopters"
|
||||||
|
className="col-span-12 lg:col-span-6"
|
||||||
|
extraInfo={<OpenPageTooltip />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MonthlyTotalUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
|
||||||
|
<MonthlyNewUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
|
||||||
|
|
||||||
|
<MonthlyCompletedDocumentsChart
|
||||||
|
data={MONTHLY_COMPLETED_DOCUMENTS}
|
||||||
|
className="col-span-12 lg:col-span-6"
|
||||||
|
/>
|
||||||
|
<TotalSignedDocumentsChart
|
||||||
|
data={MONTHLY_COMPLETED_DOCUMENTS}
|
||||||
|
className="col-span-12 lg:col-span-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
|
||||||
|
<h2 className="text-2xl font-bold">Is there more?</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal">
|
||||||
|
This page is evolving as we learn what makes a great signing company. We'll update it when
|
||||||
|
we have more to share.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 grid grid-cols-12 gap-8">
|
<CallToAction className="mt-12" utmSource="open-page" />
|
||||||
<div className="col-span-12 grid grid-cols-4 gap-4">
|
|
||||||
<MetricCard
|
|
||||||
className="col-span-2 lg:col-span-1"
|
|
||||||
title="Stargazers"
|
|
||||||
value={stargazersCount.toLocaleString('en-US')}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
className="col-span-2 lg:col-span-1"
|
|
||||||
title="Forks"
|
|
||||||
value={forksCount.toLocaleString('en-US')}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
className="col-span-2 lg:col-span-1"
|
|
||||||
title="Open Issues"
|
|
||||||
value={openIssues.toLocaleString('en-US')}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
className="col-span-2 lg:col-span-1"
|
|
||||||
title="Merged PR's"
|
|
||||||
value={mergedPullRequests.toLocaleString('en-US')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TeamMembers className="col-span-12" />
|
|
||||||
|
|
||||||
<SalaryBands className="col-span-12" />
|
|
||||||
|
|
||||||
<FundingRaised data={FUNDING_RAISED} className="col-span-12 lg:col-span-6" />
|
|
||||||
|
|
||||||
<CapTable className="col-span-12 lg:col-span-6" />
|
|
||||||
|
|
||||||
<BarMetric<EarlyAdoptersType>
|
|
||||||
data={EARLY_ADOPTERS_DATA}
|
|
||||||
metricKey="earlyAdopters"
|
|
||||||
title="Early Adopters"
|
|
||||||
label="Early Adopters"
|
|
||||||
className="col-span-12 lg:col-span-6"
|
|
||||||
extraInfo={<OpenPageTooltip />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BarMetric<StargazersType>
|
|
||||||
data={STARGAZERS_DATA}
|
|
||||||
metricKey="stars"
|
|
||||||
title="Github: Total Stars"
|
|
||||||
label="Stars"
|
|
||||||
className="col-span-12 lg:col-span-6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BarMetric<StargazersType>
|
|
||||||
data={STARGAZERS_DATA}
|
|
||||||
metricKey="mergedPRs"
|
|
||||||
title="Github: Total Merged PRs"
|
|
||||||
label="Merged PRs"
|
|
||||||
chartHeight={300}
|
|
||||||
className="col-span-12 lg:col-span-6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BarMetric<StargazersType>
|
|
||||||
data={STARGAZERS_DATA}
|
|
||||||
metricKey="forks"
|
|
||||||
title="Github: Total Forks"
|
|
||||||
label="Forks"
|
|
||||||
chartHeight={300}
|
|
||||||
className="col-span-12 lg:col-span-6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BarMetric<StargazersType>
|
|
||||||
data={STARGAZERS_DATA}
|
|
||||||
metricKey="openIssues"
|
|
||||||
title="Github: Total Open Issues"
|
|
||||||
label="Open Issues"
|
|
||||||
chartHeight={300}
|
|
||||||
className="col-span-12 lg:col-span-6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MonthlyTotalUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
|
|
||||||
<MonthlyNewUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
|
|
||||||
|
|
||||||
<Typefully className="col-span-12 lg:col-span-6" />
|
|
||||||
|
|
||||||
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
|
|
||||||
<h2 className="text-2xl font-bold">Where's the rest?</h2>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal">
|
|
||||||
We're still working on getting all our metrics together. We'll update this page as soon
|
|
||||||
as we have more to share.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
|
|
||||||
|
export type TotalSignedDocumentsChartProps = {
|
||||||
|
className?: string;
|
||||||
|
data: GetUserMonthlyGrowthResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocumentsChartProps) => {
|
||||||
|
const formattedData = [...data].reverse().map(({ month, cume_count: count }) => {
|
||||||
|
return {
|
||||||
|
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
|
||||||
|
count: Number(count),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
|
<div className="mb-6 flex px-4">
|
||||||
|
<h3 className="text-lg font-semibold">Total Completed Documents</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<BarChart data={formattedData}>
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
labelStyle={{
|
||||||
|
color: 'hsl(var(--primary-foreground))',
|
||||||
|
}}
|
||||||
|
formatter={(value) => [
|
||||||
|
Number(value).toLocaleString('en-US'),
|
||||||
|
'Total Completed Documents',
|
||||||
|
]}
|
||||||
|
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Bar
|
||||||
|
dataKey="count"
|
||||||
|
fill="hsl(var(--primary))"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={60}
|
||||||
|
label="Total Completed Documents"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -6,18 +6,19 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import { FaXTwitter } from 'react-icons/fa6';
|
import { FaXTwitter } from 'react-icons/fa6';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
export type TypefullyProps = HTMLAttributes<HTMLDivElement>;
|
export type TypefullyProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const Typefully = ({ className, ...props }: TypefullyProps) => {
|
export const Typefully = ({ className, ...props }: TypefullyProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)} {...props}>
|
<div className={className} {...props}>
|
||||||
<h3 className="px-4 text-lg font-semibold">Twitter Stats</h3>
|
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
|
<div className="mb-6 flex px-4">
|
||||||
|
<h3 className="text-lg font-semibold">Twitter Stats</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border py-8 shadow-sm hover:shadow">
|
<div className="my-12 flex flex-col items-center gap-y-4 text-center">
|
||||||
<div className="flex flex-col items-center gap-y-4 text-center">
|
|
||||||
<FaXTwitter className="h-12 w-12" />
|
<FaXTwitter className="h-12 w-12" />
|
||||||
<Link href="https://typefully.com/documenso/stats" target="_blank">
|
<Link href="https://typefully.com/documenso/stats" target="_blank">
|
||||||
<h1>Documenso on X</h1>
|
<h1>Documenso on X</h1>
|
||||||
|
|||||||
@ -161,6 +161,7 @@ export const SinglePlayerClient = () => {
|
|||||||
signingStatus: 'NOT_SIGNED',
|
signingStatus: 'NOT_SIGNED',
|
||||||
sendStatus: 'NOT_SENT',
|
sendStatus: 'NOT_SENT',
|
||||||
role: 'SIGNER',
|
role: 'SIGNER',
|
||||||
|
authOptions: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
@ -246,6 +247,7 @@ export const SinglePlayerClient = () => {
|
|||||||
recipients={uploadedFile ? [placeholderRecipient] : []}
|
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onFieldsSubmit}
|
onSubmit={onFieldsSubmit}
|
||||||
|
isDocumentPdfLoaded={true}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Suspense } from 'react';
|
|||||||
|
|
||||||
import { Caveat, Inter } from 'next/font/google';
|
import { Caveat, Inter } from 'next/font/google';
|
||||||
|
|
||||||
|
import { AxiomWebVitals } from 'next-axiom';
|
||||||
import { PublicEnvScript } from 'next-runtime-env';
|
import { PublicEnvScript } from 'next-runtime-env';
|
||||||
|
|
||||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
@ -67,6 +68,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<PublicEnvScript />
|
<PublicEnvScript />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
<AxiomWebVitals />
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<PostHogPageview />
|
<PostHogPageview />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
31
apps/marketing/src/components/(marketing)/call-to-action.tsx
Normal file
31
apps/marketing/src/components/(marketing)/call-to-action.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
|
type CallToActionProps = {
|
||||||
|
className?: string;
|
||||||
|
utmSource?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CallToAction = ({ className, utmSource = 'generic-cta' }: CallToActionProps) => {
|
||||||
|
return (
|
||||||
|
<Card spotlight className={className}>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center p-12">
|
||||||
|
<h2 className="text-center text-2xl font-bold">Join the Open Signing Movement</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center leading-normal">
|
||||||
|
Create your account and start using state-of-the-art document signing. Open and beautiful
|
||||||
|
signing is within your grasp.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button className="mt-8 rounded-full no-underline" size="lg" asChild>
|
||||||
|
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=${utmSource}`} target="_blank">
|
||||||
|
Get started
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -53,7 +53,7 @@ export const Callout = ({ starCount }: CalloutProps) => {
|
|||||||
>
|
>
|
||||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
||||||
<LuGithub className="mr-2 h-5 w-5" />
|
<LuGithub className="mr-2 h-5 w-5" />
|
||||||
Star on Github
|
Star on GitHub
|
||||||
{starCount && starCount > 0 && (
|
{starCount && starCount > 0 && (
|
||||||
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
|
||||||
{starCount.toLocaleString('en-US')}
|
{starCount.toLocaleString('en-US')}
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import LogoImage from '@documenso/assets/logo.png';
|
|||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||||
|
|
||||||
|
import { StatusWidgetContainer } from './status-widget-container';
|
||||||
|
|
||||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
const SOCIAL_LINKS = [
|
const SOCIAL_LINKS = [
|
||||||
@ -62,6 +64,10 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<StatusWidgetContainer />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
|
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
|
||||||
|
|||||||
@ -123,7 +123,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
|
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
|
||||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
||||||
<LuGithub className="mr-2 h-5 w-5" />
|
<LuGithub className="mr-2 h-5 w-5" />
|
||||||
Star on Github
|
Star on GitHub
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('', className)} {...props}>
|
<div className={cn('', className)} {...props}>
|
||||||
<div className="flex items-center justify-center gap-x-6">
|
<div className="bg-background sticky top-32 flex items-center justify-end gap-x-6 shadow-[-1px_-5px_2px_6px_hsl(var(--background))] md:top-[7.5rem] lg:static lg:justify-center">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.button
|
<motion.button
|
||||||
key="MONTHLY"
|
key="MONTHLY"
|
||||||
@ -40,7 +40,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
{period === 'MONTHLY' && (
|
{period === 'MONTHLY' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
|
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
|
||||||
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
|
className="bg-foreground lg:bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
@ -63,7 +63,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
{period === 'YEARLY' && (
|
{period === 'YEARLY' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
|
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
|
||||||
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
|
className="bg-foreground lg:bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
@ -51,7 +51,7 @@ export const ShareConnectPaidWidgetBento = ({
|
|||||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="text-foreground/80 leading-relaxed">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Connections (Soon).</strong>
|
<strong className="block">Connections</strong>
|
||||||
Create connections and automations with Zapier and more to integrate with your
|
Create connections and automations with Zapier and more to integrate with your
|
||||||
favorite tools.
|
favorite tools.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
// https://github.com/documenso/documenso/pull/1044/files#r1538258462
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
import { StatusWidget } from './status-widget';
|
||||||
|
|
||||||
|
export function StatusWidgetContainer() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<StatusWidgetFallback />}>
|
||||||
|
<StatusWidget />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusWidgetFallback() {
|
||||||
|
return (
|
||||||
|
<div className="border-border inline-flex max-w-fit items-center justify-between space-x-2 rounded-md border border-gray-200 px-2 py-2 pr-3 text-sm">
|
||||||
|
<span className="bg-muted h-2 w-36 animate-pulse rounded-md" />
|
||||||
|
<span className="bg-muted relative inline-flex h-2 w-2 rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
apps/marketing/src/components/(marketing)/status-widget.tsx
Normal file
75
apps/marketing/src/components/(marketing)/status-widget.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { use, useMemo } from 'react';
|
||||||
|
|
||||||
|
import type { Status } from '@openstatus/react';
|
||||||
|
import { getStatus } from '@openstatus/react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
const getStatusLevel = (level: Status) => {
|
||||||
|
return {
|
||||||
|
operational: {
|
||||||
|
label: 'Operational',
|
||||||
|
color: 'bg-green-500',
|
||||||
|
color2: 'bg-green-400',
|
||||||
|
},
|
||||||
|
degraded_performance: {
|
||||||
|
label: 'Degraded Performance',
|
||||||
|
color: 'bg-yellow-500',
|
||||||
|
color2: 'bg-yellow-400',
|
||||||
|
},
|
||||||
|
partial_outage: {
|
||||||
|
label: 'Partial Outage',
|
||||||
|
color: 'bg-yellow-500',
|
||||||
|
color2: 'bg-yellow-400',
|
||||||
|
},
|
||||||
|
major_outage: {
|
||||||
|
label: 'Major Outage',
|
||||||
|
color: 'bg-red-500',
|
||||||
|
color2: 'bg-red-400',
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
label: 'Unknown',
|
||||||
|
color: 'bg-gray-500',
|
||||||
|
color2: 'bg-gray-400',
|
||||||
|
},
|
||||||
|
incident: {
|
||||||
|
label: 'Incident',
|
||||||
|
color: 'bg-yellow-500',
|
||||||
|
color2: 'bg-yellow-400',
|
||||||
|
},
|
||||||
|
under_maintenance: {
|
||||||
|
label: 'Under Maintenance',
|
||||||
|
color: 'bg-gray-500',
|
||||||
|
color2: 'bg-gray-400',
|
||||||
|
},
|
||||||
|
}[level];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusWidget() {
|
||||||
|
const getStatusMemoized = useMemo(async () => getStatus('documenso-status'), []);
|
||||||
|
const { status } = use(getStatusMemoized);
|
||||||
|
const level = getStatusLevel(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="border-border inline-flex max-w-fit items-center justify-between gap-2 space-x-2 rounded-md border border-gray-200 px-3 py-1 text-sm"
|
||||||
|
href="https://status.documenso.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">{level.label}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="relative ml-auto flex h-1.5 w-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
|
||||||
|
level.color2,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className={cn('relative inline-flex h-1.5 w-1.5 rounded-full', level.color)} />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { version } = require('./package.json');
|
const { version } = require('./package.json');
|
||||||
|
const { withAxiom } = require('next-axiom');
|
||||||
|
|
||||||
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
||||||
|
|
||||||
@ -22,7 +23,7 @@ const config = {
|
|||||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||||
experimental: {
|
experimental: {
|
||||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||||
serverComponentsExternalPackages: ['@node-rs/bcrypt'],
|
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '50mb',
|
bodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
@ -91,4 +92,4 @@ const config = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = withAxiom(config);
|
||||||
|
|||||||
@ -22,7 +22,10 @@
|
|||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
"@tanstack/react-query": "^4.29.5",
|
"@tanstack/react-query": "^4.29.5",
|
||||||
|
"cookie-es": "^1.0.0",
|
||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
@ -30,8 +33,10 @@
|
|||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "14.0.3",
|
"next": "14.0.3",
|
||||||
"next-auth": "4.24.5",
|
"next-auth": "4.24.5",
|
||||||
|
"next-axiom": "^1.1.1",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
|
"papaparse": "^5.4.1",
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
"posthog-js": "^1.75.3",
|
"posthog-js": "^1.75.3",
|
||||||
"posthog-node": "^3.1.1",
|
"posthog-node": "^3.1.1",
|
||||||
@ -51,9 +56,11 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
|
"@simplewebauthn/types": "^9.0.1",
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
|
"@types/papaparse": "^5.3.14",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7",
|
"@types/react-dom": "18.2.7",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
|||||||
|
|
||||||
import { AdminActions } from './admin-actions';
|
import { AdminActions } from './admin-actions';
|
||||||
import { RecipientItem } from './recipient-item';
|
import { RecipientItem } from './recipient-item';
|
||||||
|
import { SuperDeleteDocumentDialog } from './super-delete-document-dialog';
|
||||||
|
|
||||||
type AdminDocumentDetailsPageProps = {
|
type AdminDocumentDetailsPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@ -81,6 +82,10 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
|
|||||||
))}
|
))}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
{document && <SuperDeleteDocumentDialog document={document} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import type { Document } from '@documenso/prisma/client';
|
||||||
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type SuperDeleteDocumentDialogProps = {
|
||||||
|
document: Document;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
|
const { mutateAsync: deleteDocument, isLoading: isDeletingDocument } =
|
||||||
|
trpc.admin.deleteDocument.useMutation();
|
||||||
|
|
||||||
|
const handleDeleteDocument = async () => {
|
||||||
|
try {
|
||||||
|
if (!reason) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteDocument({ id: document.id, reason });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Document deleted',
|
||||||
|
description: 'The Document has been deleted successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push('/admin/documents');
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||||
|
toast({
|
||||||
|
title: 'An error occurred',
|
||||||
|
description: err.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
description:
|
||||||
|
err.message ??
|
||||||
|
'We encountered an unknown error while attempting to delete your document. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<Alert
|
||||||
|
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row "
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<AlertTitle>Delete Document</AlertTitle>
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
Delete the document. This action is irreversible so proceed with caution.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="destructive">Delete Document</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader className="space-y-4">
|
||||||
|
<DialogTitle>Delete Document</DialogTitle>
|
||||||
|
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription className="selection:bg-red-100">
|
||||||
|
This action is not reversible. Please be certain.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<DialogDescription>To confirm, please enter the reason</DialogDescription>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
className="mt-2"
|
||||||
|
type="text"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={handleDeleteDocument}
|
||||||
|
loading={isDeletingDocument}
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!reason}
|
||||||
|
>
|
||||||
|
{isDeletingDocument ? 'Deleting document...' : 'Delete Document'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -58,6 +58,7 @@ export const UsersDataTable = ({
|
|||||||
perPage,
|
perPage,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [debouncedSearchString]);
|
}, [debouncedSearchString]);
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
|||||||
@ -1,29 +1,25 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type DocumentData,
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
type DocumentMeta,
|
SKIP_QUERY_BATCH_META,
|
||||||
DocumentStatus,
|
} from '@documenso/lib/constants/trpc';
|
||||||
type Field,
|
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
|
||||||
type Recipient,
|
|
||||||
type User,
|
|
||||||
} from '@documenso/prisma/client';
|
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
|
import { AddSettingsFormPartial } from '@documenso/ui/primitives/document-flow/add-settings';
|
||||||
|
import type { TAddSettingsFormSchema } from '@documenso/ui/primitives/document-flow/add-settings.types';
|
||||||
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
||||||
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||||
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
||||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||||
import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title';
|
|
||||||
import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types';
|
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
@ -34,27 +30,19 @@ import { useOptionalCurrentTeam } from '~/providers/team';
|
|||||||
|
|
||||||
export type EditDocumentFormProps = {
|
export type EditDocumentFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
initialDocument: DocumentWithDetails;
|
||||||
document: DocumentWithData;
|
|
||||||
recipients: Recipient[];
|
|
||||||
documentMeta: DocumentMeta | null;
|
|
||||||
fields: Field[];
|
|
||||||
documentData: DocumentData;
|
|
||||||
documentRootPath: string;
|
documentRootPath: string;
|
||||||
|
isDocumentEnterprise: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject';
|
||||||
const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'subject'];
|
const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields', 'subject'];
|
||||||
|
|
||||||
export const EditDocumentForm = ({
|
export const EditDocumentForm = ({
|
||||||
className,
|
className,
|
||||||
document,
|
initialDocument,
|
||||||
recipients,
|
|
||||||
fields,
|
|
||||||
documentMeta,
|
|
||||||
user: _user,
|
|
||||||
documentData,
|
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
|
isDocumentEnterprise,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -62,17 +50,83 @@ export const EditDocumentForm = ({
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
|
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
||||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
|
||||||
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
|
const utils = trpc.useUtils();
|
||||||
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
|
|
||||||
|
const { data: document, refetch: refetchDocument } =
|
||||||
|
trpc.document.getDocumentWithDetailsById.useQuery(
|
||||||
|
{
|
||||||
|
id: initialDocument.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialData: initialDocument,
|
||||||
|
...SKIP_QUERY_BATCH_META,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { Recipient: recipients, Field: fields } = document;
|
||||||
|
|
||||||
|
const { mutateAsync: setSettingsForDocument } = trpc.document.setSettingsForDocument.useMutation({
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
onSuccess: (newData) => {
|
||||||
|
utils.document.getDocumentWithDetailsById.setData(
|
||||||
|
{
|
||||||
|
id: initialDocument.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
},
|
||||||
|
(oldData) => ({ ...(oldData || initialDocument), ...newData }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
onSuccess: (newFields) => {
|
||||||
|
utils.document.getDocumentWithDetailsById.setData(
|
||||||
|
{
|
||||||
|
id: initialDocument.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
},
|
||||||
|
(oldData) => ({ ...(oldData || initialDocument), Field: newFields }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation({
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
onSuccess: (newRecipients) => {
|
||||||
|
utils.document.getDocumentWithDetailsById.setData(
|
||||||
|
{
|
||||||
|
id: initialDocument.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
},
|
||||||
|
(oldData) => ({ ...(oldData || initialDocument), Recipient: newRecipients }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation({
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
onSuccess: (newData) => {
|
||||||
|
utils.document.getDocumentWithDetailsById.setData(
|
||||||
|
{
|
||||||
|
id: initialDocument.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
},
|
||||||
|
(oldData) => ({ ...(oldData || initialDocument), ...newData }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { mutateAsync: setPasswordForDocument } =
|
const { mutateAsync: setPasswordForDocument } =
|
||||||
trpc.document.setPasswordForDocument.useMutation();
|
trpc.document.setPasswordForDocument.useMutation();
|
||||||
|
|
||||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
title: {
|
settings: {
|
||||||
title: 'Add Title',
|
title: 'General',
|
||||||
description: 'Add the title to the document.',
|
description: 'Configure general settings for the document.',
|
||||||
stepIndex: 1,
|
stepIndex: 1,
|
||||||
},
|
},
|
||||||
signers: {
|
signers: {
|
||||||
@ -96,8 +150,7 @@ export const EditDocumentForm = ({
|
|||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined;
|
const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined;
|
||||||
|
|
||||||
let initialStep: EditDocumentStep =
|
let initialStep: EditDocumentStep = 'settings';
|
||||||
document.status === DocumentStatus.DRAFT ? 'title' : 'signers';
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
searchParamStep &&
|
searchParamStep &&
|
||||||
@ -110,15 +163,26 @@ export const EditDocumentForm = ({
|
|||||||
return initialStep;
|
return initialStep;
|
||||||
});
|
});
|
||||||
|
|
||||||
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
|
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
||||||
try {
|
try {
|
||||||
// Custom invocation server action
|
const { timezone, dateFormat, redirectUrl } = data.meta;
|
||||||
await addTitle({
|
|
||||||
|
await setSettingsForDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
title: data.title,
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
globalAccessAuth: data.globalAccessAuth ?? null,
|
||||||
|
globalActionAuth: data.globalActionAuth ?? null,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timezone,
|
||||||
|
dateFormat,
|
||||||
|
redirectUrl,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
setStep('signers');
|
setStep('signers');
|
||||||
@ -127,7 +191,7 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'An error occurred while updating title.',
|
description: 'An error occurred while updating the document settings.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -135,14 +199,19 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||||
try {
|
try {
|
||||||
// Custom invocation server action
|
|
||||||
await addSigners({
|
await addSigners({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
signers: data.signers,
|
signers: data.signers.map((signer) => ({
|
||||||
|
...signer,
|
||||||
|
// Explicitly set to null to indicate we want to remove auth if required.
|
||||||
|
actionAuth: signer.actionAuth || null,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
setStep('fields');
|
setStep('fields');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -157,13 +226,14 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
||||||
try {
|
try {
|
||||||
// Custom invocation server action
|
|
||||||
await addFields({
|
await addFields({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
fields: data.fields,
|
fields: data.fields,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
setStep('subject');
|
setStep('subject');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -177,7 +247,7 @@ export const EditDocumentForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||||
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
|
const { subject, message } = data.meta;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendDocument({
|
await sendDocument({
|
||||||
@ -186,9 +256,6 @@ export const EditDocumentForm = ({
|
|||||||
meta: {
|
meta: {
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
dateFormat,
|
|
||||||
timezone,
|
|
||||||
redirectUrl,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -219,6 +286,15 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
const currentDocumentFlow = documentFlow[step];
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the data in the background when steps change.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
void refetchDocument();
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||||
<Card
|
<Card
|
||||||
@ -227,11 +303,12 @@ export const EditDocumentForm = ({
|
|||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer
|
<LazyPDFViewer
|
||||||
key={documentData.id}
|
key={document.documentData.id}
|
||||||
documentData={documentData}
|
documentData={document.documentData}
|
||||||
document={document}
|
document={document}
|
||||||
password={documentMeta?.password}
|
password={document.documentMeta?.password}
|
||||||
onPasswordSubmit={onPasswordSubmit}
|
onPasswordSubmit={onPasswordSubmit}
|
||||||
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -245,30 +322,35 @@ export const EditDocumentForm = ({
|
|||||||
currentStep={currentDocumentFlow.stepIndex}
|
currentStep={currentDocumentFlow.stepIndex}
|
||||||
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
|
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
|
||||||
>
|
>
|
||||||
<AddTitleFormPartial
|
<AddSettingsFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.title}
|
documentFlow={documentFlow.settings}
|
||||||
document={document}
|
document={document}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddTitleFormSubmit}
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
document={document}
|
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddFieldsFormPartial
|
<AddFieldsFormPartial
|
||||||
key={fields.length}
|
key={fields.length}
|
||||||
documentFlow={documentFlow.fields}
|
documentFlow={documentFlow.fields}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddSubjectFormPartial
|
<AddSubjectFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.subject}
|
documentFlow={documentFlow.subject}
|
||||||
@ -276,6 +358,7 @@ export const EditDocumentForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddSubjectFormSubmit}
|
onSubmit={onAddSubjectFormSubmit}
|
||||||
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
</DocumentFlowFormContainer>
|
</DocumentFlowFormContainer>
|
||||||
|
|||||||
@ -3,11 +3,10 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import type { Team } from '@documenso/prisma/client';
|
import type { Team } from '@documenso/prisma/client';
|
||||||
@ -37,13 +36,18 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const document = await getDocumentById({
|
const isDocumentEnterprise = await isUserEnterprise({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const document = await getDocumentWithDetailsById({
|
||||||
id: documentId,
|
id: documentId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!document || !document.documentData) {
|
if (!document) {
|
||||||
redirect(documentRootPath);
|
redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,7 +55,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
redirect(`${documentRootPath}/${documentId}`);
|
redirect(`${documentRootPath}/${documentId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { documentData, documentMeta } = document;
|
const { documentMeta, Recipient: recipients } = document;
|
||||||
|
|
||||||
if (documentMeta?.password) {
|
if (documentMeta?.password) {
|
||||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||||
@ -70,18 +74,6 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
documentMeta.password = securePassword;
|
documentMeta.password = securePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [recipients, fields] = await Promise.all([
|
|
||||||
getRecipientsForDocument({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
}),
|
|
||||||
getFieldsForDocument({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
@ -108,14 +100,10 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditDocumentForm
|
<EditDocumentForm
|
||||||
className="mt-8"
|
className="mt-6"
|
||||||
document={document}
|
initialDocument={document}
|
||||||
user={user}
|
|
||||||
documentMeta={documentMeta}
|
|
||||||
recipients={recipients}
|
|
||||||
fields={fields}
|
|
||||||
documentData={documentData}
|
|
||||||
documentRootPath={documentRootPath}
|
documentRootPath={documentRootPath}
|
||||||
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import { ChevronLeft, Loader } from 'lucide-react';
|
import { ChevronLeft, Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||||
@ -13,7 +15,12 @@ export default function Loading() {
|
|||||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
Loading Document...
|
Loading Document...
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mt-8 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
|
||||||
|
<div className="flex h-10 items-center">
|
||||||
|
<Skeleton className="my-6 h-4 w-24 rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
||||||
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
|
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
||||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||||
|
|||||||
@ -2,16 +2,21 @@ import Link from 'next/link';
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { ChevronLeft, DownloadIcon } from 'lucide-react';
|
import { ChevronLeft, DownloadIcon } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
|
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import type { Recipient, Team } from '@documenso/prisma/client';
|
import type { Recipient, Team } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card } from '@documenso/ui/primitives/card';
|
import { Card } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
import { FRIENDLY_STATUS_MAP } from '~/components/formatter/document-status';
|
import {
|
||||||
|
DocumentStatus as DocumentStatusComponent,
|
||||||
|
FRIENDLY_STATUS_MAP,
|
||||||
|
} from '~/components/formatter/document-status';
|
||||||
|
|
||||||
import { DocumentLogsDataTable } from './document-logs-data-table';
|
import { DocumentLogsDataTable } from './document-logs-data-table';
|
||||||
|
|
||||||
@ -23,6 +28,8 @@ export type DocumentLogsPageViewProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
|
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
|
||||||
|
const locale = getLocale();
|
||||||
|
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
|
||||||
const documentId = Number(id);
|
const documentId = Number(id);
|
||||||
@ -67,15 +74,21 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Created by',
|
description: 'Created by',
|
||||||
value: document.User.name ?? document.User.email,
|
value: document.User.name
|
||||||
|
? `${document.User.name} (${document.User.email})`
|
||||||
|
: document.User.email,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Date created',
|
description: 'Date created',
|
||||||
value: document.createdAt.toISOString(),
|
value: DateTime.fromJSDate(document.createdAt)
|
||||||
|
.setLocale(locale)
|
||||||
|
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Last updated',
|
description: 'Last updated',
|
||||||
value: document.updatedAt.toISOString(),
|
value: DateTime.fromJSDate(document.updatedAt)
|
||||||
|
.setLocale(locale)
|
||||||
|
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Time zone',
|
description: 'Time zone',
|
||||||
@ -90,7 +103,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
|||||||
text = `${recipient.name} (${recipient.email})`;
|
text = `${recipient.name} (${recipient.email})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${text} - ${recipient.role}`;
|
return `[${recipient.role}] ${text}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -104,9 +117,19 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-col justify-between sm:flex-row">
|
<div className="flex flex-col justify-between sm:flex-row">
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
<div>
|
||||||
{document.title}
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
</h1>
|
{document.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<DocumentStatusComponent
|
||||||
|
inheritColor
|
||||||
|
status={document.status}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||||
<Button variant="outline" className="mr-2 w-full sm:w-auto">
|
<Button variant="outline" className="mr-2 w-full sm:w-auto">
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
@ -16,6 +18,8 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DeleteAccountDialogProps = {
|
export type DeleteAccountDialogProps = {
|
||||||
@ -28,6 +32,8 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
|
|||||||
|
|
||||||
const hasTwoFactorAuthentication = user.twoFactorEnabled;
|
const hasTwoFactorAuthentication = user.twoFactorEnabled;
|
||||||
|
|
||||||
|
const [enteredEmail, setEnteredEmail] = useState<string>('');
|
||||||
|
|
||||||
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
|
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
|
||||||
trpc.profile.deleteAccount.useMutation();
|
trpc.profile.deleteAccount.useMutation();
|
||||||
|
|
||||||
@ -76,10 +82,11 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Dialog>
|
<Dialog onOpenChange={() => setEnteredEmail('')}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="destructive">Delete Account</Button>
|
<Button variant="destructive">Delete Account</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader className="space-y-4">
|
<DialogHeader className="space-y-4">
|
||||||
<DialogTitle>Delete Account</DialogTitle>
|
<DialogTitle>Delete Account</DialogTitle>
|
||||||
@ -105,12 +112,29 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!hasTwoFactorAuthentication && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Label>
|
||||||
|
Please type{' '}
|
||||||
|
<span className="text-muted-foreground font-semibold">{user.email}</span> to
|
||||||
|
confirm.
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
className="mt-2"
|
||||||
|
aria-label="Confirm Email"
|
||||||
|
value={enteredEmail}
|
||||||
|
onChange={(e) => setEnteredEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
onClick={onDeleteAccount}
|
onClick={onDeleteAccount}
|
||||||
loading={isDeletingAccount}
|
loading={isDeletingAccount}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={hasTwoFactorAuthentication}
|
disabled={hasTwoFactorAuthentication || enteredEmail !== user.email}
|
||||||
>
|
>
|
||||||
{isDeletingAccount ? 'Deleting account...' : 'Confirm Deletion'}
|
{isDeletingAccount ? 'Deleting account...' : 'Confirm Deletion'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -15,15 +15,14 @@ export default function SettingsSecurityActivityPage() {
|
|||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title="Security activity"
|
title="Security activity"
|
||||||
subtitle="View all recent security activity related to your account."
|
subtitle="View all recent security activity related to your account."
|
||||||
|
hideDivider={true}
|
||||||
>
|
>
|
||||||
<div>
|
<ActivityPageBackButton />
|
||||||
<ActivityPageBackButton />
|
|
||||||
</div>
|
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
<hr className="my-4" />
|
<div className="mt-4">
|
||||||
|
<UserSecurityActivityDataTable />
|
||||||
<UserSecurityActivityDataTable />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,15 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-authenticator-app-dialog';
|
||||||
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||||
|
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
|
||||||
import { PasswordForm } from '~/components/forms/password';
|
import { PasswordForm } from '~/components/forms/password';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -18,6 +19,8 @@ export const metadata: Metadata = {
|
|||||||
export default async function SecuritySettingsPage() {
|
export default async function SecuritySettingsPage() {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
@ -25,57 +28,70 @@ export default async function SecuritySettingsPage() {
|
|||||||
subtitle="Here you can manage your password and security settings."
|
subtitle="Here you can manage your password and security settings."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{user.identityProvider === 'DOCUMENSO' ? (
|
{user.identityProvider === 'DOCUMENSO' && (
|
||||||
<div>
|
<>
|
||||||
<PasswordForm user={user} />
|
<PasswordForm user={user} />
|
||||||
|
|
||||||
<hr className="border-border/50 mt-6" />
|
<hr className="border-border/50 mt-6" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
variant="neutral"
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<div className="mb-4 sm:mb-0">
|
<div className="mb-4 sm:mb-0">
|
||||||
<AlertTitle>Two factor authentication</AlertTitle>
|
<AlertTitle>Two factor authentication</AlertTitle>
|
||||||
|
|
||||||
<AlertDescription className="mr-4">
|
<AlertDescription className="mr-4">
|
||||||
Create one-time passwords that serve as a secondary authentication method for
|
Add an authenticator to serve as a secondary authentication method{' '}
|
||||||
confirming your identity when requested during the sign-in process.
|
{user.identityProvider === 'DOCUMENSO'
|
||||||
</AlertDescription>
|
? 'when signing in, or when signing documents.'
|
||||||
</div>
|
: 'for signing documents.'}
|
||||||
|
|
||||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{user.twoFactorEnabled && (
|
|
||||||
<Alert
|
|
||||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
|
||||||
variant="neutral"
|
|
||||||
>
|
|
||||||
<div className="mb-4 sm:mb-0">
|
|
||||||
<AlertTitle>Recovery codes</AlertTitle>
|
|
||||||
|
|
||||||
<AlertDescription className="mr-4">
|
|
||||||
Two factor authentication recovery codes are used to access your account in the
|
|
||||||
event that you lose access to your authenticator app.
|
|
||||||
</AlertDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Alert className="p-6" variant="neutral">
|
|
||||||
<AlertTitle>
|
|
||||||
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
|
|
||||||
</AlertTitle>
|
|
||||||
|
|
||||||
<AlertDescription>
|
|
||||||
To update your password, enable two-factor authentication, and manage other security
|
|
||||||
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
|
|
||||||
settings.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.twoFactorEnabled ? (
|
||||||
|
<DisableAuthenticatorAppDialog />
|
||||||
|
) : (
|
||||||
|
<EnableAuthenticatorAppDialog />
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{user.twoFactorEnabled && (
|
||||||
|
<Alert
|
||||||
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>Recovery codes</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-4">
|
||||||
|
Two factor authentication recovery codes are used to access your account in the event
|
||||||
|
that you lose access to your authenticator app.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ViewRecoveryCodesDialog />
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPasskeyEnabled && (
|
||||||
|
<Alert
|
||||||
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>Passkeys</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-4">
|
||||||
|
Allows authenticating using biometrics, password managers, hardware keys, etc.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild variant="outline" className="bg-background">
|
||||||
|
<Link href="/settings/security/passkeys">Manage passkeys</Link>
|
||||||
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -91,7 +107,7 @@ export default async function SecuritySettingsPage() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button asChild>
|
<Button asChild variant="outline" className="bg-background">
|
||||||
<Link href="/settings/security/activity">View activity</Link>
|
<Link href="/settings/security/activity">View activity</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@ -0,0 +1,237 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
|
import { KeyRoundIcon } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type CreatePasskeyDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZCreatePasskeyFormSchema = z.object({
|
||||||
|
passkeyName: z.string().min(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
|
||||||
|
|
||||||
|
const parser = new UAParser();
|
||||||
|
|
||||||
|
export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<TCreatePasskeyFormSchema>({
|
||||||
|
resolver: zodResolver(ZCreatePasskeyFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
passkeyName: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createPasskeyRegistrationOptions, isLoading } =
|
||||||
|
trpc.auth.createPasskeyRegistrationOptions.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const passkeyRegistrationOptions = await createPasskeyRegistrationOptions();
|
||||||
|
|
||||||
|
const registrationResult = await startRegistration(passkeyRegistrationOptions);
|
||||||
|
|
||||||
|
await createPasskey({
|
||||||
|
passkeyName,
|
||||||
|
verificationResponse: registrationResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: 'Successfully created passkey',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
setOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
setFormError(err.code || error.code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractDefaultPasskeyName = () => {
|
||||||
|
if (!window || !window.navigator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parser.setUA(window.navigator.userAgent);
|
||||||
|
|
||||||
|
const result = parser.getResult();
|
||||||
|
const operatingSystem = result.os.name;
|
||||||
|
const browser = result.browser.name;
|
||||||
|
|
||||||
|
let passkeyName = '';
|
||||||
|
|
||||||
|
if (operatingSystem && browser) {
|
||||||
|
passkeyName = `${browser} (${operatingSystem})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return passkeyName;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
const defaultPasskeyName = extractDefaultPasskeyName();
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
passkeyName: defaultPasskeyName,
|
||||||
|
});
|
||||||
|
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="secondary" loading={isLoading}>
|
||||||
|
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
|
||||||
|
Add passkey
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add passkey</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
Passkeys allow you to sign in and authenticate using biometrics, password managers, etc.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="passkeyName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Passkey name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" placeholder="eg. Mac" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<AlertDescription>
|
||||||
|
When you click continue, you will be prompted to add the first available
|
||||||
|
authenticator on your system.
|
||||||
|
</AlertDescription>
|
||||||
|
|
||||||
|
<AlertDescription className="mt-2">
|
||||||
|
If you do not want to use the authenticator prompted, you can close it, which will
|
||||||
|
then display the next available authenticator.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
{match(formError)
|
||||||
|
.with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => (
|
||||||
|
<AlertDescription>This passkey has already been registered.</AlertDescription>
|
||||||
|
))
|
||||||
|
.with('TOO_MANY_PASSKEYS', () => (
|
||||||
|
<AlertDescription>
|
||||||
|
You cannot have more than {MAXIMUM_PASSKEYS} passkeys.
|
||||||
|
</AlertDescription>
|
||||||
|
))
|
||||||
|
.with('InvalidStateError', () => (
|
||||||
|
<>
|
||||||
|
<AlertTitle className="text-sm">
|
||||||
|
Passkey creation cancelled due to one of the following reasons:
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<ul className="mt-1 list-inside list-disc">
|
||||||
|
<li>Cancelled by user</li>
|
||||||
|
<li>Passkey already exists for the provided authenticator</li>
|
||||||
|
<li>Exceeded timeout</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<AlertDescription>
|
||||||
|
Something went wrong. Please try again or contact support.
|
||||||
|
</AlertDescription>
|
||||||
|
))}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
|
|
||||||
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
|
||||||
|
import { CreatePasskeyDialog } from './create-passkey-dialog';
|
||||||
|
import { UserPasskeysDataTable } from './user-passkeys-data-table';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Manage passkeys',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SettingsManagePasskeysPage() {
|
||||||
|
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||||
|
|
||||||
|
if (!isPasskeyEnabled) {
|
||||||
|
redirect('/settings/security');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader title="Passkeys" subtitle="Manage your passkeys." hideDivider={true}>
|
||||||
|
<CreatePasskeyDialog />
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<UserPasskeysDataTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,200 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type UserPasskeysDataTableActionsProps = {
|
||||||
|
className?: string;
|
||||||
|
passkeyId: string;
|
||||||
|
passkeyName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZUpdatePasskeySchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
|
||||||
|
|
||||||
|
export const UserPasskeysDataTableActions = ({
|
||||||
|
className,
|
||||||
|
passkeyId,
|
||||||
|
passkeyName,
|
||||||
|
}: UserPasskeysDataTableActionsProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TUpdatePasskeySchema>({
|
||||||
|
resolver: zodResolver(ZUpdatePasskeySchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: passkeyName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: updatePasskey, isLoading: isUpdatingPasskey } =
|
||||||
|
trpc.auth.updatePasskey.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Passkey has been updated',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description:
|
||||||
|
'We are unable to update this passkey at the moment. Please try again later.',
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: deletePasskey, isLoading: isDeletingPasskey } =
|
||||||
|
trpc.auth.deletePasskey.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Passkey has been removed',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description:
|
||||||
|
'We are unable to remove this passkey at the moment. Please try again later.',
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex justify-end space-x-2', className)}>
|
||||||
|
<Dialog
|
||||||
|
open={isUpdateDialogOpen}
|
||||||
|
onOpenChange={(value) => !isUpdatingPasskey && setIsUpdateDialogOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
<Button variant="outline">Edit</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Update passkey</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
You are currently updating the <strong>{passkeyName}</strong> passkey.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(async ({ name }) =>
|
||||||
|
updatePasskey({
|
||||||
|
passkeyId,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<fieldset className="flex h-full flex-col" disabled={isUpdatingPasskey}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel required>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isUpdatingPasskey}>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={(value) => !isDeletingPasskey && setIsDeleteDialogOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
|
<Button variant="destructive">Delete</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete passkey</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<fieldset disabled={isDeletingPasskey}>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={async () =>
|
||||||
|
deletePasskey({
|
||||||
|
passkeyId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
variant="destructive"
|
||||||
|
loading={isDeletingPasskey}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
import { UserPasskeysDataTableActions } from './user-passkeys-data-table-actions';
|
||||||
|
|
||||||
|
export const UserPasskeysDataTable = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||||
|
Object.fromEntries(searchParams ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery(
|
||||||
|
{
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Created',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => DateTime.fromJSDate(row.original.createdAt).toRelative(),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
header: 'Last used',
|
||||||
|
accessorKey: 'updatedAt',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.lastUsedAt
|
||||||
|
? DateTime.fromJSDate(row.original.lastUsedAt).toRelative()
|
||||||
|
: 'Never',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<UserPasskeysDataTableActions
|
||||||
|
className="justify-end"
|
||||||
|
passkeyId={row.original.id}
|
||||||
|
passkeyName={row.original.name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined}
|
||||||
|
onClearFilters={() => router.push(pathname ?? '/')}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-row space-x-2">
|
||||||
|
<Skeleton className="h-8 w-16 rounded" />
|
||||||
|
<Skeleton className="h-8 w-16 rounded" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -6,7 +6,9 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||||
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
@ -17,6 +19,7 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
|||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
|
import { SigningAuthPageView } from '../signing-auth-page';
|
||||||
import { DocumentPreviewButton } from './document-preview-button';
|
import { DocumentPreviewButton } from './document-preview-button';
|
||||||
|
|
||||||
export type CompletedSigningPageProps = {
|
export type CompletedSigningPageProps = {
|
||||||
@ -32,8 +35,11 @@ export default async function CompletedSigningPage({
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { user } = await getServerComponentSession();
|
||||||
|
|
||||||
const document = await getDocumentAndSenderByToken({
|
const document = await getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
|
requireAccessAuth: false,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!document || !document.documentData) {
|
if (!document || !document.documentData) {
|
||||||
@ -53,6 +59,17 @@ export default async function CompletedSigningPage({
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||||
|
type: 'ACCESS',
|
||||||
|
document,
|
||||||
|
recipient,
|
||||||
|
userId: user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDocumentAccessValid) {
|
||||||
|
return <SigningAuthPageView email={recipient.email} />;
|
||||||
|
}
|
||||||
|
|
||||||
const signatures = await getRecipientSignatures({ recipientId: recipient.id });
|
const signatures = await getRecipientSignatures({ recipientId: recipient.id });
|
||||||
|
|
||||||
const recipientName =
|
const recipientName =
|
||||||
|
|||||||
@ -11,6 +11,9 @@ import {
|
|||||||
convertToLocalSystemFormat,
|
convertToLocalSystemFormat,
|
||||||
} from '@documenso/lib/constants/date-formats';
|
} from '@documenso/lib/constants/date-formats';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
|
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 type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -38,12 +41,12 @@ export const DateField = ({
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
trpc.field.signFieldWithToken.useMutation();
|
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeSignedFieldWithToken,
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
@ -53,16 +56,23 @@ export const DateField = ({
|
|||||||
|
|
||||||
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
|
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
|
||||||
|
|
||||||
const onSign = async () => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
await signFieldWithToken({
|
await signFieldWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@ -0,0 +1,172 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
|
|
||||||
|
export type DocumentActionAuth2FAProps = {
|
||||||
|
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||||
|
actionVerb?: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (value: boolean) => void;
|
||||||
|
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Z2FAAuthFormSchema = z.object({
|
||||||
|
token: z
|
||||||
|
.string()
|
||||||
|
.min(4, { message: 'Token must at least 4 characters long' })
|
||||||
|
.max(10, { message: 'Token must be at most 10 characters long' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type T2FAAuthFormSchema = z.infer<typeof Z2FAAuthFormSchema>;
|
||||||
|
|
||||||
|
export const DocumentActionAuth2FA = ({
|
||||||
|
actionTarget = 'FIELD',
|
||||||
|
actionVerb = 'sign',
|
||||||
|
onReauthFormSubmit,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: DocumentActionAuth2FAProps) => {
|
||||||
|
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
|
||||||
|
useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
|
const form = useForm<T2FAAuthFormSchema>({
|
||||||
|
resolver: zodResolver(Z2FAAuthFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
token: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
|
||||||
|
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
|
||||||
|
try {
|
||||||
|
setIsCurrentlyAuthenticating(true);
|
||||||
|
|
||||||
|
await onReauthFormSubmit({
|
||||||
|
type: DocumentAuth.TWO_FACTOR_AUTH,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsCurrentlyAuthenticating(false);
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
setIsCurrentlyAuthenticating(false);
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
setFormErrorCode(error.code);
|
||||||
|
|
||||||
|
// Todo: Alert.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
token: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
setIs2FASetupSuccessful(false);
|
||||||
|
setFormErrorCode(null);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!user?.twoFactorEnabled && !is2FASetupSuccessful) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
|
||||||
|
? 'You need to setup 2FA to mark this document as viewed.'
|
||||||
|
: `You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{user?.identityProvider === 'DOCUMENSO' && (
|
||||||
|
<p className="mt-2">
|
||||||
|
By enabling 2FA, you will be required to enter a code from your authenticator app
|
||||||
|
every time you sign in.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<EnableAuthenticatorAppDialog onSuccess={() => setIs2FASetupSuccessful(true)} />
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>2FA token</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Token" />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formErrorCode && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Unauthorized</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
We were unable to verify your details. Please try again or contact support
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||||
|
Sign
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
|
|
||||||
|
export type DocumentActionAuthAccountProps = {
|
||||||
|
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||||
|
actionVerb?: string;
|
||||||
|
onOpenChange: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentActionAuthAccount = ({
|
||||||
|
actionTarget = 'FIELD',
|
||||||
|
actionVerb = 'sign',
|
||||||
|
onOpenChange,
|
||||||
|
}: DocumentActionAuthAccountProps) => {
|
||||||
|
const { recipient } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
|
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
|
||||||
|
|
||||||
|
const handleChangeAccount = async (email: string) => {
|
||||||
|
try {
|
||||||
|
setIsSigningOut(true);
|
||||||
|
|
||||||
|
const encryptedEmail = await encryptSecondaryData({
|
||||||
|
data: email,
|
||||||
|
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await signOut({
|
||||||
|
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setIsSigningOut(false);
|
||||||
|
|
||||||
|
// Todo: Alert.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset disabled={isSigningOut} className="space-y-4">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||||
|
<span>
|
||||||
|
To mark this document as viewed, you need to be logged in as{' '}
|
||||||
|
<strong>{recipient.email}</strong>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
|
||||||
|
in as <strong>{recipient.email}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={async () => handleChangeAccount(recipient.email)} loading={isSigningOut}>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DocumentAuth,
|
||||||
|
type TRecipientActionAuth,
|
||||||
|
type TRecipientActionAuthTypes,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
|
import type { FieldType } from '@documenso/prisma/client';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
|
||||||
|
import { DocumentActionAuth2FA } from './document-action-auth-2fa';
|
||||||
|
import { DocumentActionAuthAccount } from './document-action-auth-account';
|
||||||
|
import { DocumentActionAuthPasskey } from './document-action-auth-passkey';
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
|
|
||||||
|
export type DocumentActionAuthDialogProps = {
|
||||||
|
title?: string;
|
||||||
|
documentAuthType: TRecipientActionAuthTypes;
|
||||||
|
description?: string;
|
||||||
|
actionTarget: FieldType | 'DOCUMENT';
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (value: boolean) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback to run when the reauth form is filled out.
|
||||||
|
*/
|
||||||
|
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentActionAuthDialog = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
documentAuthType,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onReauthFormSubmit,
|
||||||
|
}: DocumentActionAuthDialogProps) => {
|
||||||
|
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
|
const handleOnOpenChange = (value: boolean) => {
|
||||||
|
if (isCurrentlyAuthenticating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOnOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title || 'Sign field'}</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
{description || 'Reauthentication is required to sign this field'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{match({ documentAuthType, user })
|
||||||
|
.with(
|
||||||
|
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||||
|
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
|
||||||
|
() => <DocumentActionAuthAccount onOpenChange={onOpenChange} />,
|
||||||
|
)
|
||||||
|
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||||
|
<DocumentActionAuthPasskey
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onReauthFormSubmit={onReauthFormSubmit}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
|
||||||
|
<DocumentActionAuth2FA
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onReauthFormSubmit={onReauthFormSubmit}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
||||||
|
.exhaustive()}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,252 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
|
||||||
|
import { CreatePasskeyDialog } from '~/app/(dashboard)/settings/security/passkeys/create-passkey-dialog';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
|
|
||||||
|
export type DocumentActionAuthPasskeyProps = {
|
||||||
|
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||||
|
actionVerb?: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (value: boolean) => void;
|
||||||
|
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZPasskeyAuthFormSchema = z.object({
|
||||||
|
passkeyId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TPasskeyAuthFormSchema = z.infer<typeof ZPasskeyAuthFormSchema>;
|
||||||
|
|
||||||
|
export const DocumentActionAuthPasskey = ({
|
||||||
|
actionTarget = 'FIELD',
|
||||||
|
actionVerb = 'sign',
|
||||||
|
onReauthFormSubmit,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: DocumentActionAuthPasskeyProps) => {
|
||||||
|
const {
|
||||||
|
recipient,
|
||||||
|
passkeyData,
|
||||||
|
preferredPasskeyId,
|
||||||
|
setPreferredPasskeyId,
|
||||||
|
isCurrentlyAuthenticating,
|
||||||
|
setIsCurrentlyAuthenticating,
|
||||||
|
refetchPasskeys,
|
||||||
|
} = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
|
const form = useForm<TPasskeyAuthFormSchema>({
|
||||||
|
resolver: zodResolver(ZPasskeyAuthFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
passkeyId: preferredPasskeyId || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createPasskeyAuthenticationOptions } =
|
||||||
|
trpc.auth.createPasskeyAuthenticationOptions.useMutation();
|
||||||
|
|
||||||
|
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ passkeyId }: TPasskeyAuthFormSchema) => {
|
||||||
|
try {
|
||||||
|
setPreferredPasskeyId(passkeyId);
|
||||||
|
setIsCurrentlyAuthenticating(true);
|
||||||
|
|
||||||
|
const { options, tokenReference } = await createPasskeyAuthenticationOptions({
|
||||||
|
preferredPasskeyId: passkeyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authenticationResponse = await startAuthentication(options);
|
||||||
|
|
||||||
|
await onReauthFormSubmit({
|
||||||
|
type: DocumentAuth.PASSKEY,
|
||||||
|
authenticationResponse,
|
||||||
|
tokenReference,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsCurrentlyAuthenticating(false);
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
setIsCurrentlyAuthenticating(false);
|
||||||
|
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
setFormErrorCode(error.code);
|
||||||
|
|
||||||
|
// Todo: Alert.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
passkeyId: preferredPasskeyId || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
setFormErrorCode(null);
|
||||||
|
}, [open, form, preferredPasskeyId]);
|
||||||
|
|
||||||
|
if (!browserSupportsWebAuthn()) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '}
|
||||||
|
this {actionTarget.toLowerCase()}.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passkeyData.isInitialLoading || (passkeyData.isError && passkeyData.passkeys.length === 0)) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-28 items-center justify-center">
|
||||||
|
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passkeyData.isError) {
|
||||||
|
return (
|
||||||
|
<div className="h-28 space-y-4">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>Something went wrong while loading your passkeys.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="button" onClick={() => void refetchPasskeys()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passkeyData.passkeys.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
|
||||||
|
? 'You need to setup a passkey to mark this document as viewed.'
|
||||||
|
: `You need to setup a passkey to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<CreatePasskeyDialog
|
||||||
|
onSuccess={async () => refetchPasskeys()}
|
||||||
|
trigger={<Button>Setup</Button>}
|
||||||
|
/>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="passkeyId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Passkey</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="bg-background text-muted-foreground">
|
||||||
|
<SelectValue
|
||||||
|
data-testid="documentAccessSelectValue"
|
||||||
|
placeholder="Select passkey"
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{passkeyData.passkeys.map((passkey) => (
|
||||||
|
<SelectItem key={passkey.id} value={passkey.id}>
|
||||||
|
{passkey.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formErrorCode && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Unauthorized</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
We were unable to verify your details. Please try again or contact support
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||||
|
Sign
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,230 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||||
|
import type {
|
||||||
|
TDocumentAuthOptions,
|
||||||
|
TRecipientAccessAuthTypes,
|
||||||
|
TRecipientActionAuthTypes,
|
||||||
|
TRecipientAuthOptions,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
|
import { DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import {
|
||||||
|
type Document,
|
||||||
|
FieldType,
|
||||||
|
type Passkey,
|
||||||
|
type Recipient,
|
||||||
|
type User,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
|
||||||
|
import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog';
|
||||||
|
import { DocumentActionAuthDialog } from './document-action-auth-dialog';
|
||||||
|
|
||||||
|
type PasskeyData = {
|
||||||
|
passkeys: Omit<Passkey, 'credentialId' | 'credentialPublicKey'>[];
|
||||||
|
isInitialLoading: boolean;
|
||||||
|
isRefetching: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentAuthContextValue = {
|
||||||
|
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
||||||
|
document: Document;
|
||||||
|
documentAuthOption: TDocumentAuthOptions;
|
||||||
|
setDocument: (_value: Document) => void;
|
||||||
|
recipient: Recipient;
|
||||||
|
recipientAuthOption: TRecipientAuthOptions;
|
||||||
|
setRecipient: (_value: Recipient) => void;
|
||||||
|
derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null;
|
||||||
|
derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
|
||||||
|
isAuthRedirectRequired: boolean;
|
||||||
|
isCurrentlyAuthenticating: boolean;
|
||||||
|
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
||||||
|
passkeyData: PasskeyData;
|
||||||
|
preferredPasskeyId: string | null;
|
||||||
|
setPreferredPasskeyId: (_value: string | null) => void;
|
||||||
|
user?: User | null;
|
||||||
|
refetchPasskeys: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentAuthContext = createContext<DocumentAuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useDocumentAuthContext = () => {
|
||||||
|
return useContext(DocumentAuthContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRequiredDocumentAuthContext = () => {
|
||||||
|
const context = useDocumentAuthContext();
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Document auth context is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DocumentAuthProviderProps {
|
||||||
|
document: Document;
|
||||||
|
recipient: Recipient;
|
||||||
|
user?: User | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocumentAuthProvider = ({
|
||||||
|
document: initialDocument,
|
||||||
|
recipient: initialRecipient,
|
||||||
|
user,
|
||||||
|
children,
|
||||||
|
}: DocumentAuthProviderProps) => {
|
||||||
|
const [document, setDocument] = useState(initialDocument);
|
||||||
|
const [recipient, setRecipient] = useState(initialRecipient);
|
||||||
|
|
||||||
|
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
|
||||||
|
const [preferredPasskeyId, setPreferredPasskeyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
documentAuthOption,
|
||||||
|
recipientAuthOption,
|
||||||
|
derivedRecipientAccessAuth,
|
||||||
|
derivedRecipientActionAuth,
|
||||||
|
} = useMemo(
|
||||||
|
() =>
|
||||||
|
extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipient.authOptions,
|
||||||
|
}),
|
||||||
|
[document, recipient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const passkeyQuery = trpc.auth.findPasskeys.useQuery(
|
||||||
|
{
|
||||||
|
perPage: MAXIMUM_PASSKEYS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const passkeyData: PasskeyData = {
|
||||||
|
passkeys: passkeyQuery.data?.data || [],
|
||||||
|
isInitialLoading: passkeyQuery.isInitialLoading,
|
||||||
|
isRefetching: passkeyQuery.isRefetching,
|
||||||
|
isError: passkeyQuery.isError,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [documentAuthDialogPayload, setDocumentAuthDialogPayload] =
|
||||||
|
useState<ExecuteActionAuthProcedureOptions | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pre calculated auth payload if the current user is authenticated correctly
|
||||||
|
* for the `derivedRecipientActionAuth`.
|
||||||
|
*
|
||||||
|
* Will be `null` if the user still requires authentication, or if they don't need
|
||||||
|
* authentication.
|
||||||
|
*/
|
||||||
|
const preCalculatedActionAuthOptions = match(derivedRecipientActionAuth)
|
||||||
|
.with(DocumentAuth.ACCOUNT, () => {
|
||||||
|
if (recipient.email !== user?.email) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: DocumentAuth.ACCOUNT,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with(DocumentAuth.EXPLICIT_NONE, () => ({
|
||||||
|
type: DocumentAuth.EXPLICIT_NONE,
|
||||||
|
}))
|
||||||
|
.with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null)
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
|
||||||
|
// Directly run callback if no auth required.
|
||||||
|
if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) {
|
||||||
|
await options.onReauthFormSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run callback with precalculated auth options if available.
|
||||||
|
if (preCalculatedActionAuthOptions) {
|
||||||
|
setDocumentAuthDialogPayload(null);
|
||||||
|
await options.onReauthFormSubmit(preCalculatedActionAuthOptions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request the required auth from the user.
|
||||||
|
setDocumentAuthDialogPayload({
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { passkeys } = passkeyData;
|
||||||
|
|
||||||
|
if (!preferredPasskeyId && passkeys.length > 0) {
|
||||||
|
setPreferredPasskeyId(passkeys[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [passkeyData.passkeys]);
|
||||||
|
|
||||||
|
// Assume that a user must be logged in for any auth requirements.
|
||||||
|
const isAuthRedirectRequired = Boolean(
|
||||||
|
derivedRecipientActionAuth &&
|
||||||
|
derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE &&
|
||||||
|
user?.email !== recipient.email,
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchPasskeys = async () => {
|
||||||
|
await passkeyQuery.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentAuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
document,
|
||||||
|
setDocument,
|
||||||
|
executeActionAuthProcedure,
|
||||||
|
recipient,
|
||||||
|
setRecipient,
|
||||||
|
documentAuthOption,
|
||||||
|
recipientAuthOption,
|
||||||
|
derivedRecipientAccessAuth,
|
||||||
|
derivedRecipientActionAuth,
|
||||||
|
isAuthRedirectRequired,
|
||||||
|
isCurrentlyAuthenticating,
|
||||||
|
setIsCurrentlyAuthenticating,
|
||||||
|
passkeyData,
|
||||||
|
preferredPasskeyId,
|
||||||
|
setPreferredPasskeyId,
|
||||||
|
refetchPasskeys,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{documentAuthDialogPayload && derivedRecipientActionAuth && (
|
||||||
|
<DocumentActionAuthDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={() => setDocumentAuthDialogPayload(null)}
|
||||||
|
onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit}
|
||||||
|
actionTarget={documentAuthDialogPayload.actionTarget}
|
||||||
|
documentAuthType={derivedRecipientActionAuth}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DocumentAuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExecuteActionAuthProcedureOptions = Omit<
|
||||||
|
DocumentActionAuthDialogProps,
|
||||||
|
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole'
|
||||||
|
>;
|
||||||
|
|
||||||
|
DocumentAuthProvider.displayName = 'DocumentAuthProvider';
|
||||||
@ -6,6 +6,9 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
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 type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -29,26 +32,33 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
trpc.field.signFieldWithToken.useMutation();
|
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeSignedFieldWithToken,
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
const onSign = async () => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
await signFieldWithToken({
|
await signFieldWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: providedEmail ?? '',
|
value: providedEmail ?? '',
|
||||||
isBase64: false,
|
isBase64: false,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { useSession } from 'next-auth/react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
|
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -41,10 +42,10 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
|
|||||||
const { mutateAsync: completeDocumentWithToken } =
|
const { mutateAsync: completeDocumentWithToken } =
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
const {
|
const { handleSubmit, formState } = useForm();
|
||||||
handleSubmit,
|
|
||||||
formState: { isSubmitting },
|
// Keep the loading state going if successful since the redirect may take some time.
|
||||||
} = useForm();
|
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
||||||
|
|
||||||
const uninsertedFields = useMemo(() => {
|
const uninsertedFields = useMemo(() => {
|
||||||
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||||
@ -64,9 +65,20 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await completeDocument();
|
||||||
|
|
||||||
|
// Reauth is currently not required for completing the document.
|
||||||
|
// await executeActionAuthProcedure({
|
||||||
|
// onReauthFormSubmit: completeDocument,
|
||||||
|
// actionTarget: 'DOCUMENT',
|
||||||
|
// });
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
||||||
await completeDocumentWithToken({
|
await completeDocumentWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
|
|||||||
@ -6,7 +6,10 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
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 { type Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -15,6 +18,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
import { useRequiredSigningContext } from './provider';
|
import { useRequiredSigningContext } from './provider';
|
||||||
import { SigningFieldContainer } from './signing-field-container';
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
@ -31,24 +35,50 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
const { fullName: providedFullName, setFullName: setProvidedFullName } =
|
const { fullName: providedFullName, setFullName: setProvidedFullName } =
|
||||||
useRequiredSigningContext();
|
useRequiredSigningContext();
|
||||||
|
|
||||||
|
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
trpc.field.signFieldWithToken.useMutation();
|
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeSignedFieldWithToken,
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
const [showFullNameModal, setShowFullNameModal] = useState(false);
|
const [showFullNameModal, setShowFullNameModal] = useState(false);
|
||||||
const [localFullName, setLocalFullName] = useState('');
|
const [localFullName, setLocalFullName] = useState('');
|
||||||
|
|
||||||
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
const onPreSign = () => {
|
||||||
|
if (!providedFullName) {
|
||||||
|
setShowFullNameModal(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the user clicks the sign button in the dialog where they enter their full name.
|
||||||
|
*/
|
||||||
|
const onDialogSignClick = () => {
|
||||||
|
setShowFullNameModal(false);
|
||||||
|
setProvidedFullName(localFullName);
|
||||||
|
|
||||||
|
void executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localFullName),
|
||||||
|
actionTarget: field.type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => {
|
||||||
try {
|
try {
|
||||||
if (!providedFullName && !localFullName) {
|
const value = name || providedFullName;
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
setShowFullNameModal(true);
|
setShowFullNameModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -56,18 +86,19 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
await signFieldWithToken({
|
await signFieldWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: source === 'local' && localFullName ? localFullName : providedFullName ?? '',
|
value,
|
||||||
isBase64: false,
|
isBase64: false,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (source === 'local' && !providedFullName) {
|
|
||||||
setProvidedFullName(localFullName);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocalFullName('');
|
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -98,7 +129,13 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Name">
|
<SigningFieldContainer
|
||||||
|
field={field}
|
||||||
|
onPreSign={onPreSign}
|
||||||
|
onSign={onSign}
|
||||||
|
onRemove={onRemove}
|
||||||
|
type="Name"
|
||||||
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<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" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
@ -147,10 +184,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!localFullName}
|
disabled={!localFullName}
|
||||||
onClick={() => {
|
onClick={() => onDialogSignClick()}
|
||||||
setShowFullNameModal(false);
|
|
||||||
void onSign('local');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Sign
|
Sign
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,35 +1,24 @@
|
|||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||||
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 { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
|
||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { DocumentAuthProvider } from './document-auth-provider';
|
||||||
|
|
||||||
import { DateField } from './date-field';
|
|
||||||
import { EmailField } from './email-field';
|
|
||||||
import { SigningForm } from './form';
|
|
||||||
import { NameField } from './name-field';
|
|
||||||
import { NoLongerAvailable } from './no-longer-available';
|
import { NoLongerAvailable } from './no-longer-available';
|
||||||
import { SigningProvider } from './provider';
|
import { SigningProvider } from './provider';
|
||||||
import { SignatureField } from './signature-field';
|
import { SigningAuthPageView } from './signing-auth-page';
|
||||||
import { TextField } from './text-field';
|
import { SigningPageView } from './signing-page-view';
|
||||||
|
|
||||||
export type SigningPageProps = {
|
export type SigningPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@ -42,6 +31,8 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { user } = await getServerComponentSession();
|
||||||
|
|
||||||
const requestHeaders = Object.fromEntries(headers().entries());
|
const requestHeaders = Object.fromEntries(headers().entries());
|
||||||
|
|
||||||
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||||
@ -49,21 +40,40 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
const [document, fields, recipient] = await Promise.all([
|
const [document, fields, recipient] = await Promise.all([
|
||||||
getDocumentAndSenderByToken({
|
getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
|
userId: user?.id,
|
||||||
|
requireAccessAuth: false,
|
||||||
}).catch(() => null),
|
}).catch(() => null),
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getRecipientByToken({ token }).catch(() => null),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
viewedDocument({ token, requestMetadata }).catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!document || !document.documentData || !recipient) {
|
if (!document || !document.documentData || !recipient) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipient.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
const { documentData, documentMeta } = document;
|
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||||
|
type: 'ACCESS',
|
||||||
|
document,
|
||||||
|
recipient,
|
||||||
|
userId: user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
const { user } = await getServerComponentSession();
|
if (!isDocumentAccessValid) {
|
||||||
|
return <SigningAuthPageView email={recipient.email} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
await viewedDocument({
|
||||||
|
token,
|
||||||
|
requestMetadata,
|
||||||
|
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
const { documentMeta } = document;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
document.status === DocumentStatus.COMPLETED ||
|
document.status === DocumentStatus.COMPLETED ||
|
||||||
@ -109,73 +119,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
fullName={user?.email === recipient.email ? user.name : recipient.name}
|
fullName={user?.email === recipient.email ? user.name : recipient.name}
|
||||||
signature={user?.email === recipient.email ? user.signature : undefined}
|
signature={user?.email === recipient.email ? user.signature : undefined}
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-screen-xl">
|
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
<SigningPageView recipient={recipient} document={document} fields={fields} />
|
||||||
{truncatedTitle}
|
</DocumentAuthProvider>
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{document.User.name} ({document.User.email}) has invited you to{' '}
|
|
||||||
{recipient.role === RecipientRole.VIEWER && 'view'}
|
|
||||||
{recipient.role === RecipientRole.SIGNER && 'sign'}
|
|
||||||
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
|
||||||
<Card
|
|
||||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
|
||||||
gradient
|
|
||||||
>
|
|
||||||
<CardContent className="p-2">
|
|
||||||
<LazyPDFViewer
|
|
||||||
key={documentData.id}
|
|
||||||
documentData={documentData}
|
|
||||||
document={document}
|
|
||||||
password={documentMeta?.password}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
|
||||||
<SigningForm
|
|
||||||
document={document}
|
|
||||||
recipient={recipient}
|
|
||||||
fields={fields}
|
|
||||||
redirectUrl={documentMeta?.redirectUrl}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
|
||||||
{fields.map((field) =>
|
|
||||||
match(field.type)
|
|
||||||
.with(FieldType.SIGNATURE, () => (
|
|
||||||
<SignatureField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.with(FieldType.NAME, () => (
|
|
||||||
<NameField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.with(FieldType.DATE, () => (
|
|
||||||
<DateField
|
|
||||||
key={field.id}
|
|
||||||
field={field}
|
|
||||||
recipient={recipient}
|
|
||||||
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
|
||||||
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
.with(FieldType.EMAIL, () => (
|
|
||||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.with(FieldType.TEXT, () => (
|
|
||||||
<TextField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.otherwise(() => null),
|
|
||||||
)}
|
|
||||||
</ElementVisible>
|
|
||||||
</div>
|
|
||||||
</SigningProvider>
|
</SigningProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,11 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
|
||||||
|
import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
export type SignDialogProps = {
|
export type SignDialogProps = {
|
||||||
@ -33,8 +35,28 @@ export const SignDialog = ({
|
|||||||
const truncatedTitle = truncateTitle(document.title);
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
const isComplete = fields.every((field) => field.inserted);
|
const isComplete = fields.every((field) => field.inserted);
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (isSubmitting || !isComplete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reauth is currently not required for signing the document.
|
||||||
|
// if (isAuthRedirectRequired) {
|
||||||
|
// await executeActionAuthProcedure({
|
||||||
|
// actionTarget: 'DOCUMENT',
|
||||||
|
// onReauthFormSubmit: () => {
|
||||||
|
// // Do nothing since the user should be redirected.
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
setShowDialog(open);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showDialog && isComplete} onOpenChange={setShowDialog}>
|
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@ -46,23 +68,39 @@ export const SignDialog = ({
|
|||||||
{isComplete ? 'Complete' : 'Next field'}
|
{isComplete ? 'Complete' : 'Next field'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<div className="text-center">
|
<DialogTitle>
|
||||||
<div className="text-foreground text-xl font-semibold">
|
<div className="text-foreground text-xl font-semibold">
|
||||||
{role === RecipientRole.VIEWER && 'Mark Document as Viewed'}
|
{role === RecipientRole.VIEWER && 'Complete Viewing'}
|
||||||
{role === RecipientRole.SIGNER && 'Sign Document'}
|
{role === RecipientRole.SIGNER && 'Complete Signing'}
|
||||||
{role === RecipientRole.APPROVER && 'Approve Document'}
|
{role === RecipientRole.APPROVER && 'Complete Approval'}
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
|
|
||||||
{role === RecipientRole.VIEWER &&
|
|
||||||
`You are about to finish viewing "${truncatedTitle}". Are you sure?`}
|
|
||||||
{role === RecipientRole.SIGNER &&
|
|
||||||
`You are about to finish signing "${truncatedTitle}". Are you sure?`}
|
|
||||||
{role === RecipientRole.APPROVER &&
|
|
||||||
`You are about to finish approving "${truncatedTitle}". Are you sure?`}
|
|
||||||
</div>
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
|
{role === RecipientRole.VIEWER && (
|
||||||
|
<span>
|
||||||
|
You are about to complete viewing "{truncatedTitle}".
|
||||||
|
<br /> Are you sure?
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{role === RecipientRole.SIGNER && (
|
||||||
|
<span>
|
||||||
|
You are about to complete signing "{truncatedTitle}".
|
||||||
|
<br /> Are you sure?
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{role === RecipientRole.APPROVER && (
|
||||||
|
<span>
|
||||||
|
You are about to complete approving "{truncatedTitle}".
|
||||||
|
<br /> Are you sure?
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SigningDisclosure className="mt-4" />
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, useTransition } from 'react';
|
import { useMemo, useState, useTransition } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
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 { type Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -15,6 +18,9 @@ import { Label } from '@documenso/ui/primitives/label';
|
|||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
import { useRequiredSigningContext } from './provider';
|
import { useRequiredSigningContext } from './provider';
|
||||||
import { SigningFieldContainer } from './signing-field-container';
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
@ -29,18 +35,21 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
||||||
useRequiredSigningContext();
|
useRequiredSigningContext();
|
||||||
|
|
||||||
|
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
trpc.field.signFieldWithToken.useMutation();
|
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeSignedFieldWithToken,
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const { Signature: signature } = field;
|
const { Signature: signature } = field;
|
||||||
|
|
||||||
@ -48,7 +57,6 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
|
|
||||||
const [showSignatureModal, setShowSignatureModal] = useState(false);
|
const [showSignatureModal, setShowSignatureModal] = useState(false);
|
||||||
const [localSignature, setLocalSignature] = useState<string | null>(null);
|
const [localSignature, setLocalSignature] = useState<string | null>(null);
|
||||||
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
|
|
||||||
|
|
||||||
const state = useMemo<SignatureFieldState>(() => {
|
const state = useMemo<SignatureFieldState>(() => {
|
||||||
if (!field.inserted) {
|
if (!field.inserted) {
|
||||||
@ -62,23 +70,38 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
return 'signed-text';
|
return 'signed-text';
|
||||||
}, [field.inserted, signature?.signatureImageAsBase64]);
|
}, [field.inserted, signature?.signatureImageAsBase64]);
|
||||||
|
|
||||||
useEffect(() => {
|
const onPreSign = () => {
|
||||||
if (!showSignatureModal && !isLocalSignatureSet) {
|
if (!providedSignature) {
|
||||||
setLocalSignature(null);
|
setShowSignatureModal(true);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}, [showSignatureModal, isLocalSignatureSet]);
|
|
||||||
|
|
||||||
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the user clicks the sign button in the dialog where they enter their signature.
|
||||||
|
*/
|
||||||
|
const onDialogSignClick = () => {
|
||||||
|
setShowSignatureModal(false);
|
||||||
|
setProvidedSignature(localSignature);
|
||||||
|
|
||||||
|
if (!localSignature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localSignature),
|
||||||
|
actionTarget: field.type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSign = async (authOptions?: TRecipientActionAuth, signature?: string) => {
|
||||||
try {
|
try {
|
||||||
if (!providedSignature && !localSignature) {
|
const value = signature || providedSignature;
|
||||||
setIsLocalSignatureSet(false);
|
|
||||||
setShowSignatureModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = source === 'local' && localSignature ? localSignature : providedSignature ?? '';
|
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
setShowSignatureModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,16 +110,17 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value,
|
value,
|
||||||
isBase64: true,
|
isBase64: true,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (source === 'local' && !providedSignature) {
|
|
||||||
setProvidedSignature(localSignature);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocalSignature(null);
|
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -127,7 +151,13 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
|
<SigningFieldContainer
|
||||||
|
field={field}
|
||||||
|
onPreSign={onPreSign}
|
||||||
|
onSign={onSign}
|
||||||
|
onRemove={onRemove}
|
||||||
|
type="Signature"
|
||||||
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<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" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
@ -172,6 +202,8 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SigningDisclosure />
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<Button
|
<Button
|
||||||
@ -190,11 +222,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!localSignature}
|
disabled={!localSignature}
|
||||||
onClick={() => {
|
onClick={() => onDialogSignClick()}
|
||||||
setShowSignatureModal(false);
|
|
||||||
setIsLocalSignatureSet(true);
|
|
||||||
void onSign('local');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Sign
|
Sign
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type SigningAuthPageViewProps = {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
|
||||||
|
|
||||||
|
const handleChangeAccount = async (email: string) => {
|
||||||
|
try {
|
||||||
|
setIsSigningOut(true);
|
||||||
|
|
||||||
|
const encryptedEmail = await encryptSecondaryData({
|
||||||
|
data: email,
|
||||||
|
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await signOut({
|
||||||
|
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'We were unable to log you out at this time.',
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSigningOut(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold">Authentication required</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
You need to be logged in as <strong>{email}</strong> to view this page.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="mt-4 w-full"
|
||||||
|
type="submit"
|
||||||
|
onClick={async () => handleChangeAccount(email)}
|
||||||
|
loading={isSigningOut}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -2,15 +2,38 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
|
|
||||||
export type SignatureFieldProps = {
|
export type SignatureFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onSign?: () => Promise<void> | void;
|
|
||||||
|
/**
|
||||||
|
* A function that is called before the field requires to be signed, or reauthed.
|
||||||
|
*
|
||||||
|
* Example, you may want to show a dialog prior to signing where they can enter a value.
|
||||||
|
*
|
||||||
|
* Once that action is complete, you will need to call `executeActionAuthProcedure` to proceed
|
||||||
|
* regardless if it requires reauth or not.
|
||||||
|
*
|
||||||
|
* If the function returns true, we will proceed with the signing process. Otherwise if
|
||||||
|
* false is returned we will not proceed.
|
||||||
|
*/
|
||||||
|
onPreSign?: () => Promise<boolean> | boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function required to be executed to insert the field.
|
||||||
|
*
|
||||||
|
* The auth values will be passed in if available.
|
||||||
|
*/
|
||||||
|
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
|
||||||
onRemove?: () => Promise<void> | void;
|
onRemove?: () => Promise<void> | void;
|
||||||
type?: 'Date' | 'Email' | 'Name' | 'Signature';
|
type?: 'Date' | 'Email' | 'Name' | 'Signature';
|
||||||
tooltipText?: string | null;
|
tooltipText?: string | null;
|
||||||
@ -19,18 +42,56 @@ export type SignatureFieldProps = {
|
|||||||
export const SigningFieldContainer = ({
|
export const SigningFieldContainer = ({
|
||||||
field,
|
field,
|
||||||
loading,
|
loading,
|
||||||
|
onPreSign,
|
||||||
onSign,
|
onSign,
|
||||||
onRemove,
|
onRemove,
|
||||||
children,
|
children,
|
||||||
type,
|
type,
|
||||||
tooltipText,
|
tooltipText,
|
||||||
}: SignatureFieldProps) => {
|
}: SignatureFieldProps) => {
|
||||||
const onSignFieldClick = async () => {
|
const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext();
|
||||||
if (field.inserted) {
|
|
||||||
|
const handleInsertField = async () => {
|
||||||
|
if (field.inserted || !onSign) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await onSign?.();
|
// Bypass reauth for non signature fields.
|
||||||
|
if (field.type !== FieldType.SIGNATURE) {
|
||||||
|
const presignResult = await onPreSign?.();
|
||||||
|
|
||||||
|
if (presignResult === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSign();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthRedirectRequired) {
|
||||||
|
await executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: () => {
|
||||||
|
// Do nothing since the user should be redirected.
|
||||||
|
},
|
||||||
|
actionTarget: field.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle any presign requirements, and halt if required.
|
||||||
|
if (onPreSign) {
|
||||||
|
const preSignResult = await onPreSign();
|
||||||
|
|
||||||
|
if (preSignResult === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: onSign,
|
||||||
|
actionTarget: field.type,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRemoveSignedFieldClick = async () => {
|
const onRemoveSignedFieldClick = async () => {
|
||||||
@ -47,7 +108,7 @@ export const SigningFieldContainer = ({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="absolute inset-0 z-10 h-full w-full"
|
className="absolute inset-0 z-10 h-full w-full"
|
||||||
onClick={onSignFieldClick}
|
onClick={async () => handleInsertField()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
102
apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx
Normal file
102
apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
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 type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
|
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';
|
||||||
|
|
||||||
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
|
import { DateField } from './date-field';
|
||||||
|
import { EmailField } from './email-field';
|
||||||
|
import { SigningForm } from './form';
|
||||||
|
import { NameField } from './name-field';
|
||||||
|
import { SignatureField } from './signature-field';
|
||||||
|
import { TextField } from './text-field';
|
||||||
|
|
||||||
|
export type SigningPageViewProps = {
|
||||||
|
document: DocumentAndSender;
|
||||||
|
recipient: Recipient;
|
||||||
|
fields: Field[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => {
|
||||||
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
|
|
||||||
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl">
|
||||||
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
|
{truncatedTitle}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{document.User.name} ({document.User.email}) has invited you to{' '}
|
||||||
|
{recipient.role === RecipientRole.VIEWER && 'view'}
|
||||||
|
{recipient.role === RecipientRole.SIGNER && 'sign'}
|
||||||
|
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||||
|
<Card
|
||||||
|
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||||
|
gradient
|
||||||
|
>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<LazyPDFViewer
|
||||||
|
key={documentData.id}
|
||||||
|
documentData={documentData}
|
||||||
|
document={document}
|
||||||
|
password={documentMeta?.password}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
||||||
|
<SigningForm
|
||||||
|
document={document}
|
||||||
|
recipient={recipient}
|
||||||
|
fields={fields}
|
||||||
|
redirectUrl={documentMeta?.redirectUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
|
{fields.map((field) =>
|
||||||
|
match(field.type)
|
||||||
|
.with(FieldType.SIGNATURE, () => (
|
||||||
|
<SignatureField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
|
.with(FieldType.NAME, () => (
|
||||||
|
<NameField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
|
.with(FieldType.DATE, () => (
|
||||||
|
<DateField
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
recipient={recipient}
|
||||||
|
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||||
|
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with(FieldType.EMAIL, () => (
|
||||||
|
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
|
.with(FieldType.TEXT, () => (
|
||||||
|
<TextField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
|
.otherwise(() => null),
|
||||||
|
)}
|
||||||
|
</ElementVisible>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -6,6 +6,9 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
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 type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -15,6 +18,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
import { SigningFieldContainer } from './signing-field-container';
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
export type TextFieldProps = {
|
export type TextFieldProps = {
|
||||||
@ -27,36 +31,52 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
trpc.field.signFieldWithToken.useMutation();
|
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeSignedFieldWithToken,
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
||||||
const [localText, setLocalCustomText] = useState('');
|
const [localText, setLocalCustomText] = useState('');
|
||||||
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showCustomTextModal && !isLocalSignatureSet) {
|
if (!showCustomTextModal) {
|
||||||
setLocalCustomText('');
|
setLocalCustomText('');
|
||||||
}
|
}
|
||||||
}, [showCustomTextModal, isLocalSignatureSet]);
|
}, [showCustomTextModal]);
|
||||||
|
|
||||||
const onSign = async () => {
|
/**
|
||||||
|
* When the user clicks the sign button in the dialog where they enter the text field.
|
||||||
|
*/
|
||||||
|
const onDialogSignClick = () => {
|
||||||
|
setShowCustomTextModal(false);
|
||||||
|
|
||||||
|
void executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||||
|
actionTarget: field.type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPreSign = () => {
|
||||||
|
if (!localText) {
|
||||||
|
setShowCustomTextModal(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
if (!localText) {
|
|
||||||
setIsLocalSignatureSet(false);
|
|
||||||
setShowCustomTextModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!localText) {
|
if (!localText) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -66,12 +86,19 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: localText,
|
value: localText,
|
||||||
isBase64: true,
|
isBase64: true,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
setLocalCustomText('');
|
setLocalCustomText('');
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -102,7 +129,13 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
|
<SigningFieldContainer
|
||||||
|
field={field}
|
||||||
|
onPreSign={onPreSign}
|
||||||
|
onSign={onSign}
|
||||||
|
onRemove={onRemove}
|
||||||
|
type="Signature"
|
||||||
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<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" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
@ -149,11 +182,7 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!localText}
|
disabled={!localText}
|
||||||
onClick={() => {
|
onClick={() => onDialogSignClick()}
|
||||||
setShowCustomTextModal(false);
|
|
||||||
setIsLocalSignatureSet(true);
|
|
||||||
void onSign();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Save Text
|
Save Text
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -0,0 +1,108 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export default function SignatureDisclosure() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<article className="prose">
|
||||||
|
<h1>Electronic Signature Disclosure</h1>
|
||||||
|
|
||||||
|
<h2>Welcome</h2>
|
||||||
|
<p>
|
||||||
|
Thank you for using Documenso to perform your electronic document signing. The purpose of
|
||||||
|
this disclosure is to inform you about the process, legality, and your rights regarding
|
||||||
|
the use of electronic signatures on our platform. By opting to use an electronic
|
||||||
|
signature, you are agreeing to the terms and conditions outlined below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Acceptance and Consent</h2>
|
||||||
|
<p>
|
||||||
|
When you use our platform to affix your electronic signature to documents, you are
|
||||||
|
consenting to do so under the Electronic Signatures in Global and National Commerce Act
|
||||||
|
(E-Sign Act) and other applicable laws. This action indicates your agreement to use
|
||||||
|
electronic means to sign documents and receive notifications.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Legality of Electronic Signatures</h2>
|
||||||
|
<p>
|
||||||
|
An electronic signature provided by you on our platform, achieved through clicking through
|
||||||
|
to a document and entering your name, or any other electronic signing method we provide,
|
||||||
|
is legally binding. It carries the same weight and enforceability as a manual signature
|
||||||
|
written with ink on paper.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>System Requirements</h2>
|
||||||
|
<p>To use our electronic signature service, you must have access to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>A stable internet connection</li>
|
||||||
|
<li>An email account</li>
|
||||||
|
<li>A device capable of accessing, opening, and reading documents</li>
|
||||||
|
<li>A means to print or download documents for your records</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Electronic Delivery of Documents</h2>
|
||||||
|
<p>
|
||||||
|
All documents related to the electronic signing process will be provided to you
|
||||||
|
electronically through our platform or via email. It is your responsibility to ensure that
|
||||||
|
your email address is current and that you can receive and open our emails.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Consent to Electronic Transactions</h2>
|
||||||
|
<p>
|
||||||
|
By using the electronic signature feature, you are consenting to conduct transactions and
|
||||||
|
receive disclosures electronically. You acknowledge that your electronic signature on
|
||||||
|
documents is binding and that you accept the terms outlined in the documents you are
|
||||||
|
signing.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Withdrawing Consent</h2>
|
||||||
|
<p>
|
||||||
|
You have the right to withdraw your consent to use electronic signatures at any time
|
||||||
|
before completing the signing process. To withdraw your consent, please contact the sender
|
||||||
|
of the document. In failing to contact the sender you may reach out to{' '}
|
||||||
|
<a href="mailto:support@documenso.com">support@documenso.com</a> for assistance. Be aware
|
||||||
|
that withdrawing consent may delay or halt the completion of the related transaction or
|
||||||
|
service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Updating Your Information</h2>
|
||||||
|
<p>
|
||||||
|
It is crucial to keep your contact information, especially your email address, up to date
|
||||||
|
with us. Please notify us immediately of any changes to ensure that you continue to
|
||||||
|
receive all necessary communications.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Retention of Documents</h2>
|
||||||
|
<p>
|
||||||
|
After signing a document electronically, you will be provided the opportunity to view,
|
||||||
|
download, and print the document for your records. It is highly recommended that you
|
||||||
|
retain a copy of all electronically signed documents for your personal records. We will
|
||||||
|
also retain a copy of the signed document for our records however we may not be able to
|
||||||
|
provide you with a copy of the signed document after a certain period of time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Acknowledgment</h2>
|
||||||
|
<p>
|
||||||
|
By proceeding to use the electronic signature service provided by Documenso, you affirm
|
||||||
|
that you have read and understood this disclosure. You agree to all terms and conditions
|
||||||
|
related to the use of electronic signatures and electronic transactions as outlined
|
||||||
|
herein.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Contact Information</h2>
|
||||||
|
<p>
|
||||||
|
For any questions regarding this disclosure, electronic signatures, or any related
|
||||||
|
process, please contact us at:{' '}
|
||||||
|
<a href="mailto:support@documenso.com">support@documenso.com</a>
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/documents">Back to Documents</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,7 +10,7 @@ type UnauthenticatedLayoutProps = {
|
|||||||
|
|
||||||
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
|
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||||
<div>
|
<div>
|
||||||
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
|
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SignUpFormV2
|
<SignUpFormV2
|
||||||
className="w-screen max-w-screen-2xl px-4 md:px-16"
|
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
|
||||||
initialEmail={email || undefined}
|
initialEmail={email || undefined}
|
||||||
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Suspense } from 'react';
|
|||||||
|
|
||||||
import { Caveat, Inter } from 'next/font/google';
|
import { Caveat, Inter } from 'next/font/google';
|
||||||
|
|
||||||
|
import { AxiomWebVitals } from 'next-axiom';
|
||||||
import { PublicEnvScript } from 'next-runtime-env';
|
import { PublicEnvScript } from 'next-runtime-env';
|
||||||
|
|
||||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
@ -71,6 +72,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<PublicEnvScript />
|
<PublicEnvScript />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
<AxiomWebVitals />
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<PostHogPageview />
|
<PostHogPageview />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { StackAvatar } from './stack-avatar';
|
import { StackAvatar } from './stack-avatar';
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,10 @@ import {
|
|||||||
SETTINGS_PAGE_SHORTCUT,
|
SETTINGS_PAGE_SHORTCUT,
|
||||||
TEMPLATES_PAGE_SHORTCUT,
|
TEMPLATES_PAGE_SHORTCUT,
|
||||||
} from '@documenso/lib/constants/keyboard-shortcuts';
|
} from '@documenso/lib/constants/keyboard-shortcuts';
|
||||||
|
import {
|
||||||
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
SKIP_QUERY_BATCH_META,
|
||||||
|
} from '@documenso/lib/constants/trpc';
|
||||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import {
|
import {
|
||||||
@ -82,6 +86,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
|
// Do not batch this due to relatively long request time compared to
|
||||||
|
// other queries which are generally batched with this.
|
||||||
|
...SKIP_QUERY_BATCH_META,
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -93,7 +93,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
<Button
|
<Button
|
||||||
data-testid="menu-switcher"
|
data-testid="menu-switcher"
|
||||||
variant="none"
|
variant="none"
|
||||||
className="relative flex h-12 flex-row items-center px-2 py-2 ring-0 focus-visible:border-0 focus-visible:ring-0"
|
className="relative flex h-12 flex-row items-center px-2 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent"
|
||||||
>
|
>
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
||||||
|
|||||||
@ -5,11 +5,18 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
export type SettingsHeaderProps = {
|
export type SettingsHeaderProps = {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
hideDivider?: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsHeader = ({ children, title, subtitle, className }: SettingsHeaderProps) => {
|
export const SettingsHeader = ({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
className,
|
||||||
|
hideDivider,
|
||||||
|
}: SettingsHeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn('flex flex-row items-center justify-between', className)}>
|
<div className={cn('flex flex-row items-center justify-between', className)}>
|
||||||
@ -22,7 +29,7 @@ export const SettingsHeader = ({ children, title, subtitle, className }: Setting
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="my-4" />
|
{!hideDivider && <hr className="my-4" />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,19 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { Mail, PlusCircle, Trash } from 'lucide-react';
|
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
|
||||||
|
import Papa, { type ParseResult } from 'papaparse';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||||
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -39,6 +42,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@documenso/ui/primitives/select';
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type InviteTeamMembersDialogProps = {
|
export type InviteTeamMembersDialogProps = {
|
||||||
@ -51,18 +55,45 @@ const ZInviteTeamMembersFormSchema = z
|
|||||||
.object({
|
.object({
|
||||||
invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
|
invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
|
||||||
})
|
})
|
||||||
.refine(
|
// Display exactly which rows are duplicates.
|
||||||
(schema) => {
|
.superRefine((items, ctx) => {
|
||||||
const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase());
|
const uniqueEmails = new Map<string, number>();
|
||||||
|
|
||||||
return new Set(emails).size === emails.length;
|
for (const [index, invitation] of items.invitations.entries()) {
|
||||||
},
|
const email = invitation.email.toLowerCase();
|
||||||
// Dirty hack to handle errors when .root is populated for an array type
|
|
||||||
{ message: 'Members must have unique emails', path: ['members__root'] },
|
const firstFoundIndex = uniqueEmails.get(email);
|
||||||
);
|
|
||||||
|
if (firstFoundIndex === undefined) {
|
||||||
|
uniqueEmails.set(email, index);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Emails must be unique',
|
||||||
|
path: ['invitations', index, 'email'],
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Emails must be unique',
|
||||||
|
path: ['invitations', firstFoundIndex, 'email'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>;
|
type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>;
|
||||||
|
|
||||||
|
type TabTypes = 'INDIVIDUAL' | 'BULK';
|
||||||
|
|
||||||
|
const ZImportTeamMemberSchema = z.array(
|
||||||
|
z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
role: z.nativeEnum(TeamMemberRole),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const InviteTeamMembersDialog = ({
|
export const InviteTeamMembersDialog = ({
|
||||||
currentUserTeamRole,
|
currentUserTeamRole,
|
||||||
teamId,
|
teamId,
|
||||||
@ -70,6 +101,8 @@ export const InviteTeamMembersDialog = ({
|
|||||||
...props
|
...props
|
||||||
}: InviteTeamMembersDialogProps) => {
|
}: InviteTeamMembersDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -130,9 +163,75 @@ export const InviteTeamMembersDialog = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setInvitationType('INDIVIDUAL');
|
||||||
}
|
}
|
||||||
}, [open, form]);
|
}, [open, form]);
|
||||||
|
|
||||||
|
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!e.target.files?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvFile = e.target.files[0];
|
||||||
|
|
||||||
|
Papa.parse(csvFile, {
|
||||||
|
skipEmptyLines: true,
|
||||||
|
comments: 'Work email,Job title',
|
||||||
|
complete: (results: ParseResult<string[]>) => {
|
||||||
|
const members = results.data.map((row) => {
|
||||||
|
const [email, role] = row;
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: email.trim(),
|
||||||
|
role: role.trim().toUpperCase(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove the first row if it contains the headers.
|
||||||
|
if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') {
|
||||||
|
members.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const importedInvitations = ZImportTeamMemberSchema.parse(members);
|
||||||
|
|
||||||
|
form.setValue('invitations', importedInvitations);
|
||||||
|
form.clearErrors('invitations');
|
||||||
|
|
||||||
|
setInvitationType('INDIVIDUAL');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.message);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'Please check the CSV file and make sure it is according to our format',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadTemplate = () => {
|
||||||
|
const data = [
|
||||||
|
{ email: 'admin@documenso.com', role: 'Admin' },
|
||||||
|
{ email: 'manager@documenso.com', role: 'Manager' },
|
||||||
|
{ email: 'member@documenso.com', role: 'Member' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const csvContent =
|
||||||
|
'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], {
|
||||||
|
type: 'text/csv',
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadFile({
|
||||||
|
filename: 'documenso-team-member-invites-template.csv',
|
||||||
|
data: blob,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
{...props}
|
{...props}
|
||||||
@ -152,92 +251,144 @@ export const InviteTeamMembersDialog = ({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Tabs
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
defaultValue="INDIVIDUAL"
|
||||||
<fieldset
|
value={invitationType}
|
||||||
className="flex h-full flex-col space-y-4"
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
disabled={form.formState.isSubmitting}
|
onValueChange={(value) => setInvitationType(value as TabTypes)}
|
||||||
>
|
>
|
||||||
{teamMemberInvites.map((teamMemberInvite, index) => (
|
<TabsList className="w-full">
|
||||||
<div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}>
|
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
|
||||||
<FormField
|
<MailIcon size={20} className="mr-2" />
|
||||||
control={form.control}
|
Invite Members
|
||||||
name={`invitations.${index}.email`}
|
</TabsTrigger>
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
{index === 0 && <FormLabel required>Email address</FormLabel>}
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
|
||||||
control={form.control}
|
<UsersIcon size={20} className="mr-2" /> Bulk Import
|
||||||
name={`invitations.${index}.role`}
|
</TabsTrigger>
|
||||||
render={({ field }) => (
|
</TabsList>
|
||||||
<FormItem className="w-full">
|
|
||||||
{index === 0 && <FormLabel required>Role</FormLabel>}
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent position="popper">
|
<TabsContent value="INDIVIDUAL">
|
||||||
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
|
<Form {...form}>
|
||||||
<SelectItem key={role} value={role}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
{TEAM_MEMBER_ROLE_MAP[role] ?? role}
|
<fieldset
|
||||||
</SelectItem>
|
className="flex h-full flex-col space-y-4"
|
||||||
))}
|
disabled={form.formState.isSubmitting}
|
||||||
</SelectContent>
|
>
|
||||||
</Select>
|
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||||
</FormControl>
|
{teamMemberInvites.map((teamMemberInvite, index) => (
|
||||||
<FormMessage />
|
<div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}>
|
||||||
</FormItem>
|
<FormField
|
||||||
)}
|
control={form.control}
|
||||||
/>
|
name={`invitations.${index}.email`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
{index === 0 && <FormLabel required>Email address</FormLabel>}
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`invitations.${index}.role`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
{index === 0 && <FormLabel required>Role</FormLabel>}
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{TEAM_MEMBER_ROLE_MAP[role] ?? role}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
index === 0 ? 'mt-8' : 'mt-0',
|
||||||
|
)}
|
||||||
|
disabled={teamMemberInvites.length === 1}
|
||||||
|
onClick={() => removeTeamMemberInvite(index)}
|
||||||
|
>
|
||||||
|
<Trash className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
size="sm"
|
||||||
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
variant="outline"
|
||||||
index === 0 ? 'mt-8' : 'mt-0',
|
className="w-fit"
|
||||||
)}
|
onClick={() => onAddTeamMemberInvite()}
|
||||||
disabled={teamMemberInvites.length === 1}
|
|
||||||
onClick={() => removeTeamMemberInvite(index)}
|
|
||||||
>
|
>
|
||||||
<Trash className="h-5 w-5" />
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
</button>
|
Add more
|
||||||
</div>
|
</Button>
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
<DialogFooter>
|
||||||
type="button"
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
size="sm"
|
Cancel
|
||||||
variant="outline"
|
</Button>
|
||||||
className="w-fit"
|
|
||||||
onClick={() => onAddTeamMemberInvite()}
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
>
|
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
|
||||||
<PlusCircle className="mr-2 h-4 w-4" />
|
Invite
|
||||||
Add more
|
</Button>
|
||||||
</Button>
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="BULK">
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<Card gradient className="h-32">
|
||||||
|
<CardContent
|
||||||
|
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="h-5 w-5" />
|
||||||
|
|
||||||
|
<p className="mt-1 text-sm">Click here to upload</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
onChange={onFileInputChange}
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept=".csv"
|
||||||
|
hidden
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
<Button type="button" variant="secondary" onClick={downloadTemplate}>
|
||||||
Cancel
|
<Download className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
Template
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
|
|
||||||
Invite
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
</div>
|
||||||
</form>
|
</TabsContent>
|
||||||
</Form>
|
</Tabs>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { SVGAttributes } from 'react';
|
import type { SVGAttributes } from 'react';
|
||||||
|
|
||||||
export type LogoProps = SVGAttributes<SVGSVGElement>;
|
export type LogoProps = SVGAttributes<SVGSVGElement>;
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { match } from 'ts-pattern';
|
|||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
|
||||||
|
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs';
|
import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -79,7 +80,11 @@ export const DocumentHistorySheet = ({
|
|||||||
* @param text The text to format
|
* @param text The text to format
|
||||||
* @returns The formatted text
|
* @returns The formatted text
|
||||||
*/
|
*/
|
||||||
const formatGenericText = (text: string) => {
|
const formatGenericText = (text?: string | null) => {
|
||||||
|
if (!text) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
|
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -219,6 +224,24 @@ export const DocumentHistorySheet = ({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.with(
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED },
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED },
|
||||||
|
({ data }) => (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
key: 'Old',
|
||||||
|
value: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'New',
|
||||||
|
value: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
|
||||||
if (data.changes.length === 0) {
|
if (data.changes.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@ -281,6 +304,7 @@ export const DocumentHistorySheet = ({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
|
|
||||||
{isUserDetailsVisible && (
|
{isUserDetailsVisible && (
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type FormErrorMessageProps = {
|
|
||||||
className?: string;
|
|
||||||
error: { message?: string } | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => {
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{error && (
|
|
||||||
<motion.p
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
y: -10,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
y: 10,
|
|
||||||
}}
|
|
||||||
className={cn('text-xs text-red-500', className)}
|
|
||||||
>
|
|
||||||
{error.message}
|
|
||||||
</motion.p>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { Globe, Lock } from 'lucide-react';
|
import { Globe, Lock } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||||
|
|
||||||
import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
|
import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
type TemplateTypeIcon = {
|
type TemplateTypeIcon = {
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { DisableAuthenticatorAppDialog } from './disable-authenticator-app-dialog';
|
|
||||||
import { EnableAuthenticatorAppDialog } from './enable-authenticator-app-dialog';
|
|
||||||
|
|
||||||
type AuthenticatorAppProps = {
|
|
||||||
isTwoFactorEnabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps) => {
|
|
||||||
const [modalState, setModalState] = useState<'enable' | 'disable' | null>(null);
|
|
||||||
|
|
||||||
const isEnableDialogOpen = modalState === 'enable';
|
|
||||||
const isDisableDialogOpen = modalState === 'disable';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{isTwoFactorEnabled ? (
|
|
||||||
<Button variant="destructive" onClick={() => setModalState('disable')}>
|
|
||||||
Disable 2FA
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button onClick={() => setModalState('enable')}>Enable 2FA</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EnableAuthenticatorAppDialog
|
|
||||||
open={isEnableDialogOpen}
|
|
||||||
onOpenChange={(open) => !open && setModalState(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DisableAuthenticatorAppDialog
|
|
||||||
open={isDisableDialogOpen}
|
|
||||||
onOpenChange={(open) => !open && setModalState(null)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,3 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@ -9,65 +13,51 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export const ZDisableTwoFactorAuthenticationForm = z.object({
|
export const ZDisable2FAForm = z.object({
|
||||||
password: z.string().min(6).max(72),
|
token: z.string(),
|
||||||
backupCode: z.string(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TDisableTwoFactorAuthenticationForm = z.infer<
|
export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
|
||||||
typeof ZDisableTwoFactorAuthenticationForm
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type DisableAuthenticatorAppDialogProps = {
|
export const DisableAuthenticatorAppDialog = () => {
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (_open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DisableAuthenticatorAppDialog = ({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: DisableAuthenticatorAppDialogProps) => {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: disableTwoFactorAuthentication } =
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
trpc.twoFactorAuthentication.disable.useMutation();
|
|
||||||
|
|
||||||
const disableTwoFactorAuthenticationForm = useForm<TDisableTwoFactorAuthenticationForm>({
|
const { mutateAsync: disable2FA } = trpc.twoFactorAuthentication.disable.useMutation();
|
||||||
|
|
||||||
|
const disable2FAForm = useForm<TDisable2FAForm>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
password: '',
|
token: '',
|
||||||
backupCode: '',
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZDisableTwoFactorAuthenticationForm),
|
resolver: zodResolver(ZDisable2FAForm),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } =
|
const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState;
|
||||||
disableTwoFactorAuthenticationForm.formState;
|
|
||||||
|
|
||||||
const onDisableTwoFactorAuthenticationFormSubmit = async ({
|
const onDisable2FAFormSubmit = async ({ token }: TDisable2FAForm) => {
|
||||||
password,
|
|
||||||
backupCode,
|
|
||||||
}: TDisableTwoFactorAuthenticationForm) => {
|
|
||||||
try {
|
try {
|
||||||
await disableTwoFactorAuthentication({ password, backupCode });
|
await disable2FA({ token });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Two-factor authentication disabled',
|
title: 'Two-factor authentication disabled',
|
||||||
@ -76,7 +66,7 @@ export const DisableAuthenticatorAppDialog = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
onOpenChange(false);
|
setIsOpen(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@ -91,74 +81,51 @@ export const DisableAuthenticatorAppDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
<DialogTrigger asChild={true}>
|
||||||
|
<Button className="flex-shrink-0" variant="destructive">
|
||||||
|
Disable 2FA
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Disable Authenticator App</DialogTitle>
|
<DialogTitle>Disable 2FA</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
To disable the Authenticator App for your account, please enter your password and a
|
Please provide a token from the authenticator, or a backup code. If you do not have a
|
||||||
backup code. If you do not have a backup code available, please contact support.
|
backup code available, please contact support.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...disableTwoFactorAuthenticationForm}>
|
<Form {...disable2FAForm}>
|
||||||
<form
|
<form onSubmit={disable2FAForm.handleSubmit(onDisable2FAFormSubmit)}>
|
||||||
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit(
|
<fieldset className="flex flex-col gap-y-4" disabled={isDisable2FASubmitting}>
|
||||||
onDisableTwoFactorAuthenticationFormSubmit,
|
|
||||||
)}
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
>
|
|
||||||
<fieldset
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
disabled={isDisableTwoFactorAuthenticationSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
<FormField
|
||||||
name="password"
|
name="token"
|
||||||
control={disableTwoFactorAuthenticationForm.control}
|
control={disable2FAForm.control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput
|
<Input {...field} placeholder="Token" />
|
||||||
{...field}
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={field.value ?? ''}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<DialogFooter>
|
||||||
name="backupCode"
|
<DialogClose asChild>
|
||||||
control={disableTwoFactorAuthenticationForm.control}
|
<Button type="button" variant="secondary">
|
||||||
render={({ field }) => (
|
Cancel
|
||||||
<FormItem>
|
</Button>
|
||||||
<FormLabel className="text-muted-foreground">Backup Code</FormLabel>
|
</DialogClose>
|
||||||
<FormControl>
|
|
||||||
<Input {...field} type="text" value={field.value ?? ''} />
|
<Button type="submit" variant="destructive" loading={isDisable2FASubmitting}>
|
||||||
</FormControl>
|
Disable 2FA
|
||||||
<FormMessage />
|
</Button>
|
||||||
</FormItem>
|
</DialogFooter>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isDisableTwoFactorAuthenticationSubmitting}
|
|
||||||
>
|
|
||||||
Disable 2FA
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import { renderSVG } from 'uqr';
|
import { renderSVG } from 'uqr';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@ -11,11 +14,13 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@ -26,98 +31,79 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { RecoveryCodeList } from './recovery-code-list';
|
import { RecoveryCodeList } from './recovery-code-list';
|
||||||
|
|
||||||
export const ZSetupTwoFactorAuthenticationForm = z.object({
|
export const ZEnable2FAForm = z.object({
|
||||||
password: z.string().min(6).max(72),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TSetupTwoFactorAuthenticationForm = z.infer<typeof ZSetupTwoFactorAuthenticationForm>;
|
|
||||||
|
|
||||||
export const ZEnableTwoFactorAuthenticationForm = z.object({
|
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>;
|
export type TEnable2FAForm = z.infer<typeof ZEnable2FAForm>;
|
||||||
|
|
||||||
export type EnableAuthenticatorAppDialogProps = {
|
export type EnableAuthenticatorAppDialogProps = {
|
||||||
open: boolean;
|
onSuccess?: () => void;
|
||||||
onOpenChange: (_open: boolean) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EnableAuthenticatorAppDialog = ({
|
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: EnableAuthenticatorAppDialogProps) => {
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
const router = useRouter();
|
||||||
trpc.twoFactorAuthentication.setup.useMutation();
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||||
|
|
||||||
|
const { mutateAsync: enable2FA } = trpc.twoFactorAuthentication.enable.useMutation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: enableTwoFactorAuthentication,
|
mutateAsync: setup2FA,
|
||||||
data: enableTwoFactorAuthenticationData,
|
data: setup2FAData,
|
||||||
isLoading: isEnableTwoFactorAuthenticationDataLoading,
|
isLoading: isSettingUp2FA,
|
||||||
} = trpc.twoFactorAuthentication.enable.useMutation();
|
} = trpc.twoFactorAuthentication.setup.useMutation({
|
||||||
|
onError: () => {
|
||||||
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
toast({
|
||||||
defaultValues: {
|
title: 'Unable to setup two-factor authentication',
|
||||||
password: '',
|
description:
|
||||||
|
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } =
|
const enable2FAForm = useForm<TEnable2FAForm>({
|
||||||
setupTwoFactorAuthenticationForm.formState;
|
|
||||||
|
|
||||||
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
token: '',
|
token: '',
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZEnableTwoFactorAuthenticationForm),
|
resolver: zodResolver(ZEnable2FAForm),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } =
|
const { isSubmitting: isEnabling2FA } = enable2FAForm.formState;
|
||||||
enableTwoFactorAuthenticationForm.formState;
|
|
||||||
|
|
||||||
const step = useMemo(() => {
|
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
|
||||||
if (!setupTwoFactorAuthenticationData || isSetupTwoFactorAuthenticationSubmitting) {
|
|
||||||
return 'setup';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!enableTwoFactorAuthenticationData || isEnableTwoFactorAuthenticationSubmitting) {
|
|
||||||
return 'enable';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'view';
|
|
||||||
}, [
|
|
||||||
setupTwoFactorAuthenticationData,
|
|
||||||
isSetupTwoFactorAuthenticationSubmitting,
|
|
||||||
enableTwoFactorAuthenticationData,
|
|
||||||
isEnableTwoFactorAuthenticationSubmitting,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const onSetupTwoFactorAuthenticationFormSubmit = async ({
|
|
||||||
password,
|
|
||||||
}: TSetupTwoFactorAuthenticationForm) => {
|
|
||||||
try {
|
try {
|
||||||
await setupTwoFactorAuthentication({ password });
|
const data = await enable2FA({ code: token });
|
||||||
|
|
||||||
|
setRecoveryCodes(data.recoveryCodes);
|
||||||
|
onSuccess?.();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Two-factor authentication enabled',
|
||||||
|
description:
|
||||||
|
'You will now be required to enter a code from your authenticator app when signing in.',
|
||||||
|
});
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Unable to setup two-factor authentication',
|
title: 'Unable to setup two-factor authentication',
|
||||||
description:
|
description:
|
||||||
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
|
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadRecoveryCodes = () => {
|
const downloadRecoveryCodes = () => {
|
||||||
if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) {
|
if (recoveryCodes) {
|
||||||
const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], {
|
const blob = new Blob([recoveryCodes.join('\n')], {
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -128,175 +114,126 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEnableTwoFactorAuthenticationFormSubmit = async ({
|
const handleEnable2FA = async () => {
|
||||||
token,
|
if (!setup2FAData) {
|
||||||
}: TEnableTwoFactorAuthenticationForm) => {
|
await setup2FA();
|
||||||
try {
|
|
||||||
await enableTwoFactorAuthentication({ code: token });
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Two-factor authentication enabled',
|
|
||||||
description:
|
|
||||||
'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.',
|
|
||||||
});
|
|
||||||
} catch (_err) {
|
|
||||||
toast({
|
|
||||||
title: 'Unable to setup two-factor authentication',
|
|
||||||
description:
|
|
||||||
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset the form when the Dialog closes
|
enable2FAForm.reset();
|
||||||
if (!open) {
|
|
||||||
setupTwoFactorAuthenticationForm.reset();
|
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
|
||||||
|
setRecoveryCodes(null);
|
||||||
|
router.refresh();
|
||||||
}
|
}
|
||||||
}, [open, setupTwoFactorAuthenticationForm]);
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
<DialogTrigger asChild={true}>
|
||||||
<DialogHeader>
|
<Button
|
||||||
<DialogTitle>Enable Authenticator App</DialogTitle>
|
className="flex-shrink-0"
|
||||||
|
loading={isSettingUp2FA}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleEnable2FA();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Enable 2FA
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
{step === 'setup' && (
|
<DialogContent position="center">
|
||||||
<DialogDescription>
|
{setup2FAData && (
|
||||||
To enable two-factor authentication, please enter your password below.
|
<>
|
||||||
</DialogDescription>
|
{recoveryCodes ? (
|
||||||
)}
|
<div>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Backup codes</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Your recovery codes are listed below. Please store them in a safe place.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
{step === 'view' && (
|
<div className="mt-4">
|
||||||
<DialogDescription>
|
<RecoveryCodeList recoveryCodes={recoveryCodes} />
|
||||||
Your recovery codes are listed below. Please store them in a safe place.
|
</div>
|
||||||
</DialogDescription>
|
|
||||||
)}
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{match(step)
|
<DialogFooter className="mt-4">
|
||||||
.with('setup', () => {
|
<DialogClose asChild>
|
||||||
return (
|
<Button variant="secondary">Close</Button>
|
||||||
<Form {...setupTwoFactorAuthenticationForm}>
|
</DialogClose>
|
||||||
<form
|
|
||||||
onSubmit={setupTwoFactorAuthenticationForm.handleSubmit(
|
|
||||||
onSetupTwoFactorAuthenticationFormSubmit,
|
|
||||||
)}
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
name="password"
|
|
||||||
control={setupTwoFactorAuthenticationForm.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput
|
|
||||||
{...field}
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={field.value ?? ''}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<Button onClick={downloadRecoveryCodes}>Download</Button>
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
</DialogFooter>
|
||||||
Cancel
|
</div>
|
||||||
</Button>
|
) : (
|
||||||
|
<Form {...enable2FAForm}>
|
||||||
|
<form onSubmit={enable2FAForm.handleSubmit(onEnable2FAFormSubmit)}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Enable Authenticator App</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
To enable two-factor authentication, scan the following QR code using your
|
||||||
|
authenticator app.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
<fieldset disabled={isEnabling2FA} className="mt-4 flex flex-col gap-y-4">
|
||||||
Continue
|
<div
|
||||||
</Button>
|
className="flex h-36 justify-center"
|
||||||
</DialogFooter>
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: renderSVG(setup2FAData?.uri ?? ''),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
If your authenticator app does not support QR codes, you can use the following
|
||||||
|
code instead:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
|
||||||
|
{setup2FAData?.secret}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Once you have scanned the QR code or entered the code manually, enter the code
|
||||||
|
provided by your authenticator app below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="token"
|
||||||
|
control={enable2FAForm.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="text" value={field.value ?? ''} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="secondary">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isEnabling2FA}>
|
||||||
|
Enable 2FA
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
)}
|
||||||
})
|
</>
|
||||||
.with('enable', () => (
|
)}
|
||||||
<Form {...enableTwoFactorAuthenticationForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={enableTwoFactorAuthenticationForm.handleSubmit(
|
|
||||||
onEnableTwoFactorAuthenticationFormSubmit,
|
|
||||||
)}
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
To enable two-factor authentication, scan the following QR code using your
|
|
||||||
authenticator app.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex h-36 justify-center"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
If your authenticator app does not support QR codes, you can use the following
|
|
||||||
code instead:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
|
|
||||||
{setupTwoFactorAuthenticationData?.secret}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Once you have scanned the QR code or entered the code manually, enter the code
|
|
||||||
provided by your authenticator app below.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
name="token"
|
|
||||||
control={enableTwoFactorAuthenticationForm.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} type="text" value={field.value ?? ''} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
|
||||||
Enable 2FA
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
))
|
|
||||||
.with('view', () => (
|
|
||||||
<div>
|
|
||||||
{enableTwoFactorAuthenticationData?.recoveryCodes && (
|
|
||||||
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-row-reverse items-center gap-2">
|
|
||||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={downloadRecoveryCodes}
|
|
||||||
disabled={!enableTwoFactorAuthenticationData?.recoveryCodes}
|
|
||||||
loading={isEnableTwoFactorAuthenticationDataLoading}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
.exhaustive()}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
|
|
||||||
|
|
||||||
type RecoveryCodesProps = {
|
|
||||||
isTwoFactorEnabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
className="flex-shrink-0"
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
disabled={!isTwoFactorEnabled}
|
|
||||||
>
|
|
||||||
View Codes
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<ViewRecoveryCodesDialog
|
|
||||||
key={isOpen ? 'open' : 'closed'}
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={setIsOpen}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@ -6,69 +8,58 @@ import { match } from 'ts-pattern';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { RecoveryCodeList } from './recovery-code-list';
|
import { RecoveryCodeList } from './recovery-code-list';
|
||||||
|
|
||||||
export const ZViewRecoveryCodesForm = z.object({
|
export const ZViewRecoveryCodesForm = z.object({
|
||||||
password: z.string().min(6).max(72),
|
token: z.string().min(1, { message: 'Token is required' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
|
export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
|
||||||
|
|
||||||
export type ViewRecoveryCodesDialogProps = {
|
export const ViewRecoveryCodesDialog = () => {
|
||||||
open: boolean;
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
onOpenChange: (_open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: viewRecoveryCodes,
|
data: recoveryCodes,
|
||||||
data: viewRecoveryCodesData,
|
mutate,
|
||||||
isLoading: isViewRecoveryCodesDataLoading,
|
isLoading,
|
||||||
|
error,
|
||||||
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||||
|
|
||||||
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
password: '',
|
token: '',
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZViewRecoveryCodesForm),
|
resolver: zodResolver(ZViewRecoveryCodesForm),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState;
|
|
||||||
|
|
||||||
const step = useMemo(() => {
|
|
||||||
if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) {
|
|
||||||
return 'authenticate';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'view';
|
|
||||||
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
|
|
||||||
|
|
||||||
const downloadRecoveryCodes = () => {
|
const downloadRecoveryCodes = () => {
|
||||||
if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) {
|
if (recoveryCodes) {
|
||||||
const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], {
|
const blob = new Blob([recoveryCodes.join('\n')], {
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -79,105 +70,88 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
|
|
||||||
try {
|
|
||||||
await viewRecoveryCodes({ password });
|
|
||||||
} catch (_err) {
|
|
||||||
toast({
|
|
||||||
title: 'Unable to view recovery codes',
|
|
||||||
description:
|
|
||||||
'We were unable to view your recovery codes. Please ensure that you have entered your password correctly and try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Reset the form when the Dialog closes
|
|
||||||
if (!open) {
|
|
||||||
viewRecoveryCodesForm.reset();
|
|
||||||
}
|
|
||||||
}, [open, viewRecoveryCodesForm]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="flex-shrink-0">View Codes</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||||
<DialogHeader>
|
{recoveryCodes ? (
|
||||||
<DialogTitle>View Recovery Codes</DialogTitle>
|
<div>
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<DialogTitle>View Recovery Codes</DialogTitle>
|
||||||
|
|
||||||
{step === 'authenticate' && (
|
<DialogDescription>
|
||||||
<DialogDescription>
|
Your recovery codes are listed below. Please store them in a safe place.
|
||||||
To view your recovery codes, please enter your password below.
|
</DialogDescription>
|
||||||
</DialogDescription>
|
</DialogHeader>
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'view' && (
|
<RecoveryCodeList recoveryCodes={recoveryCodes} />
|
||||||
<DialogDescription>
|
|
||||||
Your recovery codes are listed below. Please store them in a safe place.
|
|
||||||
</DialogDescription>
|
|
||||||
)}
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{match(step)
|
<DialogFooter className="mt-4">
|
||||||
.with('authenticate', () => {
|
<DialogClose asChild>
|
||||||
return (
|
<Button variant="secondary">Close</Button>
|
||||||
<Form {...viewRecoveryCodesForm}>
|
</DialogClose>
|
||||||
<form
|
|
||||||
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)}
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
name="password"
|
|
||||||
control={viewRecoveryCodesForm.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput
|
|
||||||
{...field}
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={field.value ?? ''}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<Button onClick={downloadRecoveryCodes}>Download</Button>
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Form {...viewRecoveryCodesForm}>
|
||||||
|
<form onSubmit={viewRecoveryCodesForm.handleSubmit((value) => mutate(value))}>
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<DialogTitle>View Recovery Codes</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
Please provide a token from your authenticator, or a backup code.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col space-y-4" disabled={isLoading}>
|
||||||
|
<FormField
|
||||||
|
name="token"
|
||||||
|
control={viewRecoveryCodesForm.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Token" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
{match(AppError.parseError(error).message)
|
||||||
|
.with(
|
||||||
|
ErrorCode.INCORRECT_TWO_FACTOR_CODE,
|
||||||
|
() => 'Invalid code. Please try again.',
|
||||||
|
)
|
||||||
|
.otherwise(
|
||||||
|
() => 'Something went wrong. Please try again or contact support.',
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
<Button type="submit" loading={isLoading}>
|
||||||
Continue
|
View
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</fieldset>
|
||||||
</Form>
|
</form>
|
||||||
);
|
</Form>
|
||||||
})
|
)}
|
||||||
.with('view', () => (
|
|
||||||
<div>
|
|
||||||
{viewRecoveryCodesData?.recoveryCodes && (
|
|
||||||
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-row-reverse items-center gap-2">
|
|
||||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
disabled={!viewRecoveryCodesData?.recoveryCodes}
|
|
||||||
loading={isViewRecoveryCodesDataLoading}
|
|
||||||
onClick={downloadRecoveryCodes}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
.exhaustive()}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -55,11 +55,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isSubmitting = form.formState.isSubmitting;
|
const isSubmitting = form.formState.isSubmitting;
|
||||||
const hasTwoFactorAuthentication = user.twoFactorEnabled;
|
|
||||||
|
|
||||||
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
|
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
|
||||||
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
|
|
||||||
trpc.profile.deleteAccount.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
|
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -154,7 +154,7 @@ export const ClaimPublicProfileDialogForm = ({
|
|||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|
||||||
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block truncate rounded-md px-2 py-1 text-sm">
|
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block max-w-[29rem] truncate rounded-md px-2 py-1 text-sm lowercase">
|
||||||
{baseUrl.host}/u/{field.value || '<username>'}
|
{baseUrl.host}/u/{field.value || '<username>'}
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@ -6,12 +6,18 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||||
|
import { KeyRoundIcon } from 'lucide-react';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { FcGoogle } from 'react-icons/fc';
|
import { FcGoogle } from 'react-icons/fc';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -66,14 +72,24 @@ export type SignInFormProps = {
|
|||||||
|
|
||||||
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
|
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||||
'totp' | 'backup'
|
'totp' | 'backup'
|
||||||
>('totp');
|
>('totp');
|
||||||
|
|
||||||
|
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||||
|
|
||||||
|
const isPasskeyEnabled = getFlag('app_passkey');
|
||||||
|
|
||||||
|
const { mutateAsync: createPasskeySigninOptions } =
|
||||||
|
trpc.auth.createPasskeySigninOptions.useMutation();
|
||||||
|
|
||||||
const form = useForm<TSignInFormSchema>({
|
const form = useForm<TSignInFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
email: initialEmail ?? '',
|
email: initialEmail ?? '',
|
||||||
@ -107,6 +123,63 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
setTwoFactorAuthenticationMethod(method);
|
setTwoFactorAuthenticationMethod(method);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSignInWithPasskey = async () => {
|
||||||
|
if (!browserSupportsWebAuthn()) {
|
||||||
|
toast({
|
||||||
|
title: 'Not supported',
|
||||||
|
description: 'Passkeys are not supported on this browser',
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsPasskeyLoading(true);
|
||||||
|
|
||||||
|
const options = await createPasskeySigninOptions();
|
||||||
|
|
||||||
|
const credential = await startAuthentication(options);
|
||||||
|
|
||||||
|
const result = await signIn('webauthn', {
|
||||||
|
credential: JSON.stringify(credential),
|
||||||
|
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.url || result.error) {
|
||||||
|
throw new AppError(result?.error ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = result.url;
|
||||||
|
} catch (err) {
|
||||||
|
setIsPasskeyLoading(false);
|
||||||
|
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
const errorMessage = match(error.code)
|
||||||
|
.with(
|
||||||
|
AppErrorCode.NOT_SETUP,
|
||||||
|
() =>
|
||||||
|
'This passkey is not configured for this application. Please login and add one in the user settings.',
|
||||||
|
)
|
||||||
|
.with(AppErrorCode.EXPIRED_CODE, () => 'This session has expired. Please try again.')
|
||||||
|
.otherwise(() => 'Please try again later or login using your normal details');
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: errorMessage,
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const credentials: Record<string, string> = {
|
const credentials: Record<string, string> = {
|
||||||
@ -189,7 +262,10 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
<fieldset
|
||||||
|
className="flex w-full flex-col gap-y-4"
|
||||||
|
disabled={isSubmitting || isPasskeyLoading}
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
@ -217,6 +293,8 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
<PasswordInput {...field} />
|
<PasswordInput {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
|
||||||
<p className="mt-2 text-right">
|
<p className="mt-2 text-right">
|
||||||
<Link
|
<Link
|
||||||
href="/forgot-password"
|
href="/forgot-password"
|
||||||
@ -225,29 +303,28 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
Forgot your password?
|
Forgot your password?
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
size="lg"
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
className="dark:bg-documenso dark:hover:opacity-90"
|
className="dark:bg-documenso dark:hover:opacity-90"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isGoogleSSOEnabled && (
|
{(isGoogleSSOEnabled || isPasskeyEnabled) && (
|
||||||
<>
|
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isGoogleSSOEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
@ -259,8 +336,23 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
Google
|
Google
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
|
{isPasskeyEnabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isPasskeyLoading}
|
||||||
|
className="bg-background text-muted-foreground border"
|
||||||
|
onClick={onSignInWithPasskey}
|
||||||
|
>
|
||||||
|
{!isPasskeyLoading && <KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />}
|
||||||
|
Passkey
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|||||||
@ -108,17 +108,6 @@ export const SignUpFormV2 = ({
|
|||||||
const name = form.watch('name');
|
const name = form.watch('name');
|
||||||
const url = form.watch('url');
|
const url = form.watch('url');
|
||||||
|
|
||||||
// To continue we need to make sure name, email, password and signature are valid
|
|
||||||
const canContinue =
|
|
||||||
form.formState.dirtyFields.name &&
|
|
||||||
form.formState.errors.name === undefined &&
|
|
||||||
form.formState.dirtyFields.email &&
|
|
||||||
form.formState.errors.email === undefined &&
|
|
||||||
form.formState.dirtyFields.password &&
|
|
||||||
form.formState.errors.password === undefined &&
|
|
||||||
form.formState.dirtyFields.signature &&
|
|
||||||
form.formState.errors.signature === undefined;
|
|
||||||
|
|
||||||
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormV2Schema) => {
|
const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormV2Schema) => {
|
||||||
@ -169,6 +158,14 @@ export const SignUpFormV2 = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onNextClick = async () => {
|
||||||
|
const valid = await form.trigger(['name', 'email', 'password', 'signature']);
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
setStep('CLAIM_USERNAME');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSignUpWithGoogleClick = async () => {
|
const onSignUpWithGoogleClick = async () => {
|
||||||
try {
|
try {
|
||||||
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
|
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
|
||||||
@ -224,7 +221,7 @@ export const SignUpFormV2 = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(800px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
|
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
|
||||||
{step === 'BASIC_DETAILS' && (
|
{step === 'BASIC_DETAILS' && (
|
||||||
<div className="h-20">
|
<div className="h-20">
|
||||||
<h1 className="text-xl font-semibold md:text-2xl">Create a new account</h1>
|
<h1 className="text-xl font-semibold md:text-2xl">Create a new account</h1>
|
||||||
@ -257,8 +254,8 @@ export const SignUpFormV2 = ({
|
|||||||
{step === 'BASIC_DETAILS' && (
|
{step === 'BASIC_DETAILS' && (
|
||||||
<fieldset
|
<fieldset
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-[500px] w-full flex-col gap-y-4',
|
'flex h-[550px] w-full flex-col gap-y-4',
|
||||||
isGoogleSSOEnabled && 'h-[600px]',
|
isGoogleSSOEnabled && 'h-[650px]',
|
||||||
)}
|
)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
@ -360,8 +357,8 @@ export const SignUpFormV2 = ({
|
|||||||
{step === 'CLAIM_USERNAME' && (
|
{step === 'CLAIM_USERNAME' && (
|
||||||
<fieldset
|
<fieldset
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-[500px] w-full flex-col gap-y-4',
|
'flex h-[550px] w-full flex-col gap-y-4',
|
||||||
isGoogleSSOEnabled && 'h-[600px]',
|
isGoogleSSOEnabled && 'h-[650px]',
|
||||||
)}
|
)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
@ -431,9 +428,8 @@ export const SignUpFormV2 = ({
|
|||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="flex-1 disabled:cursor-not-allowed"
|
className="flex-1 disabled:cursor-not-allowed"
|
||||||
disabled={!canContinue}
|
|
||||||
loading={form.formState.isSubmitting}
|
loading={form.formState.isSubmitting}
|
||||||
onClick={() => setStep('CLAIM_USERNAME')}
|
onClick={onNextClick}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
29
apps/web/src/components/general/signing-disclosure.tsx
Normal file
29
apps/web/src/components/general/signing-disclosure.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
export type SigningDisclosureProps = HTMLAttributes<HTMLParagraphElement>;
|
||||||
|
|
||||||
|
export const SigningDisclosure = ({ className, ...props }: SigningDisclosureProps) => {
|
||||||
|
return (
|
||||||
|
<p className={cn('text-muted-foreground text-xs', className)} {...props}>
|
||||||
|
By proceeding with your electronic signature, you acknowledge and consent that it will be used
|
||||||
|
to sign the given document and holds the same legal validity as a handwritten signature. By
|
||||||
|
completing the electronic signing process, you affirm your understanding and acceptance of
|
||||||
|
these conditions.
|
||||||
|
<span className="mt-2 block">
|
||||||
|
Read the full{' '}
|
||||||
|
<Link
|
||||||
|
className="text-documenso-700 underline"
|
||||||
|
href="/articles/signature-disclosure"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
signature disclosure
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { SVGAttributes } from 'react';
|
import type { SVGAttributes } from 'react';
|
||||||
|
|
||||||
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;
|
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm">
|
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm lowercase">
|
||||||
{baseUrl.host}/u/{user.url}
|
{baseUrl.host}/u/{user.url}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
import { ThemeProviderProps } from 'next-themes/dist/types';
|
import type { ThemeProviderProps } from 'next-themes/dist/types';
|
||||||
|
|
||||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
|||||||
@ -115,6 +115,7 @@ Here's a markdown table documenting all the provided environment variables:
|
|||||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
|
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
|
||||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). |
|
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). |
|
||||||
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
|
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
|
||||||
|
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
|
||||||
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
|
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
|
||||||
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
|
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
|
||||||
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
|
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user