diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..60b385403 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Documenso", + "image": "mcr.microsoft.com/devcontainers/base:bullseye", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "version": "latest", + "enableNonRootDocker": "true", + "moby": "true" + }, + "ghcr.io/devcontainers/features/node:1": {} + }, + "onCreateCommand": "./.devcontainer/on-create.sh", + "forwardPorts": [3000, 54320, 9000, 2500, 1100], + "customizations": { + "vscode": { + "extensions": [ + "aaron-bond.better-comments", + "bradlc.vscode-tailwindcss", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "mikestead.dotenv", + "unifiedjs.vscode-mdx", + "GitHub.copilot-chat", + "GitHub.copilot-labs", + "GitHub.copilot", + "GitHub.vscode-pull-request-github", + "Prisma.prisma", + "VisualStudioExptTeam.vscodeintellicode", + ] + } + } +} diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh new file mode 100755 index 000000000..a66491ef7 --- /dev/null +++ b/.devcontainer/on-create.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +# Start the database and mailserver +docker compose -f ./docker/compose-without-app.yml up -d + +# Install dependencies +npm install + +# Copy the env file +cp .env.example .env + +# Source the env file, export the variables +set -a +source .env +set +a + +# Run the migrations +npm run -w @documenso/prisma prisma:migrate-dev diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh new file mode 100755 index 000000000..80d19dc7c --- /dev/null +++ b/.devcontainer/post-start.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +npm run dev diff --git a/.env.example b/.env.example index cfa96f59b..fb22bbedf 100644 --- a/.env.example +++ b/.env.example @@ -7,14 +7,28 @@ NEXT_PRIVATE_GOOGLE_CLIENT_ID="" NEXT_PRIVATE_GOOGLE_CLIENT_SECRET="" # [[APP]] -NEXT_PUBLIC_SITE_URL="http://localhost:3000" -NEXT_PUBLIC_APP_URL="http://localhost:3000" +NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" +NEXT_PUBLIC_MARKETING_URL="http://localhost:3001" # [[DATABASE]] NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" # 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" +# [[STORAGE]] +# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 +NEXT_PUBLIC_UPLOAD_TRANSPORT="database" +# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers. +NEXT_PRIVATE_UPLOAD_ENDPOINT= +# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1. +NEXT_PRIVATE_UPLOAD_REGION= +# REQUIRED: Defines the bucket to use for the S3 storage transport. +NEXT_PRIVATE_UPLOAD_BUCKET= +# OPTIONAL: Defines the access key ID to use for the S3 storage transport. +NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID= +# OPTIONAL: Defines the secret access key to use for the S3 storage transport. +NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY= + # [[SMTP]] # OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" diff --git a/.eslintignore b/.eslintignore index f80dc7f80..b7f7e638f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,3 +5,4 @@ # Statically hosted javascript files apps/*/public/*.js apps/*/public/*.cjs +scripts/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c15689123..1a5d4bbcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,12 +22,18 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 2 + - name: Install Node.js uses: actions/setup-node@v3 with: node-version: 18 cache: npm + - name: Install dependencies run: npm ci + + - name: Copy env + run: cp .env.example .env + - name: Build run: npm run build diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 92a0936a5..c934272a4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,7 +32,10 @@ jobs: - name: Install Dependencies run: npm ci - + + - name: Copy env + run: cp .env.example .env + - name: Build Documenso run: npm run build @@ -42,4 +45,4 @@ jobs: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 \ No newline at end of file + uses: github/codeql-action/analyze@v2 diff --git a/README.md b/README.md index ebab1c3f5..29ffb0d65 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,11 @@

- + Documenso Logo -

Open Source Signing Infrastructure

-

- The DocuSign Open Source Alternative. -
+ The Open Source DocuSign Alternative. +
Learn more »

@@ -22,12 +20,16 @@

- Join Documenso on Discord + Join Documenso on Discord Github Stars License Commits-per-month

+> **🚧 We're currently working on a large scale refactor which can be found on the [feat/refresh](https://github.com/documenso/documenso/tree/feat/refresh) branch.** +> +> **[Read more on why 👀](https://documenso.com/blog/why-were-doing-a-rewrite)** + # Documenso 0.9 - Developer Preview
@@ -63,18 +65,28 @@ Signing documents digitally is fast, easy and should be best practice for every ## Community and Next Steps 🎯 -The current project goal is to [release a production ready version](https://github.com/documenso/documenso/milestone/1) for self-hosting as soon as possible. If you want to help making that happen you can: +We're currently working on a redesign of the application including a revamp of the codebase so Documenso can be more intuitive to use and robust to develop upon. - Check out the first source code release in this repository and test it - Tell us what you think in the current [Discussions](https://github.com/documenso/documenso/discussions) -- Join the [Slack Channel](https://documen.so/slack) for any questions and getting to know to other community members +- Join the [Discord server](https://documen.so/discord) for any questions and getting to know to other community members - ⭐ the repository to help us raise awareness - Spread the word on Twitter, that Documenso is working towards a more open signing tool - Fix or create [issues](https://github.com/documenso/documenso/issues), that are needed for the first production release ## Contributing -- To contribute please see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md). +- To contribute, please see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md). + +## Contact us + +Contact us if you are interested in our Enterprise plan for large organizations that need extra flexibility and control. + +Book us with Cal.com + +## Activity + +![Repository Activity](https://repobeats.axiom.co/api/embed/622a2e9aa709696f7226304b5b7178a5741b3868.svg) # Tech @@ -89,10 +101,6 @@ Documenso is built using awesome open source tech including: - [Node SignPDF (Digital Signature)](https://github.com/vbuch/node-signpdf) - [React-PDF for viewing PDFs](https://github.com/wojtekmaj/react-pdf) - [PDF-Lib for PDF manipulation](https://github.com/Hopding/pdf-lib) -- [Zod for schema declaration and validation](https://zod.dev/) -- [Lucide React for icons in React app](https://lucide.dev/) -- [Framer Motion for motion library](https://www.framer.com/motion/) -- [Radix UI for component library](https://www.radix-ui.com/) - Check out `/package.json` and `/apps/web/package.json` for more - Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned. @@ -135,37 +143,47 @@ Your database will also be available on port `54320`. You can connect to it usin ## Developer Setup +### Manual Setup + Follow these steps to setup documenso on you local machine: - [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device. ```sh git clone https://github.com/documenso/documenso ``` -- Run npm i in root directory -- Rename .env.example to .env +- Run `npm i` in root directory +- Rename `.env.example` to `.env` - Set DATABASE_URL value in .env file - You can use the provided test database url (may be wiped at any point) - Or setup a local postgres sql instance (recommended) -- Create the database scheme by running db-migrate:dev +- Create the database scheme by running `db-migrate:dev` - Setup your mail provider - - Set SENDGRID_API_KEY value in .env file + - Set `SENDGRID_API_KEY` value in .env file - You need a SendGrid account, which you can create [here](https://signup.sendgrid.com/). - - Documenso uses [Nodemailer](https://nodemailer.com/about/) so you can easily use your own SMTP server by setting the SMTP\_\* variables in your .env -- Run npm run dev root directory to start + - Documenso uses [Nodemailer](https://nodemailer.com/about/) so you can easily use your own SMTP server by setting the `SMTP + \_ + * variables` in your .env +- Run `npm run dev` root directory to start - Register a new user at http://localhost:3000/signup --- -- Optional: Seed the database using npm run db-seed to create a test user and document -- Optional: Upload and sign apps/web/resources/example.pdf manually to test your setup +- Optional: Seed the database using `npm run db-seed` to create a test user and document +- Optional: Upload and sign `apps/web/resources/example.pdf` manually to test your setup - Optional: Create your own signing certificate - A demo certificate is provided in `/app/web/resources/certificate.p12` - To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL) see **[Create your own signing certificate](#creating-your-own-signing-certificate)**. +### Run in Gitpod + +- Click below to launch a ready-to-use Gitpod workspace in your browser. + +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/documenso/documenso) + ## Updating -- If you pull the newest version from main, using git pull, it may be necessary to regenerate your database client +- If you pull the newest version from main, using `git pull`, it may be necessary to regenerate your database client - You can do this by running the generate command in `/packages/prisma`: ```sh npx prisma generate @@ -176,16 +194,22 @@ Follow these steps to setup documenso on you local machine: For the digital signature of your documents you need a signing certificate in .p12 format (public and private key). You can buy one (not recommended for dev) or use the steps to create a self-signed one: -1. Generate a private key using the OpenSSL command. You can run the following command to generate a 2048-bit RSA key:\ - openssl genrsa -out private.key 2048 +1. Generate a private key using the OpenSSL command. You can run the following command to generate a 2048-bit RSA key: + + `openssl genrsa -out private.key 2048` + +2. Generate a self-signed certificate using the private key. You can run the following command to generate a self-signed certificate: + + `openssl req -new -x509 -key private.key -out certificate.crt -days 365` -2. Generate a self-signed certificate using the private key. You can run the following command to generate a self-signed certificate:\ - openssl req -new -x509 -key private.key -out certificate.crt -days 365 \ This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The -days parameter sets the number of days for which the certificate is valid. -3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following command to do this: \ - openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt + +3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following command to do this: + + `openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt` + 4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**) -5. Place the certificate /apps/web/resources/certificate.p12 +5. Place the certificate `/apps/web/resources/certificate.p12` # Docker @@ -193,16 +217,42 @@ For the digital signature of your documents you need a signing certificate in .p Want to create a production ready docker image? Follow these steps: -- Run `./docker/build.sh` in the root directory. -- Publish the image to your docker registry of choice. +- cd into `docker` directory +- Make `build.sh` executable by running `chmod +x build.sh` +- Run `./build.sh` to start building the docker image. +- Publish the image to your docker registry of choice (or) If you prefer running the image from local, run the below command -# Deploying - Coming Soon™ +``` +docker run -d --restart=unless-stopped -p 3000:3000 -v documenso:/app/data --name documenso documenso:latest +``` -- Docker support -- One-Click-Deploy on Render.com Deploy +Command Breakdown: +- `-d` - Let's you run the container in background +- `-p` - Passes down which ports to use. First half is the host port, Second half is the app port. You can change the first half anything you want and reverse proxy to that port. +- `-v` - Volume let's you persist the data +- `--name` - Name of the container +- `documenso:latest` - Image you have built + +# Deployment + +We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates! + +## Railway + +[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/DjrRRX) + +## Render + +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso) # Troubleshooting +## I'm not receiving any emails when using the developer quickstart + +When using the developer quickstart an [Inbucket](https://inbucket.org/) server will be spun up in a docker container that will store all outgoing email locally for you to view. + +The Web UI can be found at http://localhost:9000 while the SMTP port will be on localhost:2500. + ## Support IPv6 In case you are deploying to a cluster that uses only IPv6. You can use a custom command to pass a parameter to the NextJS start command diff --git a/apps/marketing/content/blog/building-documenso-pt1.mdx b/apps/marketing/content/blog/building-documenso-pt1.mdx index 92c6f61ed..b8507be03 100644 --- a/apps/marketing/content/blog/building-documenso-pt1.mdx +++ b/apps/marketing/content/blog/building-documenso-pt1.mdx @@ -1,98 +1,98 @@ ---- -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. -authorName: 'Timur Ercan' -authorImage: '/blog/blog-author-timur.jpeg' -authorRole: 'Co-Founder' -date: 2023-06-23 -tags: - - Open Source - - Document Signature - - Certificates - - Signing ---- - -
- - -
- What actually is a signature? -
-
- -> Disclaimer: I’m not a lawyer and this isn’t legal advice. We plan to publish a much more specific framework on the topic of signature validity. - -This is the first installment of the new Building Documenso series, where I describe the challenges and design choices that we make while building the world’s most open signing platform. - -As you may have heard, we launched the community-reviewed version 0.9 of Documenso on GitHub recently and it’s now available through the early adopter’s plan. One of the most fundamental choices we had to make on this first release, was the choice of certificate. While it’s interesting to know what we opted for, this shall also serve as a guide for everyone facing the same choice for self-hosting Documenso. - -> Question: Why do I need a document signing certificate to self-host? -> -> Short Answer: Inserting the images of a signature into the document is only part of the signing process. - -To have an actual digitally signed document you need a document signing certificate that is used to create the digital signature that is inserted into the document, alongside the visible one¹. - -When hosting a signature service yourself, as we do, there are four main choices for handling the certificate: Not using a certificate, creating your own, buying a trusted certificate, and becoming and trusted service provider to issue your own trusted certificate. - -## 1\. No Certificate - -A lot of signing services actually don’t employ actual digital signatures besides the inserted image. The only insert and image of the signatures into the document you sign. This can be done and is legally acceptable in many cases. This option isn’t directly supported by Documenso without changing the code. - -## 2\. Create your own - -Since the cryptography behind certificates is freely available as open source you could generate your own using OpenSSL for example. Since it’s hardly more work than option 1 (using Documenso at least), this would be my minimum effort recommendation. Having a self-created (“self-signed”) certificate doesn’t add much in terms of regulation but it guarantees the document’s integrity, meaning no changes have been made after signing². What this doesn’t give you, is the famous green checkmark in Adobe Acrobat. Why? Because you aren’t on the list of providers Adobe “trusts”.³ - -## 3\. Buy a “trusted” certificate. - -There are Certificate Authorities (CAs) that can sell you a certificate⁴. The service they provide is, that they validate your name (personal certificates) or your organization’s name (corporate certificate) before creating your certificate for you, just like you did in option 2. The difference is, that they are listed on the previously mentioned trust lists (e.g. Adobe’s) and thus the resulting signatures get a nice, green checkmark in Adobe Reader⁵ - -## 4\. Becoming a Trusted Certificate Authority (CA) yourself and create your own certificate - -This option is an incredibly complex endeavour, requiring a lot of effort and skill. It can be done, as there are multiple CAs around the world. Is it worth the effort? That depends a lot on what you’re trying to accomplish. - -
.  .  .
- -## What we did - -Having briefly introduced the options, here is what we did: Since we aim to raise the bar on digital signature proliferation and trust, we opted to buy an “Advanced Personal Certificates for Companies/Organisations” from WiseKey. Thus, documents signed with Documenso’s hosted version look like this: - -
- - -
The famous green checkmark: Signed by hosted Documenso
-
- -There weren’t any deeper reasons we choose WiseKey, other than they offered what we needed and there wasn’t any reason to look much further. While I didn’t map the entire certificate market offering (yet), I’m pretty sure something similar could be found elsewhere. While we opted for option 3, choosing option 2 might be perfectly reasonable considering your use case.⁶ - -> While this is our setup, for now, we have a bigger plan for this topic. While globally trusted SSL Certificates have been available for free, courtesy of Let’s Encrypt, for a while now, there is no such thing as document signing. And there should be. Not having free and trusted infrastructure for signing is blocking a completely new generation of signing products from being created. This is why we’ll start working on option 4 when the time is right. - -Do you have questions or thoughts about this? As always, let me know in the comments, on twitter.com/eltimuro -or directly: documen.so/timur - -Join the self-hoster community here: https://documenso.slack.com/ - -Best from Hamburg - -Timur - -\[1\] There are different approaches to signing a document. For the sake of simplicity, here we talk about a document with X inserted signature images, that is afterward signed once the by signing service, i.e. Documenso. If each visual signature should have its own digital one (e.g. QES — eIDAS Level 3), the case is a bit more complex. - -\[2\] Of course, the signing service provider technically can change and resign the document, especially in the case mentioned in \[1\]. This can be countered by requiring actual digital signatures from each signer, that are bound to their identity/ account. Creating a completely trustless system in the context however is extremely hard to do and not the most pressing business need for the industry at this point, in my opinion. Though, this would be nice. - -\[3\] Adobe, like the EU, has a list of organizations they trust. The Adobe green checkmark is powered by the Adobe trust list, if you want to be trusted by EU standards here: https://ec.europa.eu/digital-building-blocks/DSS/webapp-demo/validation, you need to be on the EU trust list. Getting on each list is possible, though the latter is much more work. - -\[4\] Technically, they sign your certificate creation request (created by you), containing your info with their certificate (which is trusted), making your certificate trusted. This way, everything you sign with your certificate is seen as trusted. They created their certificate just like you, the difference is they are on the lists, mentioned in \[3\] - -\[5\] Why does Adobe get to say, what is trusted? They simply happen to have the most used pdf viewer. And since everyone checks there, whom they consider trusted carries weight. If it should be like this, is a different matter. - -\[6\] Self-Signed signatures, even purely visual signatures, are fully legally binding. Why you use changes mainly your confidence in the signature and the burden of proof. Also, some industries require a certain level of signatures e.g. retail loans (QES/ eIDAS Level 3 in the EU). +--- +title: 'Building Documenso — Part 1: Certificates' +description: This is the first part of the new Building Documenso series, where I describe the challenges and design choices that we make while building the world’s most open signing platform. +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2023-06-23 +tags: + - Open Source + - Document Signature + - Certificates + - Signing +--- + +
+ + +
+ What actually is a signature? +
+
+ +> Disclaimer: I’m not a lawyer and this isn’t legal advice. We plan to publish a much more specific framework on the topic of signature validity. + +This is the first installment of the new Building Documenso series, where I describe the challenges and design choices that we make while building the world’s most open signing platform. + +As you may have heard, we launched the community-reviewed version 0.9 of Documenso on GitHub recently and it’s now available through the early adopter’s plan. One of the most fundamental choices we had to make on this first release, was the choice of certificate. While it’s interesting to know what we opted for, this shall also serve as a guide for everyone facing the same choice for self-hosting Documenso. + +> Question: Why do I need a document signing certificate to self-host? +> +> Short Answer: Inserting the images of a signature into the document is only part of the signing process. + +To have an actual digitally signed document you need a document signing certificate that is used to create the digital signature that is inserted into the document, alongside the visible one¹. + +When hosting a signature service yourself, as we do, there are four main choices for handling the certificate: Not using a certificate, creating your own, buying a trusted certificate, and becoming and trusted service provider to issue your own trusted certificate. + +## 1\. No Certificate + +A lot of signing services actually don’t employ actual digital signatures besides the inserted image. The only insert and image of the signatures into the document you sign. This can be done and is legally acceptable in many cases. This option isn’t directly supported by Documenso without changing the code. + +## 2\. Create your own + +Since the cryptography behind certificates is freely available as open source you could generate your own using OpenSSL for example. Since it’s hardly more work than option 1 (using Documenso at least), this would be my minimum effort recommendation. Having a self-created (“self-signed”) certificate doesn’t add much in terms of regulation but it guarantees the document’s integrity, meaning no changes have been made after signing². What this doesn’t give you, is the famous green checkmark in Adobe Acrobat. Why? Because you aren’t on the list of providers Adobe “trusts”.³ + +## 3\. Buy a “trusted” certificate. + +There are Certificate Authorities (CAs) that can sell you a certificate⁴. The service they provide is, that they validate your name (personal certificates) or your organization’s name (corporate certificate) before creating your certificate for you, just like you did in option 2. The difference is, that they are listed on the previously mentioned trust lists (e.g. Adobe’s) and thus the resulting signatures get a nice, green checkmark in Adobe Reader⁵ + +## 4\. Becoming a Trusted Certificate Authority (CA) yourself and create your own certificate + +This option is an incredibly complex endeavour, requiring a lot of effort and skill. It can be done, as there are multiple CAs around the world. Is it worth the effort? That depends a lot on what you’re trying to accomplish. + +
.  .  .
+ +## What we did + +Having briefly introduced the options, here is what we did: Since we aim to raise the bar on digital signature proliferation and trust, we opted to buy an “Advanced Personal Certificates for Companies/Organisations” from WiseKey. Thus, documents signed with Documenso’s hosted version look like this: + +
+ + +
The famous green checkmark: Signed by hosted Documenso
+
+ +There weren’t any deeper reasons we choose WiseKey, other than they offered what we needed and there wasn’t any reason to look much further. While I didn’t map the entire certificate market offering (yet), I’m pretty sure something similar could be found elsewhere. While we opted for option 3, choosing option 2 might be perfectly reasonable considering your use case.⁶ + +> While this is our setup, for now, we have a bigger plan for this topic. While globally trusted SSL Certificates have been available for free, courtesy of Let’s Encrypt, for a while now, there is no such thing as document signing. And there should be. Not having free and trusted infrastructure for signing is blocking a completely new generation of signing products from being created. This is why we’ll start working on option 4 when the time is right. + +Do you have questions or thoughts about this? As always, let me know in the comments, on twitter.com/eltimuro +or directly: documen.so/timur + +Join the self-hoster community here: https://documen.so/discord + +Best from Hamburg + +Timur + +\[1\] There are different approaches to signing a document. For the sake of simplicity, here we talk about a document with X inserted signature images, that is afterward signed once the by signing service, i.e. Documenso. If each visual signature should have its own digital one (e.g. QES — eIDAS Level 3), the case is a bit more complex. + +\[2\] Of course, the signing service provider technically can change and resign the document, especially in the case mentioned in \[1\]. This can be countered by requiring actual digital signatures from each signer, that are bound to their identity/ account. Creating a completely trustless system in the context however is extremely hard to do and not the most pressing business need for the industry at this point, in my opinion. Though, this would be nice. + +\[3\] Adobe, like the EU, has a list of organizations they trust. The Adobe green checkmark is powered by the Adobe trust list, if you want to be trusted by EU standards here: https://ec.europa.eu/digital-building-blocks/DSS/webapp-demo/validation, you need to be on the EU trust list. Getting on each list is possible, though the latter is much more work. + +\[4\] Technically, they sign your certificate creation request (created by you), containing your info with their certificate (which is trusted), making your certificate trusted. This way, everything you sign with your certificate is seen as trusted. They created their certificate just like you, the difference is they are on the lists, mentioned in \[3\] + +\[5\] Why does Adobe get to say, what is trusted? They simply happen to have the most used pdf viewer. And since everyone checks there, whom they consider trusted carries weight. If it should be like this, is a different matter. + +\[6\] Self-Signed signatures, even purely visual signatures, are fully legally binding. Why you use changes mainly your confidence in the signature and the burden of proof. Also, some industries require a certain level of signatures e.g. retail loans (QES/ eIDAS Level 3 in the EU). diff --git a/apps/marketing/content/blog/deploy-with-vercel-supabase-resend.mdx b/apps/marketing/content/blog/deploy-with-vercel-supabase-resend.mdx new file mode 100644 index 000000000..fb00f40bf --- /dev/null +++ b/apps/marketing/content/blog/deploy-with-vercel-supabase-resend.mdx @@ -0,0 +1,198 @@ +--- +title: 'Deploying Documenso with Vercel, Supabase and Resend' +description: This is the first part of the new Building Documenso series, where I describe the challenges and design choices that we make while building the world’s most open signing platform. +authorName: 'Ephraim Atta-Duncan' +authorImage: '/blog/blog-author-duncan.jpeg' +authorRole: 'Software Engineer Intern' +date: 2023-09-08 +tags: + - Open Source + - Self Hosting + - Tutorial +--- + +In this article, we'll walk you through how to deploy and self-host Documenso using Vercel, Supabase, and Resend. + +You'll learn: + +- How to set up a Postgres database using Supabase, +- How to install SMTP with Resend, +- How to deploy your project with Vercel. + +If you don't know what [Documenso](https://documenso.com/) is, it's an open-source alternative to DocuSign, with the mission to create an open signing infrastructure while embracing openness, cooperation, and transparency. + +## Prerequisites + +Before we start, make sure you have a [GitHub](https://github.com/signup) account. You also need [Node.js](https://nodejs.org/en) and [npm](https://www.npmjs.com/) installed on your local machine (note: you also have the option to host it on a cloud environment using Gitpod for example; that would be another post). If you need accounts on Vercel, Supabase, and Resend, create them by visiting the [Vercel](https://vercel.com/), [Supabase](https://supabase.com/), and [Resend](https://resend.com/) websites. + +Checklist: + +- [ ] Have a GitHub account +- [ ] Install Node.js +- [ ] Install npm +- [ ] Have a Vercel account +- [ ] Have a Supabase account +- [ ] Have a Resend account + +## Step-by-Step guide to deploying Documenso with Vercel, Supabase, and Resend + +To deploy Documenso, we'll take the following steps: + +1. Fork the Documenso repository +2. Clone the forked repository and install dependencies +3. Create a new project on Supabase +4. Copy the Supabase Postgres database connection URL +5. Create a `.env` file +6. Run the migration on the Supabase Postgres Database +7. Get your SMTP Keys on Resend +8. Create a new project on Vercel +9. Add Environment Variables to your Vercel project + +So, you're ready? Let’s dive in! + +### Step 1: Fork the Documenso repository + +Start by creating a fork of Documenso on GitHub. You can do this by visiting the [Documenso repository](https://github.com/documenso/documenso) and clicking on the 'Fork' button. (Also, star the repo!) + +![Documenso](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wkcujctpf86p56bju3mq.png) + +Choose your GitHub profile as the owner and click on 'Create fork' to create a fork of the repo. + +![Fork the Documenso repository on GitHub](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xf49r2byu9nnd1465niy.png) + +### Step 2: Clone the forked repository and install dependencies + +Clone the forked repository to your local machine in any directory of your choice. Open your terminal and enter the following commands: + +```bash +# Clone the repo using Github CLI +gh repo clone [your_github_username]/documenso + +# Clone the repo using Git +git clone +``` + +You can now navigate into the directory and install the project’s dependencies: + +```bash +cd documenso +npm install +``` + +### Step 3: Create a new project on Supabase + +Now, let's set up the database. + +If you haven't already, create a new project on Supabase. This will automatically create a new Postgres database for you. + +On your Supabase dashboard, click the '**New project**' button and choose your organization. + +On the '**Create a new project**' page, set a database name of **documenso** and a secure password for your database. Choose a region closer to you, a pricing plan, and click on '**Create new project**' to create your project. + +![Create a new project on Supabase](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w5lqz771iupjyi1ekfdz.png) + +### Step 4: Copy the Supabase Postgres database connection URL + +In your project, click the '**Settings**' icon at the bottom left. + +Under the '**Project Settings**' section, click '**Database**' and scroll down to the '**Connection string**' section. Copy the '**URI**' and update it with the password you chose in the previous step. + +![Copy the Supabase Postgres database connection URL](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y1ldu3qrg9moednbzjij.png) + +### Step 5: Create a `.env` file + +Create a `.env` file in the root of your project by copying the contents of the `.env.example` file. + +Add the connection string you copied from your Supabase dashboard to the `DATABASE_URL` variable in the `.env` file. + +The `.env` should look like this: + +```bash +DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres" +``` + +### Step 6: Run the migration on the Supabase Postgres Database + +Run the migration on the Supabase Postgres Database using the following command: + +```bash +npx prisma migrate deploy +``` + +### Step 7: Get your SMTP Keys on Resend + +So, you've just cloned Documenso, installed dependencies on your local machine, and set your database using Supabase. Now, SMTP is missing. Emails won't go out! Let's fix it with Resend. + +In the **[Resend](https://resend.com/)** dashboard, click 'Add API Key' to create a key for Resend SMTP. + +![Create a key for Resend SMTP](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uh2rztgn09mlvecl34i5.png) + +Next, add and verify your domain in the '**Domains**' section on the sidebar. This will allow you to send emails from any address associated with your domain. + +![Verify your domain on Resend](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nxgie0esz530vq5a494o.png) + +You can update your `.env` file with the following: + +```jsx +SMTP_MAIL_HOST = 'smtp.resend.com'; +SMTP_MAIL_PORT = '25'; +SMTP_MAIL_USER = 'resend'; +SMTP_MAIL_PASSWORD = 'YOUR_RESEND_API_KEY'; +MAIL_FROM = 'noreply@[YOUR_DOMAIN]'; +``` + +### Step 8: Create a new project on Vercel + +You set the database with Supabase and are SMTP-ready with Resend. Almost there! The next step is to deploy the project — we'll use Vercel for that. + +On your Vercel dashboard, create a new project using the forked project from your GitHub repositories. Select the project among the options and click '**Import**' to start running Documenso. + +![Create a new project on Vercel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gdy97tltpnu7vf4fc11f.png) + +### Step 9: Add Environment Variables to your Vercel project + +In the '**Configure Project**' page, adding the required Environmental Variables is essential to ensure the application deploys without any errors. + +Specifically, for the `NEXT_PUBLIC_WEBAPP_URL` and `NEXTAUTH_URL` variables, you must add `.vercel.app` to your Project Name. This will form the deployment URL, which will be in the format: `https://[project_name].vercel.app`. + +For example, in my case, the deployment URL is `https://documenso-supabase-web.vercel.app`. + +![Add Environment Variables to your Vercel project](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/aru33fk1i19h0valffow.png) + +This is a sample `.env` to deploy. Copy and paste it to auto-populate the fields and click ‘**Deploy.’** Now, you only need to wait for your project to deploy. You’re going live — enjoy! + +```bash +DATABASE_URL='postgresql://postgres:typeinastrongpassword@db.njuigobjlbteahssqbtw.supabase.co:5432/postgres' + +NEXT_PUBLIC_WEBAPP_URL='https://documenso-supabase-web.vercel.app' +NEXTAUTH_SECRET='something gibrish to encrypt your jwt tokens' +NEXTAUTH_URL='https://documenso-supabase-web.vercel.app' + +# Get a Sendgrid Api key here: +SENDGRID_API_KEY='' + +# Set SMTP credentials to use SMTP instead of the Sendgrid API. +SMTP_MAIL_HOST='smtp.resend.com' +SMTP_MAIL_PORT='25' +SMTP_MAIL_USER='resend' +SMTP_MAIL_PASSWORD='YOUR_RESEND_API_KEY' +MAIL_FROM='noreply@[YOUR_DOMAIN]' + +NEXT_PUBLIC_ALLOW_SIGNUP=true +``` + +## Wrapping up + +![Deploying Documenso](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/secg29j9j40o4u2oa8o8.png) + +Congratulations! 🎉 You've successfully deployed Documenso using Vercel, Supabase, and Resend. You're now ready to create and sign your own documents with your self-hosted Documenso! + +In this step-by-step guide, you learned how to: + +- set up a Postgres database using Supabase, +- install SMTP with Resend, +- deploy your project with Vercel. + +Over to you! How was the tutorial? If you enjoyed it, [please do share](https://twitter.com/documenso/status/1700141802693480482)! And if you have any questions or comments, please reach out to me on [Twitter / X](https://twitter.com/EphraimDuncan_) (DM open) or [Discord](https://documen.so/discord). + +We're building an open-source alternative to DocuSign and welcome every contribution. Head over to the GitHub repository and [leave us a Star](https://github.com/documenso/documenso)! diff --git a/apps/marketing/content/blog/next.mdx b/apps/marketing/content/blog/next.mdx index 4f846a0a7..c241cf3eb 100644 --- a/apps/marketing/content/blog/next.mdx +++ b/apps/marketing/content/blog/next.mdx @@ -12,7 +12,7 @@ tags: Since we launched [Documenso 0.9 on Product Hunt](https://producthunt.com/products/documenso#documenso) last May, the team's been hard at work behind the scenes to ramp up development and design to deliver an excellent next version. -Last week, Lucas shared the reasoning how [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite). +Last week, Lucas shared the reasoning on [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite). Today, I'm pleased to share with you a preview of the next Documenso. diff --git a/apps/marketing/content/blog/why-were-doing-a-rewrite.mdx b/apps/marketing/content/blog/why-were-doing-a-rewrite.mdx new file mode 100644 index 000000000..f2195a019 --- /dev/null +++ b/apps/marketing/content/blog/why-were-doing-a-rewrite.mdx @@ -0,0 +1,113 @@ +--- +title: Why we're doing a rewrite +description: As we move beyond MVP and onto creating the open signing infrastructure we all deserve we need to take a quick pit-stop. +authorName: 'Lucas Smith' +authorImage: '/blog/blog-author-lucas.png' +authorRole: 'Co-Founder' +date: 2023-08-05 +tags: + - Community + - Development +--- + +
+ + +
+ The next generation of Documenso and signing infrastructure. +
+
+ +> TLDR; We're rewriting Documenso to move on from our MVP foundations and create an even better base for the project. This rewrite will provide us the opportunity to fix a few things within the project while enabling a faster development process moving forward. + +# Introduction + +At Documenso, we're building the next generation of signing infrastructure with a focus on making it inclusive and accessible for all. To do this we need to ensure that the software we write is also inclusive and accessible and for this reason we’ve decided to take a step back and perform a _quick_ rewrite. + +Although we've achieved validated MVP status and gained paying customers, we're still quite far from our goal of creating a trusted, open signing experience. To move closer to that future, we need to step back and focus on the project's foundations to ensure we can solve all the items we set out to on our current homepage. + +Fortunately, this wasn't a case of someone joining the team and proposing a rewrite due to a lack of understanding of the codebase and context surrounding it. Prior to joining Documenso as a co-founder, I had spent an extensive amount of time within the Documenso codebase and had a fairly intimate understanding of what was happening for the most part. This knowledge allowed me to make the fair and simultaneously hard call to take a quick pause so we can rebuild our current foundations to enable accessibility and a faster delivery time in the future. + +# The Reasoning: TypeScript + +Our primary reason for the rewrite is to better leverage the tools and technologies we've already chosen, namely TypeScript. While Documenso currently uses TypeScript, it's not fully taking advantage of its safety features, such as generics and type guards. + +The codebase currently has several instances of `any` types, which is expected when working in an unknown domain where object models aren't fully understood before exploration and experimentation. These `any`s initially sped up development, but have since become a hindrance due to the lack of type information, combined with prop drilling. As a result, it's necessary to go through a lot of context to understand the root of any given issue. + +The rewrite is using TypeScript to its full potential, ensuring that every interaction is strongly typed, both through general TypeScript tooling and the introduction of [Zod](https://github.com/colinhacks/zod), a validation library with excellent TypeScript support. With these choices, we can ensure that the codebase is robust to various inputs and states, as most issues will be caught during compile time and flagged within a developer's IDE. + +# The Reasoning: Stronger API contracts + +In line with our pattern of creating strongly typed contracts, we've decided to use [tRPC](https://github.com/trpc/trpc) for our internal API. This enables us to share types between our frontend and backend and establish a solid contract for interactions between the two. This is in contrast to the currently untyped API endpoints in Documenso, which are accessed using the `fetch` API that is itself untyped. + +Using tRPC drastically reduces the chance of failures resulting from mundane things like argument or response shape changes during updates and upgrades. We made this decision easily because tRPC is a mature technology with no signs of losing momentum any time soon. + +Additionally, many of our open-source friends have made the same choice for similar reasons. + +# The Reasoning: Choosing exciting technologies + +Although we already work with what I consider to be a fun stack that includes Next.js, Prisma, Tailwind, and more, it's no secret that contributors enjoy working with new technologies that benefit them in their own careers and projects. + +To take advantage of this, we have decided to use Next.js 13 and React's new server component and actions architecture. Server components are currently popular among developers, with many loving and hating them at the same time. + +I have personally worked with server components and actions since they were first released in October 2022 and have dealt with most of the hiccups and limitations along the way. Now, in July 2023, I believe they are in a much more stable place and are ready to be adopted, with their benefits being recognised by many. + +By choosing to use server components and actions, we hope to encourage the community to participate more than they otherwise might. However, we are only choosing this because it has become more mature and stable. We will not choose things that are less likely to become the de-facto solution in the future, as we do not wish to inherit a pile of tech debt later on. + +# The Reasoning: Allowing concurrent work + +Another compelling reason for the rewrite was to effectively modularise code so we can work on features concurrently and without issue. This means extracting as much as possible out of components, API handlers and more and into a set of methods and functions that attempt to focus on just one thing. + +In performing this work we should be able to easily make refactors and other changes to various parts of the code without stepping on each others feet, this also grants us the ability to upgrade or deprecate items as required by sticking to the contract of the previous method. + +Additionally, this makes testing a much easier task as we can focus more on units of work rather than extensive end to end testing although we aim to have both, just not straight away. + +# The Reasoning: Licensing of work + +Another major reasoning for the rewrite is to ensure that all work performed on the project by both our internal team and external contributors is licensed in a way that benefits the project long-term. Prior to the rewrite contributors would create pull requests that would be merged in without any further process outside of the common code-review and testing cycles. + +This was fine for the most part since we were simply working on the MVP but now as we move towards an infrastructure focus we intend on taking on enterprise clients who will have a need for a non-GPLv3 license since interpretations of it can be quite harmful to private hosting, to facilitate this we will require contributors to sign a contributor license agreement (CLA) prior to their changes being merged which will assign a perpetual license for us to use their code and relicense it as required such as for the use-case above. + +While some might cringe at the idea of signing a CLA, we want to offer a compelling enterprise offering through means of dual-licensing. Great enterprise adoption is one of the cornerstones of our strategy and will be key to funding community and product development long-term. + +_Do note that the above does not mean that we will ever go closed-source, it’s a point in our investor agreements that [https://github.com/documenso/documenso](https://github.com/documenso/documenso) will always remain available and open-source._ + +# Goals and Non-Goals + +Rewriting an application is a monumental task that I have taken on and rejected many times in my career. As I get older, I become more hesitant to perform these rewrites because I understand that systems carry a lot of context and history. This makes them better suited for piecemeal refactoring instead, which avoids learning the lessons of the past all over again during the launch of the rewrite. + +To ensure that we aren't just jumping off the deep end, I have set out a list of goals and non-goals to keep this rewrite lean and affordable. + +### Goals + +- Provide a clean design and interface for the newly rewritten application that creates a sense of trust and security at first glance. +- Create a stable foundation and architecture that will allow for growth into our future roadmap items (teams, automation, workflows, etc.). +- Create a robust system that requires minimal context through strong contracts and typing. + +### Non-Goals + +- Change the database schema (we don't want to make migration harder than it needs to be, thus all changes must be additive). +- Add too many features that weren't in the system prior to the rewrite. +- Remove any features that were in the older version of Documenso, such as free signatures (signatures that have no corresponding field). + +# Rollout Plan + +Thanks to the constraints listed above our rollout will hopefully be fairly painless, still to be safe we plan on doing the following. + +1. In the current [testing environment](https://test.documenso.com), create and sign a number of documents leaving many in varying states of completion. +2. Deploy the rewrite to the testing environment and verify that all existing documents and information is retrievable and modifiable without any issue. +3. Create another set of documents using the new rewrite and verify that all interactions between authoring and signing work as expected. +4. Repeat this until we reach a general confidence level (expectation of two weeks). + +Once we’ve reached the desired confidence level with our testing environment we will look to deploy the rewrite to the production environment ensuring that we’ve performed all the required backups in the event of a catastrophic failure. + +# Want to help out? + +We’re currently working on the **[feat/refresh](https://github.com/documenso/documenso/tree/feat/refresh)** branch on GitHub, we aim to have a CLA available to sign in the coming days so we can start accepting external contributions asap. While we’re nearing the end-stage of the rewrite we will be throwing up a couple of bounties shortly for things like [Husky](https://github.com/typicode/husky) and [Changesets](https://github.com/changesets/changesets). + +Keep an eye on our [GitHub issues](https://github.com/documenso/documenso/issues) to stay up to date! diff --git a/apps/marketing/next.config.js b/apps/marketing/next.config.js index ee7d10899..2783e4063 100644 --- a/apps/marketing/next.config.js +++ b/apps/marketing/next.config.js @@ -8,9 +8,50 @@ const { parsed: env } = require('dotenv').config({ /** @type {import('next').NextConfig} */ const config = { + experimental: { + serverActions: true, + }, reactStrictMode: true, transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'], - env, + modularizeImports: { + 'lucide-react': { + transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}', + }, + }, + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'x-dns-prefetch-control', + value: 'on', + }, + { + key: 'strict-transport-security', + value: 'max-age=31536000; includeSubDomains; preload', + }, + { + key: 'x-frame-options', + value: 'SAMEORIGIN', + }, + { + key: 'x-content-type-options', + value: 'nosniff', + }, + { + key: 'referrer-policy', + value: 'strict-origin-when-cross-origin', + }, + { + key: 'permissions-policy', + value: + 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()', + }, + ], + }, + ]; + }, }; module.exports = withContentlayer(config); diff --git a/apps/marketing/process-env.d.ts b/apps/marketing/process-env.d.ts index ac170a616..3dfdcb30f 100644 --- a/apps/marketing/process-env.d.ts +++ b/apps/marketing/process-env.d.ts @@ -1,6 +1,7 @@ declare namespace NodeJS { export interface ProcessEnv { - NEXT_PUBLIC_SITE_URL?: string; + NEXT_PUBLIC_WEBAPP_URL?: string; + NEXT_PUBLIC_MARKETING_URL?: string; NEXT_PRIVATE_DATABASE_URL: string; diff --git a/apps/marketing/public/blog/blog-author-duncan.jpeg b/apps/marketing/public/blog/blog-author-duncan.jpeg new file mode 100644 index 000000000..a4a52711c Binary files /dev/null and b/apps/marketing/public/blog/blog-author-duncan.jpeg differ diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/opengraph-image.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/opengraph-image.tsx new file mode 100644 index 000000000..f9987dd27 --- /dev/null +++ b/apps/marketing/src/app/(marketing)/blog/[post]/opengraph-image.tsx @@ -0,0 +1,76 @@ +import { ImageResponse } from 'next/server'; + +import { allBlogPosts } from 'contentlayer/generated'; + +export const runtime = 'edge'; + +export const size = { + width: 1200, + height: 630, +}; + +export const contentType = 'image/png'; + +type BlogPostOpenGraphImageProps = { + params: { post: string }; +}; + +export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGraphImageProps) { + const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`); + + if (!blogPost) { + return null; + } + + // The long urls are needed for a compiler optimisation on the Next.js side, lifting this up + // to a constant will break og image generation. + const [interBold, interRegular, backgroundImage, logoImage] = await Promise.all([ + fetch(new URL('./../../../../assets/inter-bold.ttf', import.meta.url)).then(async (res) => + res.arrayBuffer(), + ), + fetch(new URL('./../../../../assets/inter-regular.ttf', import.meta.url)).then(async (res) => + res.arrayBuffer(), + ), + fetch(new URL('./../../../../assets/background-blog-og.png', import.meta.url)).then( + async (res) => res.arrayBuffer(), + ), + fetch(new URL('./../../../../../public/logo.png', import.meta.url)).then(async (res) => + res.arrayBuffer(), + ), + ]); + + return new ImageResponse( + ( +
+ {/* @ts-expect-error Lack of typing from ImageResponse */} + og-background + + {/* @ts-expect-error Lack of typing from ImageResponse */} + logo + +

+ {blogPost.title} +

+ +

Written by {blogPost.authorName}

+
+ ), + { + ...size, + fonts: [ + { + name: 'Inter', + data: interRegular, + style: 'normal', + weight: 400, + }, + { + name: 'Inter', + data: interBold, + style: 'normal', + weight: 700, + }, + ], + }, + ); +} diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx index 5192dec32..757eb8882 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx @@ -17,7 +17,10 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => { notFound(); } - return { title: `Documenso - ${blogPost.title}` }; + return { + title: `Documenso - ${blogPost.title}`, + description: blogPost.description, + }; }; const mdxComponents: MDXComponents = { diff --git a/apps/marketing/src/app/(marketing)/claimed/page.tsx b/apps/marketing/src/app/(marketing)/claimed/page.tsx index ce748006e..b1636e2be 100644 --- a/apps/marketing/src/app/(marketing)/claimed/page.tsx +++ b/apps/marketing/src/app/(marketing)/claimed/page.tsx @@ -27,7 +27,11 @@ export type ClaimedPlanPageProps = { export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlanPageProps) { const { sessionId } = searchParams; - const session = await stripe.checkout.sessions.retrieve(sessionId as string); + if (typeof sessionId !== 'string') { + redirect('/'); + } + + const session = await stripe.checkout.sessions.retrieve(sessionId); const user = await prisma.user.findFirst({ where: { @@ -157,8 +161,7 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan

diff --git a/apps/marketing/src/app/layout.tsx b/apps/marketing/src/app/layout.tsx index ea21ed3c3..46d9a3d32 100644 --- a/apps/marketing/src/app/layout.tsx +++ b/apps/marketing/src/app/layout.tsx @@ -21,12 +21,12 @@ export const metadata = { description: 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.', type: 'website', - images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`], + images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`], }, twitter: { site: '@documenso', card: 'summary_large_image', - images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`], + images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`], description: 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.', }, diff --git a/apps/marketing/src/assets/background-blog-og.png b/apps/marketing/src/assets/background-blog-og.png new file mode 100644 index 000000000..d5d48a21a Binary files /dev/null and b/apps/marketing/src/assets/background-blog-og.png differ diff --git a/apps/marketing/src/assets/inter-bold.ttf b/apps/marketing/src/assets/inter-bold.ttf new file mode 100644 index 000000000..8e82c70d1 Binary files /dev/null and b/apps/marketing/src/assets/inter-bold.ttf differ diff --git a/apps/marketing/src/assets/inter-regular.ttf b/apps/marketing/src/assets/inter-regular.ttf new file mode 100644 index 000000000..8d4eebf20 Binary files /dev/null and b/apps/marketing/src/assets/inter-regular.ttf differ diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index 6ae66a0a4..ab0dd6e24 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -9,6 +9,22 @@ import { cn } from '@documenso/ui/lib/utils'; export type FooterProps = HTMLAttributes; +const SOCIAL_LINKS = [ + { href: 'https://twitter.com/documenso', icon: }, + { href: 'https://github.com/documenso/documenso', icon: }, + { href: 'https://documen.so/discord', icon: }, +]; + +const FOOTER_LINKS = [ + { href: '/pricing', text: 'Pricing' }, + { href: '/blog', text: 'Blog' }, + { href: '/open', text: 'Open' }, + { href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' }, + { href: 'https://status.documenso.com', text: 'Status', target: '_blank' }, + { href: 'mailto:support@documenso.com', text: 'Support' }, + { href: '/privacy', text: 'Privacy' }, +]; + export const Footer = ({ className, ...props }: FooterProps) => { return (
@@ -19,77 +35,25 @@ export const Footer = ({ className, ...props }: FooterProps) => {
- - - - - - - - - - - + {SOCIAL_LINKS.map((link, index) => ( + + {link.icon} + + ))}
- - Pricing - - - - Blog - - - - Open - - - - Shop - - - - Status - - - - Support - - - - Privacy - + {FOOTER_LINKS.map((link, index) => ( + + {link.text} + + ))}
diff --git a/apps/marketing/src/components/(marketing)/mobile-navigation.tsx b/apps/marketing/src/components/(marketing)/mobile-navigation.tsx index bc64e7f59..d32cd4af1 100644 --- a/apps/marketing/src/components/(marketing)/mobile-navigation.tsx +++ b/apps/marketing/src/components/(marketing)/mobile-navigation.tsx @@ -22,6 +22,10 @@ export const MENU_NAVIGATION_LINKS = [ href: '/pricing', text: 'Pricing', }, + { + href: '/open', + text: 'Open', + }, { href: 'https://status.documenso.com', text: 'Status', @@ -59,7 +63,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat initial="initial" animate="animate" transition={{ - staggerChildren: 0.2, + staggerChildren: 0.03, }} > {MENU_NAVIGATION_LINKS.map(({ href, text }) => ( @@ -75,6 +79,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat x: 0, transition: { duration: 0.5, + ease: 'backInOut', }, }, }} diff --git a/apps/marketing/src/components/(marketing)/pricing-table.tsx b/apps/marketing/src/components/(marketing)/pricing-table.tsx index 4d229ae98..d3e76bd84 100644 --- a/apps/marketing/src/components/(marketing)/pricing-table.tsx +++ b/apps/marketing/src/components/(marketing)/pricing-table.tsx @@ -22,7 +22,6 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => { const event = usePlausible(); const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() => - // eslint-disable-next-line turbo/no-undeclared-env-vars params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID ? 'YEARLY' : 'MONTHLY', @@ -30,11 +29,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => { const planId = useMemo(() => { if (period === 'MONTHLY') { - // eslint-disable-next-line turbo/no-undeclared-env-vars return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID; } - // eslint-disable-next-line turbo/no-undeclared-env-vars return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID; }, [period]); diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx index 15e15d04c..def90e0cd 100644 --- a/apps/marketing/src/components/(marketing)/widget.tsx +++ b/apps/marketing/src/components/(marketing)/widget.tsx @@ -139,7 +139,6 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { setTimeout(resolve, 1000); }); - // eslint-disable-next-line turbo/no-undeclared-env-vars const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID; const claimPlanInput = signatureDataUrl @@ -147,7 +146,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { name, email, planId, - signatureDataUrl: signatureDataUrl!, + signatureDataUrl: signatureDataUrl, signatureText: null, } : { @@ -155,7 +154,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { email, planId, signatureDataUrl: null, - signatureText: signatureText!, + signatureText: signatureText ?? '', }; const [result] = await Promise.all([claimPlan(claimPlanInput), delay]); diff --git a/apps/marketing/src/pages/api/claim-plan/index.ts b/apps/marketing/src/pages/api/claim-plan/index.ts index a2e4108d2..3d2d8679b 100644 --- a/apps/marketing/src/pages/api/claim-plan/index.ts +++ b/apps/marketing/src/pages/api/claim-plan/index.ts @@ -43,8 +43,7 @@ export default async function handler( if (user && user.Subscription.length > 0) { return res.status(200).json({ - // eslint-disable-next-line turbo/no-undeclared-env-vars - redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`, + redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`, }); } @@ -104,9 +103,8 @@ export default async function handler( mode: 'subscription', metadata, allow_promotion_codes: true, - // eslint-disable-next-line turbo/no-undeclared-env-vars - success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`, - cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent( + success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`, + cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/pricing?email=${encodeURIComponent( email, )}&name=${encodeURIComponent(name)}&planId=${planId}&cancelled=true`, }); diff --git a/apps/marketing/src/pages/api/stripe/webhook/index.ts b/apps/marketing/src/pages/api/stripe/webhook/index.ts index a0a4ccebb..ad9bfe808 100644 --- a/apps/marketing/src/pages/api/stripe/webhook/index.ts +++ b/apps/marketing/src/pages/api/stripe/webhook/index.ts @@ -8,8 +8,11 @@ import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf'; import { redis } from '@documenso/lib/server-only/redis'; import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; +import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { updateFile } from '@documenso/lib/universal/upload/update-file'; import { prisma } from '@documenso/prisma'; import { + DocumentDataType, DocumentStatus, FieldType, ReadStatus, @@ -17,14 +20,13 @@ import { SigningStatus, } from '@documenso/prisma/client'; -const log = (...args: any[]) => console.log('[stripe]', ...args); +const log = (...args: unknown[]) => console.log('[stripe]', ...args); export const config = { api: { bodyParser: false }, }; export default async function handler(req: NextApiRequest, res: NextApiResponse) { - // eslint-disable-next-line turbo/no-undeclared-env-vars // if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) { // return res.status(500).json({ // success: false, @@ -55,6 +57,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) log('event-type:', event.type); if (event.type === 'checkout.session.completed') { + // This typecast is required since we don't want to create a guard for every event type + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const session = event.data.object as Stripe.Checkout.Session; if (session.metadata?.source === 'landing') { @@ -84,16 +88,34 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const now = new Date(); + const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'); + + const { id: documentDataId } = await prisma.documentData.create({ + data: { + type: DocumentDataType.BYTES_64, + data: bytes64, + initialData: bytes64, + }, + }); + const document = await prisma.document.create({ data: { title: 'Documenso Supporter Pledge.pdf', status: DocumentStatus.COMPLETED, userId: user.id, - document: readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'), - created: now, + documentDataId, + }, + include: { + documentData: true, }, }); + const { documentData } = document; + + if (!documentData) { + throw new Error(`Document ${document.id} has no document data`); + } + const recipient = await prisma.recipient.create({ data: { name: user.name ?? '', @@ -120,17 +142,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); + let pdfData = await getFile(documentData).then((data) => + Buffer.from(data).toString('base64'), + ); + if (signatureDataUrl) { - document.document = await insertImageInPDF( - document.document, + pdfData = await insertImageInPDF( + pdfData, signatureDataUrl, Number(field.positionX), Number(field.positionY), field.page, ); } else { - document.document = await insertTextInPDF( - document.document, + pdfData = await insertTextInPDF( + pdfData, signatureText ?? '', Number(field.positionX), Number(field.positionY), @@ -138,6 +164,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ); } + const { data: newData } = await updateFile({ + type: documentData.type, + oldData: documentData.initialData, + newData: Buffer.from(pdfData, 'base64').toString('binary'), + }); + await Promise.all([ prisma.signature.create({ data: { @@ -147,12 +179,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) typedSignature: signatureDataUrl ? '' : signatureText, }, }), - prisma.document.update({ + prisma.documentData.update({ where: { - id: document.id, + id: documentData.id, }, data: { - document: document.document, + data: newData, }, }), ]); diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 09760f806..be51b51fc 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); +const { version } = require('./package.json'); const { parsed: env } = require('dotenv').config({ path: path.join(__dirname, '../../.env.local'), @@ -9,6 +10,7 @@ const { parsed: env } = require('dotenv').config({ const config = { experimental: { serverActions: true, + serverActionsBodySizeLimit: '50mb', }, reactStrictMode: true, transpilePackages: [ @@ -18,7 +20,9 @@ const config = { '@documenso/ui', '@documenso/email', ], - env, + env: { + APP_VERSION: version, + }, modularizeImports: { 'lucide-react': { transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}', diff --git a/apps/web/package.json b/apps/web/package.json index 8e7dd2be7..d3ab34f96 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,7 +24,6 @@ "lucide-react": "^0.214.0", "luxon": "^3.4.0", "micro": "^10.0.1", - "nanoid": "^4.0.2", "next": "13.4.12", "next-auth": "4.22.3", "next-plausible": "^3.10.1", diff --git a/apps/web/process-env.d.ts b/apps/web/process-env.d.ts index 1cb0018ac..4149423dd 100644 --- a/apps/web/process-env.d.ts +++ b/apps/web/process-env.d.ts @@ -1,6 +1,7 @@ declare namespace NodeJS { export interface ProcessEnv { - NEXT_PUBLIC_SITE_URL?: string; + NEXT_PUBLIC_WEBAPP_URL?: string; + NEXT_PUBLIC_MARKETING_URL?: string; NEXT_PRIVATE_DATABASE_URL: string; diff --git a/apps/web/src/api/document/create/fetcher.ts b/apps/web/src/api/document/create/fetcher.ts deleted file mode 100644 index fdc23456c..000000000 --- a/apps/web/src/api/document/create/fetcher.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; - -import { TCreateDocumentRequestSchema, ZCreateDocumentResponseSchema } from './types'; - -export const useCreateDocument = () => { - return useMutation(async ({ file }: TCreateDocumentRequestSchema) => { - const formData = new FormData(); - - formData.set('file', file); - - const response = await fetch('/api/document/create', { - method: 'POST', - body: formData, - }); - - const body = await response.json(); - - if (response.status !== 200) { - throw new Error('Failed to create document'); - } - - const safeBody = ZCreateDocumentResponseSchema.safeParse(body); - - if (!safeBody.success) { - throw new Error('Failed to create document'); - } - - if ('error' in safeBody.data) { - throw new Error(safeBody.data.error); - } - - return safeBody.data; - }); -}; diff --git a/apps/web/src/api/document/create/types.ts b/apps/web/src/api/document/create/types.ts deleted file mode 100644 index 07541a5dd..000000000 --- a/apps/web/src/api/document/create/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod'; - -export const ZCreateDocumentRequestSchema = z.object({ - file: z.instanceof(File), -}); - -export type TCreateDocumentRequestSchema = z.infer; - -export const ZCreateDocumentResponseSchema = z - .object({ - id: z.number(), - }) - .or( - z.object({ - error: z.string(), - }), - ); - -export type TCreateDocumentResponseSchema = z.infer; diff --git a/apps/web/src/app/(dashboard)/admin/layout.tsx b/apps/web/src/app/(dashboard)/admin/layout.tsx new file mode 100644 index 000000000..3aa47d1a9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/layout.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { redirect } from 'next/navigation'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; + +import { AdminNav } from './nav'; + +export type AdminSectionLayoutProps = { + children: React.ReactNode; +}; + +export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) { + const user = await getRequiredServerComponentSession(); + + if (!isAdmin(user)) { + redirect('/documents'); + } + + return ( +
+
+ + +
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/nav.tsx b/apps/web/src/app/(dashboard)/admin/nav.tsx new file mode 100644 index 000000000..3b87a9b13 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/nav.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { HTMLAttributes } from 'react'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +import { BarChart3, User2 } from 'lucide-react'; + +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type AdminNavProps = HTMLAttributes; + +export const AdminNav = ({ className, ...props }: AdminNavProps) => { + const pathname = usePathname(); + + return ( +
+ + + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/page.tsx b/apps/web/src/app/(dashboard)/admin/page.tsx new file mode 100644 index 000000000..5fe030685 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function Admin() { + redirect('/admin/stats'); +} diff --git a/apps/web/src/app/(dashboard)/admin/stats/page.tsx b/apps/web/src/app/(dashboard)/admin/stats/page.tsx new file mode 100644 index 000000000..b93af5a03 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/stats/page.tsx @@ -0,0 +1,75 @@ +import { + File, + FileCheck, + FileClock, + FileEdit, + Mail, + MailOpen, + PenTool, + User as UserIcon, + UserPlus2, + UserSquare2, +} from 'lucide-react'; + +import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats'; +import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats'; +import { + getUsersCount, + getUsersWithSubscriptionsCount, +} from '@documenso/lib/server-only/admin/get-users-stats'; + +import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card'; + +export default async function AdminStatsPage() { + const [usersCount, usersWithSubscriptionsCount, docStats, recipientStats] = await Promise.all([ + getUsersCount(), + getUsersWithSubscriptionsCount(), + getDocumentStats(), + getRecipientsStats(), + ]); + + return ( +
+

Instance Stats

+ +
+ + + + +
+ +
+
+

Document metrics

+ +
+ + + + +
+
+ +
+

Recipients metrics

+ +
+ + + + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx deleted file mode 100644 index 8e242b2fd..000000000 --- a/apps/web/src/app/(dashboard)/dashboard/page.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import Link from 'next/link'; - -import { Clock, File, FileCheck } from 'lucide-react'; - -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; -import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; -import { getStats } from '@documenso/lib/server-only/document/get-stats'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@documenso/ui/primitives/table'; - -import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; -import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card'; -import { DocumentStatus } from '~/components/formatter/document-status'; -import { LocaleDate } from '~/components/formatter/locale-date'; - -import { UploadDocument } from './upload-document'; - -export default async function DashboardPage() { - const user = await getRequiredServerComponentSession(); - - const [stats, results] = await Promise.all([ - getStats({ - user, - }), - findDocuments({ - userId: user.id, - perPage: 10, - }), - ]); - - return ( -
-

Dashboard

- -
- - - - - - - - - -
- -
- - -

Recent Documents

- -
- - - - ID - Title - Reciepient - Status - Created - - - - {results.data.map((document) => { - return ( - - {document.id} - - - {document.title} - - - - - - - - - - - - - - - ); - })} - {results.data.length === 0 && ( - - - No results. - - - )} - -
-
-
-
- ); -} diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index e1c9a79e1..b4837ab23 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -4,7 +4,8 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Document, Field, Recipient, User } from '@documenso/prisma/client'; +import { Field, Recipient, User } from '@documenso/prisma/client'; +import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { cn } from '@documenso/ui/lib/utils'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields'; @@ -28,9 +29,10 @@ import { completeDocument } from '~/components/forms/edit-document/add-subject.a export type EditDocumentFormProps = { className?: string; user: User; - document: Document; + document: DocumentWithData; recipients: Recipient[]; fields: Field[]; + dataUrl: string; }; type EditDocumentStep = 'signers' | 'fields' | 'subject'; @@ -41,14 +43,13 @@ export const EditDocumentForm = ({ recipients, fields, user: _user, + dataUrl, }: EditDocumentFormProps) => { const { toast } = useToast(); const router = useRouter(); const [step, setStep] = useState('signers'); - const documentUrl = `data:application/pdf;base64,${document.document}`; - const documentFlow: Record = { signers: { title: 'Add Signers', @@ -130,7 +131,13 @@ export const EditDocumentForm = ({ }, }); - router.refresh(); + toast({ + title: 'Document sent', + description: 'Your document has been sent successfully.', + duration: 5000, + }); + + router.push('/documents'); } catch (err) { console.error(err); @@ -145,11 +152,11 @@ export const EditDocumentForm = ({ return (
- + diff --git a/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx b/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx deleted file mode 100644 index 5f01ec107..000000000 --- a/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx +++ /dev/null @@ -1,20 +0,0 @@ -'use client'; - -import { Card, CardContent } from '@documenso/ui/primitives/card'; -import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; -import { PDFViewerProps } from '@documenso/ui/primitives/pdf-viewer'; - -export type LoadablePDFCard = PDFViewerProps & { - className?: string; - pdfClassName?: string; -}; - -export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => { - return ( - - - - - - ); -}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index 7ab2c331c..3d6dbb954 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -7,6 +7,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get- import { getDocumentById } from '@documenso/lib/server-only/document/get-document-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 { getFile } from '@documenso/lib/universal/upload/get-file'; import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; @@ -36,10 +37,16 @@ export default async function DocumentPage({ params }: DocumentPageProps) { userId: session.id, }).catch(() => null); - if (!document) { + if (!document || !document.documentData) { redirect('/documents'); } + const { documentData } = document; + + const documentDataUrl = await getFile(documentData) + .then((buffer) => Buffer.from(buffer).toString('base64')) + .then((data) => `data:application/pdf;base64,${data}`); + const [recipients, fields] = await Promise.all([ await getRecipientsForDocument({ documentId, @@ -86,12 +93,13 @@ export default async function DocumentPage({ params }: DocumentPageProps) { user={session} recipients={recipients} fields={fields} + dataUrl={documentDataUrl} /> )} {document.status === InternalDocumentStatus.COMPLETED && (
- +
)}
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index b1d5832f8..dd028e0b2 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -15,7 +15,10 @@ import { } from 'lucide-react'; import { useSession } from 'next-auth/react'; +import { getFile } from '@documenso/lib/universal/upload/get-file'; import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client'; +import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; +import { trpc } from '@documenso/trpc/client'; import { DropdownMenu, DropdownMenuContent, @@ -47,17 +50,26 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = const isComplete = row.status === DocumentStatus.COMPLETED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; - const onDownloadClick = () => { - let decodedDocument = row.document; + const onDownloadClick = async () => { + let document: DocumentWithData | null = null; - try { - decodedDocument = atob(decodedDocument); - } catch (err) { - // We're just going to ignore this error and try to download the document - console.error(err); + if (!recipient) { + document = await trpc.document.getDocumentById.query({ + id: row.id, + }); + } else { + document = await trpc.document.getDocumentByToken.query({ + token: recipient.token, + }); } - const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0))); + const documentData = document?.documentData; + + if (!documentData) { + return; + } + + const documentBytes = await getFile(documentData); const blob = new Blob([documentBytes], { type: 'application/pdf', @@ -82,14 +94,14 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = Action - + Sign - + Edit diff --git a/apps/web/src/app/(dashboard)/documents/data-table-title.tsx b/apps/web/src/app/(dashboard)/documents/data-table-title.tsx new file mode 100644 index 000000000..c04f9f13d --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/data-table-title.tsx @@ -0,0 +1,56 @@ +'use client'; + +import Link from 'next/link'; + +import { useSession } from 'next-auth/react'; +import { match } from 'ts-pattern'; + +import { Document, Recipient, User } from '@documenso/prisma/client'; + +export type DataTableTitleProps = { + row: Document & { + User: Pick; + Recipient: Recipient[]; + }; +}; + +export const DataTableTitle = ({ row }: DataTableTitleProps) => { + const { data: session } = useSession(); + + if (!session) { + return null; + } + + const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); + + const isOwner = row.User.id === session.user.id; + const isRecipient = !!recipient; + + return match({ + isOwner, + isRecipient, + }) + .with({ isOwner: true }, () => ( + + {row.title} + + )) + .with({ isRecipient: true }, () => ( + + {row.title} + + )) + .otherwise(() => ( + + {row.title} + + )); +}; diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index 1d6c08e73..7d2f18a27 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -2,9 +2,8 @@ import { useTransition } from 'react'; -import Link from 'next/link'; - import { Loader } from 'lucide-react'; +import { useSession } from 'next-auth/react'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { FindResultSet } from '@documenso/lib/types/find-result-set'; @@ -18,6 +17,7 @@ import { LocaleDate } from '~/components/formatter/locale-date'; import { DataTableActionButton } from './data-table-action-button'; import { DataTableActionDropdown } from './data-table-action-dropdown'; +import { DataTableTitle } from './data-table-title'; export type DocumentsDataTableProps = { results: FindResultSet< @@ -29,6 +29,7 @@ export type DocumentsDataTableProps = { }; export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { + const { data: session } = useSession(); const [isPending, startTransition] = useTransition(); const updateSearchParams = useUpdateSearchParams(); @@ -42,25 +43,22 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { }); }; + if (!session) { + return null; + } + return (
, }, { header: 'Title', - cell: ({ row }) => ( - - {row.original.title} - - ), + cell: ({ row }) => , }, { header: 'Recipient', @@ -74,11 +72,6 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { accessorKey: 'status', cell: ({ row }) => , }, - { - header: 'Created', - accessorKey: 'created', - cell: ({ row }) => , - }, { header: 'Actions', cell: ({ row }) => ( @@ -95,7 +88,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { totalPages={results.totalPages} onPaginationChange={onPaginationChange} > - {(table) => } + {(table) => } {isPending && ( diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index 4ea55936b..32ec4f814 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -11,8 +11,8 @@ import { PeriodSelector } from '~/components/(dashboard)/period-selector/period- import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types'; import { DocumentStatus } from '~/components/formatter/document-status'; -import { UploadDocument } from '../dashboard/upload-document'; import { DocumentsDataTable } from './data-table'; +import { UploadDocument } from './upload-document'; export type DocumentsPageProps = { searchParams?: { @@ -39,7 +39,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage userId: user.id, status, orderBy: { - column: 'created', + column: 'createdAt', direction: 'desc', }, page, @@ -81,6 +81,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage {value !== ExtendedDocumentStatus.ALL && ( {Math.min(stats[value], 99)} + {stats[value] > 99 && '+'} )} diff --git a/apps/web/src/app/(dashboard)/dashboard/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx similarity index 67% rename from apps/web/src/app/(dashboard)/dashboard/upload-document.tsx rename to apps/web/src/app/(dashboard)/documents/upload-document.tsx index ee8a69ae6..b472c606b 100644 --- a/apps/web/src/app/(dashboard)/dashboard/upload-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx @@ -1,29 +1,45 @@ 'use client'; +import { useState } from 'react'; + import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; +import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; +import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useCreateDocument } from '~/api/document/create/fetcher'; - export type UploadDocumentProps = { className?: string; }; export const UploadDocument = ({ className }: UploadDocumentProps) => { - const { toast } = useToast(); const router = useRouter(); - const { isLoading, mutateAsync: createDocument } = useCreateDocument(); + const { toast } = useToast(); + + const [isLoading, setIsLoading] = useState(false); + + const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation(); const onFileDrop = async (file: File) => { try { + setIsLoading(true); + + const { type, data } = await putFile(file); + + const { id: documentDataId } = await createDocumentData({ + type, + data, + }); + const { id } = await createDocument({ - file: file, + title: file.name, + documentDataId, }); toast({ @@ -41,6 +57,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => { description: 'An error occurred while uploading your document.', variant: 'destructive', }); + } finally { + setIsLoading(false); } }; diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index e9966b9ac..bd2659e62 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -21,19 +21,21 @@ export default async function BillingSettingsPage() { redirect('/settings/profile'); } - let subscription = await getSubscriptionByUserId({ userId: user.id }); + const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => { + if (sub) { + return sub; + } - // If we don't have a customer record, create one as well as an empty subscription. - if (!subscription?.customerId) { - subscription = await createCustomer({ user }); - } + // If we don't have a customer record, create one as well as an empty subscription. + return createCustomer({ user }); + }); let billingPortalUrl = ''; - if (subscription?.customerId) { + if (subscription.customerId) { billingPortalUrl = await getPortalSession({ customerId: subscription.customerId, - returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`, + returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`, }); } diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/download-button.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/download-button.tsx index 2195e2e70..49b7a8f15 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/download-button.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/download-button.tsx @@ -1,55 +1,64 @@ 'use client'; -import { HTMLAttributes } from 'react'; +import { HTMLAttributes, useState } from 'react'; import { Download } from 'lucide-react'; +import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { DocumentData } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; export type DownloadButtonProps = HTMLAttributes & { disabled?: boolean; fileName?: string; - document?: string; + documentData?: DocumentData; }; export const DownloadButton = ({ className, fileName, - document, + documentData, disabled, ...props }: DownloadButtonProps) => { - /** - * Convert the document from base64 to a blob and download it. - */ - const onDownloadClick = () => { - if (!document) { - return; - } + const { toast } = useToast(); - let decodedDocument = document; + const [isLoading, setIsLoading] = useState(false); + const onDownloadClick = async () => { try { - decodedDocument = atob(document); + setIsLoading(true); + + if (!documentData) { + return; + } + + const bytes = await getFile(documentData); + + const blob = new Blob([bytes], { + type: 'application/pdf', + }); + + const link = window.document.createElement('a'); + + link.href = window.URL.createObjectURL(blob); + link.download = fileName || 'document.pdf'; + + link.click(); + + window.URL.revokeObjectURL(link.href); } catch (err) { - // We're just going to ignore this error and try to download the document console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while downloading your document.', + variant: 'destructive', + }); + } finally { + setIsLoading(false); } - - const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0))); - - const blob = new Blob([documentBytes], { - type: 'application/pdf', - }); - - const link = window.document.createElement('a'); - - link.href = window.URL.createObjectURL(blob); - link.download = fileName || 'document.pdf'; - - link.click(); - - window.URL.revokeObjectURL(link.href); }; return ( @@ -57,8 +66,9 @@ export const DownloadButton = ({ type="button" variant="outline" className={className} - disabled={disabled || !document} + disabled={disabled || !documentData} onClick={onDownloadClick} + loading={isLoading} {...props} > diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index b97b7f8d6..71a368da5 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -30,15 +30,21 @@ export default async function CompletedSigningPage({ token, }).catch(() => null); - if (!document) { + if (!document || !document.documentData) { return notFound(); } + const { documentData } = document; + const [fields, recipient] = await Promise.all([ getFieldsForToken({ token }), - getRecipientByToken({ token }), + getRecipientByToken({ token }).catch(() => null), ]); + if (!recipient) { + return notFound(); + } + const recipientName = recipient.name || fields.find((field) => field.type === FieldType.NAME)?.customText || @@ -91,7 +97,7 @@ export default async function CompletedSigningPage({
diff --git a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx new file mode 100644 index 000000000..05c1cb31c --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Loader } from 'lucide-react'; + +import { Recipient } from '@documenso/prisma/client'; +import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { trpc } from '@documenso/trpc/react'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredSigningContext } from './provider'; +import { SigningFieldContainer } from './signing-field-container'; + +export type EmailFieldProps = { + field: FieldWithSignature; + recipient: Recipient; +}; + +export const EmailField = ({ field, recipient }: EmailFieldProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const { email: providedEmail } = useRequiredSigningContext(); + + const [isPending, startTransition] = useTransition(); + + const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(); + + const { + mutateAsync: removeSignedFieldWithToken, + isLoading: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + + const onSign = async () => { + try { + await signFieldWithToken({ + token: recipient.token, + fieldId: field.id, + value: providedEmail ?? '', + isBase64: false, + }); + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while signing the document.', + variant: 'destructive', + }); + } + }; + + const onRemove = async () => { + try { + await removeSignedFieldWithToken({ + token: recipient.token, + fieldId: field.id, + }); + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while removing the signature.', + variant: 'destructive', + }); + } + }; + + return ( + + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( +

Email

+ )} + + {field.inserted &&

{field.customText}

} +
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 3666941dc..e18571e33 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -87,9 +87,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = className="h-44 w-full" defaultValue={signature ?? undefined} onChange={(value) => { - console.log({ - signpadValue: value, - }); setSignature(value); }} /> diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index d3329ff79..d2c14a524 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -3,16 +3,19 @@ import { notFound } from 'next/navigation'; import { match } from 'ts-pattern'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; 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 { getFile } from '@documenso/lib/universal/upload/get-file'; import { FieldType } 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 { DateField } from './date-field'; +import { EmailField } from './email-field'; import { SigningForm } from './form'; import { NameField } from './name-field'; import { SigningProvider } from './provider'; @@ -34,18 +37,24 @@ export default async function SigningPage({ params: { token } }: SigningPageProp token, }).catch(() => null), getFieldsForToken({ token }), - getRecipientByToken({ token }), + getRecipientByToken({ token }).catch(() => null), viewedDocument({ token }), ]); - if (!document) { + if (!document || !document.documentData || !recipient) { return notFound(); } - const documentUrl = `data:application/pdf;base64,${document.document}`; + const { documentData } = document; + + const documentDataUrl = await getFile(documentData) + .then((buffer) => Buffer.from(buffer).toString('base64')) + .then((data) => `data:application/pdf;base64,${data}`); + + const user = await getServerComponentSession(); return ( - +

{document.title} @@ -63,7 +72,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp gradient > - + @@ -84,6 +93,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp .with(FieldType.DATE, () => ( )) + .with(FieldType.EMAIL, () => ( + + )) .otherwise(() => null), )} diff --git a/apps/web/src/app/(signing)/sign/[token]/provider.tsx b/apps/web/src/app/(signing)/sign/[token]/provider.tsx index 40d2bd0bb..454007cb0 100644 --- a/apps/web/src/app/(signing)/sign/[token]/provider.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/provider.tsx @@ -28,9 +28,9 @@ export const useRequiredSigningContext = () => { }; export interface SigningProviderProps { - fullName?: string; - email?: string; - signature?: string; + fullName?: string | null; + email?: string | null; + signature?: string | null; children: React.ReactNode; } diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx index 03fc40914..cb70ea4db 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -63,11 +63,6 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { const onSign = async (source: 'local' | 'provider' = 'provider') => { try { - console.log({ - providedSignature, - localSignature, - }); - if (!providedSignature && !localSignature) { setShowSignatureModal(true); return; @@ -141,6 +136,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { {state === 'signed-text' && (

+ {/* This optional chaining is intentional, we don't want to move the check into the condition above */} {signature?.typedSignature}

)} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 1d1e056ae..2a1d082f9 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,8 @@ import { Suspense } from 'react'; import { Caveat, Inter } from 'next/font/google'; +import { LocaleProvider } from '@documenso/lib/client-only/providers/locale'; +import { getLocale } from '@documenso/lib/server-only/headers/get-locale'; import { TrpcProvider } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Toaster } from '@documenso/ui/primitives/toaster'; @@ -31,12 +33,12 @@ export const metadata = { description: 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.', type: 'website', - images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`], + images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`], }, twitter: { site: '@documenso', card: 'summary_large_image', - images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`], + images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`], description: 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.', }, @@ -45,6 +47,8 @@ export const metadata = { export default async function RootLayout({ children }: { children: React.ReactNode }) { const flags = await getServerComponentAllFlags(); + const locale = getLocale(); + return ( - - - - - {children} - - - - - + + + + + + {children} + + + + + + ); diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx index 78814cd45..a2a81bb2a 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatar.tsx @@ -15,7 +15,7 @@ export type StackAvatarProps = { type: 'unsigned' | 'waiting' | 'opened' | 'completed'; }; -export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => { +export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => { let classes = ''; let zIndexClass = ''; const firstClass = first ? '' : '-ml-3'; @@ -48,7 +48,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr ${firstClass} dark:border-border h-10 w-10 border-2 border-solid border-white`} > - {fallbackText ?? 'UK'} + {fallbackText} ); }; diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx index 2a053a35a..e36415813 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -1,5 +1,5 @@ -import { initials } from '@documenso/lib/client-only/recipient-initials'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { Recipient } from '@documenso/prisma/client'; import { Tooltip, @@ -56,7 +56,7 @@ export const StackAvatarsWithTooltip = ({ first={true} key={recipient.id} type={getRecipientType(recipient)} - fallbackText={initials(recipient.name)} + fallbackText={recipientAbbreviation(recipient)} /> {recipient.email}

@@ -73,7 +73,7 @@ export const StackAvatarsWithTooltip = ({ first={true} key={recipient.id} type={getRecipientType(recipient)} - fallbackText={initials(recipient.name)} + fallbackText={recipientAbbreviation(recipient)} /> {recipient.email}
@@ -90,7 +90,7 @@ export const StackAvatarsWithTooltip = ({ first={true} key={recipient.id} type={getRecipientType(recipient)} - fallbackText={initials(recipient.name)} + fallbackText={recipientAbbreviation(recipient)} /> {recipient.email} @@ -107,7 +107,7 @@ export const StackAvatarsWithTooltip = ({ first={true} key={recipient.id} type={getRecipientType(recipient)} - fallbackText={initials(recipient.name)} + fallbackText={recipientAbbreviation(recipient)} /> {recipient.email} diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx index 97af9dc9e..91f470f74 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { initials } from '@documenso/lib/client-only/recipient-initials'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { Recipient } from '@documenso/prisma/client'; import { StackAvatar } from './stack-avatar'; @@ -26,7 +26,7 @@ export function StackAvatars({ recipients }: { recipients: Recipient[] }) { first={first} zIndex={String(zIndex - index * 10)} type={lastItemText && index === 4 ? 'unsigned' : getRecipientType(recipient)} - fallbackText={lastItemText ? lastItemText : initials(recipient.name)} + fallbackText={lastItemText ? lastItemText : recipientAbbreviation(recipient)} /> ); }); diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index 88dc5d7a4..bea7f4aee 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -1,6 +1,6 @@ 'use client'; -import { HTMLAttributes } from 'react'; +import { HTMLAttributes, useEffect, useState } from 'react'; import Link from 'next/link'; @@ -17,10 +17,23 @@ export type HeaderProps = HTMLAttributes & { }; export const Header = ({ className, user, ...props }: HeaderProps) => { + const [scrollY, setScrollY] = useState(0); + + useEffect(() => { + const onScroll = () => { + setScrollY(window.scrollY); + }; + + window.addEventListener('scroll', onScroll); + + return () => window.removeEventListener('scroll', onScroll); + }, []); + return (
5 && 'border-b-border', className, )} {...props} diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx index 02af86d70..9ae9b4297 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -11,10 +11,13 @@ import { Monitor, Moon, Sun, + UserCog, } from 'lucide-react'; import { signOut } from 'next-auth/react'; import { useTheme } from 'next-themes'; +import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; +import { recipientInitials } from '@documenso/lib/utils/recipient-formatter'; import { User } from '@documenso/prisma/client'; import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; import { Button } from '@documenso/ui/primitives/button'; @@ -35,24 +38,21 @@ export type ProfileDropdownProps = { export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { const { theme, setTheme } = useTheme(); - const { getFlag } = useFeatureFlags(); + const isUserAdmin = isAdmin(user); const isBillingEnabled = getFlag('app_billing'); - const initials = - user.name - ?.split(' ') - .map((name: string) => name.slice(0, 1).toUpperCase()) - .slice(0, 2) - .join('') ?? 'UK'; + const avatarFallback = user.name + ? recipientInitials(user.name) + : user.email.slice(0, 1).toUpperCase(); return ( @@ -60,6 +60,19 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { Account + {isUserAdmin && ( + <> + + + + Admin + + + + + + )} + diff --git a/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx b/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx index f59d42096..a2248ccdc 100644 --- a/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx +++ b/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx @@ -18,10 +18,10 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr )} >
-
- {Icon && } +
+ {Icon && } -

{title}

+

{title}

diff --git a/apps/web/src/components/(dashboard)/period-selector/types.ts b/apps/web/src/components/(dashboard)/period-selector/types.ts index 4ebfe47f1..2b50f5d6c 100644 --- a/apps/web/src/components/(dashboard)/period-selector/types.ts +++ b/apps/web/src/components/(dashboard)/period-selector/types.ts @@ -1,5 +1,6 @@ export type PeriodSelectorValue = '' | '7d' | '14d' | '30d'; export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return ['', '7d', '14d', '30d'].includes(value as string); }; diff --git a/apps/web/src/components/(marketing)/callout.tsx b/apps/web/src/components/(marketing)/callout.tsx deleted file mode 100644 index d83983141..000000000 --- a/apps/web/src/components/(marketing)/callout.tsx +++ /dev/null @@ -1,66 +0,0 @@ -'use client'; - -import Link from 'next/link'; - -import { Github } from 'lucide-react'; -import { usePlausible } from 'next-plausible'; - -import { Button } from '@documenso/ui/primitives/button'; - -export type CalloutProps = { - starCount?: number; - [key: string]: unknown; -}; - -export const Callout = ({ starCount }: CalloutProps) => { - const event = usePlausible(); - - const onSignUpClick = () => { - const el = document.getElementById('email'); - - if (el) { - const { top } = el.getBoundingClientRect(); - - window.scrollTo({ - top: top - 120, - behavior: 'smooth', - }); - - setTimeout(() => { - el.focus(); - }, 500); - } - }; - - return ( -

- - - event('view-github')} - > - - -
- ); -}; diff --git a/apps/web/src/components/(marketing)/claim-plan-dialog.tsx b/apps/web/src/components/(marketing)/claim-plan-dialog.tsx deleted file mode 100644 index 1f78c5292..000000000 --- a/apps/web/src/components/(marketing)/claim-plan-dialog.tsx +++ /dev/null @@ -1,150 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; - -import { useSearchParams } from 'next/navigation'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { Info, Loader } from 'lucide-react'; -import { usePlausible } from 'next-plausible'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} 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 { claimPlan } from '~/api/claim-plan/fetcher'; - -import { FormErrorMessage } from '../form/form-error-message'; - -export const ZClaimPlanDialogFormSchema = z.object({ - name: z.string().min(3), - email: z.string().email(), -}); - -export type TClaimPlanDialogFormSchema = z.infer; - -export type ClaimPlanDialogProps = { - className?: string; - planId: string; - children: React.ReactNode; -}; - -export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => { - const params = useSearchParams(); - const { toast } = useToast(); - const event = usePlausible(); - - const [open, setOpen] = useState(() => params?.get('cancelled') === 'true'); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - mode: 'onBlur', - defaultValues: { - name: params?.get('name') ?? '', - email: params?.get('email') ?? '', - }, - resolver: zodResolver(ZClaimPlanDialogFormSchema), - }); - - const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => { - try { - const delay = new Promise((resolve) => { - setTimeout(resolve, 1000); - }); - - const [redirectUrl] = await Promise.all([ - claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }), - delay, - ]); - - event('claim-plan-pricing'); - - window.location.href = redirectUrl; - } catch (error) { - event('claim-plan-failed'); - - toast({ - title: 'Something went wrong', - description: error instanceof Error ? error.message : 'Please try again later.', - variant: 'destructive', - }); - } - }; - - return ( - - {children} - - - - Claim your plan - - - We're almost there! Please enter your email address and name to claim your plan. - - - -
- {params?.get('cancelled') === 'true' && ( -
-
-
- -
-
-

- You have cancelled the payment process. If you didn't mean to do this, please - try again. -

-
-
-
- )} - -
- - - - - -
- -
- - - - - -
- - -
-
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/faster-smarter-beautiful-bento.tsx b/apps/web/src/components/(marketing)/faster-smarter-beautiful-bento.tsx deleted file mode 100644 index 2cbaaef53..000000000 --- a/apps/web/src/components/(marketing)/faster-smarter-beautiful-bento.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { HTMLAttributes } from 'react'; - -import Image from 'next/image'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; - -import backgroundPattern from '~/assets/background-pattern.png'; -import cardBeautifulFigure from '~/assets/card-beautiful-figure.png'; -import cardFastFigure from '~/assets/card-fast-figure.png'; -import cardSmartFigure from '~/assets/card-smart-figure.png'; - -export type FasterSmarterBeautifulBentoProps = HTMLAttributes; - -export const FasterSmarterBeautifulBento = ({ - className, - ...props -}: FasterSmarterBeautifulBentoProps) => { - return ( -
-
- background pattern -
-

- A 10x better signing experience. - Faster, smarter and more beautiful. -

- -
- - -

- Fast. - When it comes to sending or receiving a contract, you can count on lightning-fast - speeds. -

- -
- its fast -
-
-
- - - -

- Beautiful. - Because signing should be celebrated. That’s why we care about the smallest detail in - our product. -

- -
- its fast -
-
-
- - - -

- Smart. - Our custom templates come with smart rules that can help you save time and energy. -

- -
- its fast -
-
-
-
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/footer.tsx b/apps/web/src/components/(marketing)/footer.tsx deleted file mode 100644 index 823ece92e..000000000 --- a/apps/web/src/components/(marketing)/footer.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { HTMLAttributes } from 'react'; - -import Image from 'next/image'; -import Link from 'next/link'; - -import { Github, Slack, Twitter } from 'lucide-react'; - -import { cn } from '@documenso/ui/lib/utils'; - -export type FooterProps = HTMLAttributes; - -export const Footer = ({ className, ...props }: FooterProps) => { - return ( -
-
-
- - Documenso Logo - - -
- - - - - - - - - - - -
-
- -
- - Pricing - - - - Status - - - - Support - - - {/* - Privacy - */} -
-
-
-

- © {new Date().getFullYear()} Documenso, Inc. All rights reserved. -

-
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/header.tsx b/apps/web/src/components/(marketing)/header.tsx deleted file mode 100644 index 5a1fa3b89..000000000 --- a/apps/web/src/components/(marketing)/header.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { HTMLAttributes } from 'react'; - -import Image from 'next/image'; -import Link from 'next/link'; - -import { cn } from '@documenso/ui/lib/utils'; - -export type HeaderProps = HTMLAttributes; - -export const Header = ({ className, ...props }: HeaderProps) => { - return ( -
- - Documenso Logo - - -
- - Pricing - - - - Sign in - -
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/hero.tsx b/apps/web/src/components/(marketing)/hero.tsx deleted file mode 100644 index 7896a010e..000000000 --- a/apps/web/src/components/(marketing)/hero.tsx +++ /dev/null @@ -1,225 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import Link from 'next/link'; - -import { Variants, motion } from 'framer-motion'; -import { Github } from 'lucide-react'; -import { usePlausible } from 'next-plausible'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; - -import backgroundPattern from '~/assets/background-pattern.png'; - -import { Widget } from './widget'; - -export type HeroProps = { - className?: string; - starCount?: number; - [key: string]: unknown; -}; - -const BackgroundPatternVariants: Variants = { - initial: { - opacity: 0, - }, - - animate: { - opacity: 1, - - transition: { - delay: 1, - duration: 1.2, - }, - }, -}; - -const HeroTitleVariants: Variants = { - initial: { - opacity: 0, - y: 60, - }, - animate: { - opacity: 1, - y: 0, - transition: { - duration: 0.5, - }, - }, -}; - -export const Hero = ({ className, starCount, ...props }: HeroProps) => { - const event = usePlausible(); - - const onSignUpClick = () => { - const el = document.getElementById('email'); - - if (el) { - const { top } = el.getBoundingClientRect(); - - window.scrollTo({ - top: top - 120, - behavior: 'smooth', - }); - - requestAnimationFrame(() => { - el.focus(); - }); - } - }; - - return ( - -
- - background pattern - -
- -
- - Document signing, - finally open source. - - - - - - event('view-github')}> - - - - -
- - - Documenso - The open source DocuSign alternative | Product Hunt - - -
- - - - Documenso Supporter Pledge -

- Our mission is to create an open signing infrastructure that empowers the world, - enabling businesses to embrace openness, cooperation, and transparency. We believe - that signing, as a fundamental act, should embody these values. By offering an - open-source signing solution, we aim to make document signing accessible, transparent, - and trustworthy. -

- -

- Through our platform, called Documenso, we strive to earn your trust by allowing - self-hosting and providing complete visibility into its inner workings. We value - inclusivity and foster an environment where diverse perspectives and contributions are - welcomed, even though we may not implement them all. -

- -

- At Documenso, we envision a web-enabled future for business and contracts, and we are - committed to being the leading provider of open signing infrastructure. By combining - exceptional product design with open-source principles, we aim to deliver a robust and - well-designed application that exceeds your expectations. -

- -

- We understand that exceptional products are born from exceptional communities, and we - invite you to join our open-source community. Your contributions, whether technical or - non-technical, will help shape the future of signing. Together, we can create a better - future for everyone. -

- -

- Today we invite you to join us on this journey: By signing this mission statement you - signal your support of Documenso's mission{' '} - - (in a non-legally binding, but heartfelt way) - {' '} - and lock in the early supporter plan for forever, including everything we build this - year. -

- -
-

Timur & Lucas

-
- -
- Timur Ercan & Lucas Smith -

Co-Founders, Documenso

-
-
-
-
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/open-build-template-bento.tsx b/apps/web/src/components/(marketing)/open-build-template-bento.tsx deleted file mode 100644 index e7920500b..000000000 --- a/apps/web/src/components/(marketing)/open-build-template-bento.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { HTMLAttributes } from 'react'; - -import Image from 'next/image'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; - -import backgroundPattern from '~/assets/background-pattern.png'; -import cardBuildFigure from '~/assets/card-build-figure.png'; -import cardOpenFigure from '~/assets/card-open-figure.png'; -import cardTemplateFigure from '~/assets/card-template-figure.png'; - -export type OpenBuildTemplateBentoProps = HTMLAttributes; - -export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplateBentoProps) => { - return ( -
-
- background pattern -
-

- Truly your own. - Customise and expand. -

- -
- - -

- Open Source or Hosted. - It’s up to you. Either clone our repository or rely on our easy to use hosting - solution. -

- -
- its fast -
-
-
- - - -

- Build on top. - Make it your own through advanced customization and adjustability. -

- -
- its fast -
-
-
- - - -

- Template Store (Soon). - Choose a template from the community app store. Or submit your own template for others - to use. -

- -
- its fast -
-
-
-
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/password-reveal.tsx b/apps/web/src/components/(marketing)/password-reveal.tsx deleted file mode 100644 index b31765943..000000000 --- a/apps/web/src/components/(marketing)/password-reveal.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client'; - -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard'; - -export type PasswordRevealProps = { - password: string; -}; - -export const PasswordReveal = ({ password }: PasswordRevealProps) => { - const { toast } = useToast(); - const [, copy] = useCopyToClipboard(); - - const onCopyClick = () => { - void copy(password).then(() => { - toast({ - title: 'Copied to clipboard', - description: 'Your password has been copied to your clipboard.', - }); - }); - }; - - return ( - - ); -}; diff --git a/apps/web/src/components/(marketing)/pricing-table.tsx b/apps/web/src/components/(marketing)/pricing-table.tsx deleted file mode 100644 index 73003abdc..000000000 --- a/apps/web/src/components/(marketing)/pricing-table.tsx +++ /dev/null @@ -1,180 +0,0 @@ -'use client'; - -import { HTMLAttributes, useMemo, useState } from 'react'; - -import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; - -import { AnimatePresence, motion } from 'framer-motion'; -import { usePlausible } from 'next-plausible'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; - -import { ClaimPlanDialog } from './claim-plan-dialog'; - -export type PricingTableProps = HTMLAttributes; - -const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar'; - -export const PricingTable = ({ className, ...props }: PricingTableProps) => { - const params = useSearchParams(); - const event = usePlausible(); - - const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() => - // eslint-disable-next-line turbo/no-undeclared-env-vars - params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID - ? 'YEARLY' - : 'MONTHLY', - ); - - const planId = useMemo(() => { - if (period === 'MONTHLY') { - // eslint-disable-next-line turbo/no-undeclared-env-vars - return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID; - } - - // eslint-disable-next-line turbo/no-undeclared-env-vars - return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID; - }, [period]); - - return ( -
-
- - setPeriod('MONTHLY')} - > - Monthly - {period === 'MONTHLY' && ( - - )} - - - setPeriod('YEARLY')} - > - Yearly -
- Save $60 -
- {period === 'YEARLY' && ( - - )} -
-
-
- -
-
-

Self Hosted

-

Free

- -

- For small teams and individuals who need a simple solution -

- - - -
-

Host your own instance

-

Full Control

-

Customizability

-

Docker Ready

-

Community Support

-

Free, Forever

-
-
- -
-

Community

-
- - {period === 'MONTHLY' && $30} - {period === 'YEARLY' && $300} - -
- -

- For fast-growing companies that aim to scale across multiple teams. -

- - - - - -
-

Documenso Early Adopter Deal:

-

Join the movement

-

Simple signing solution

-

Email and Slack assistance

-

- Includes all upcoming features -

-

Fixed, straightforward pricing

-
-
- -
-

Enterprise

-

Pricing on request

- -

- For large organizations that need extra flexibility and control. -

- - event('enterprise-contact')} - > - - - -
-

Everything in Community, plus:

-

Custom Subdomain

-

Compliance Check

-

Guaranteed Uptime

-

Reporting & Analysis

-

24/7 Support

-
-
-
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/share-connect-paid-widget-bento.tsx b/apps/web/src/components/(marketing)/share-connect-paid-widget-bento.tsx deleted file mode 100644 index 05b6a3232..000000000 --- a/apps/web/src/components/(marketing)/share-connect-paid-widget-bento.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { HTMLAttributes } from 'react'; - -import Image from 'next/image'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; - -import backgroundPattern from '~/assets/background-pattern.png'; -import cardConnectionsFigure from '~/assets/card-connections-figure.png'; -import cardPaidFigure from '~/assets/card-paid-figure.png'; -import cardSharingFigure from '~/assets/card-sharing-figure.png'; -import cardWidgetFigure from '~/assets/card-widget-figure.png'; - -export type ShareConnectPaidWidgetBentoProps = HTMLAttributes; - -export const ShareConnectPaidWidgetBento = ({ - className, - ...props -}: ShareConnectPaidWidgetBentoProps) => { - return ( -
-
- background pattern -
-

- Integrates with all your favourite tools. - Send, connect, receive and embed everywhere. -

- -
- - -

- Easy Sharing (Soon). - Receive your personal link to share with everyone you care about. -

- -
- its fast -
-
-
- - - -

- Connections (Soon). - Create connections and automations with Zapier and more to integrate with your - favorite tools. -

- -
- its fast -
-
-
- - - -

- Get paid (Soon). - Integrated payments with stripe so you don’t have to worry about getting paid. -

- -
- its fast -
-
-
- - - -

- React Widget (Soon). - Easily embed Documenso into your product. Simply copy and paste our react widget into - your application. -

- -
- its fast -
-
-
-
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/widget.tsx b/apps/web/src/components/(marketing)/widget.tsx deleted file mode 100644 index 15e15d04c..000000000 --- a/apps/web/src/components/(marketing)/widget.tsx +++ /dev/null @@ -1,402 +0,0 @@ -'use client'; - -import { HTMLAttributes, KeyboardEvent, useMemo, useState } from 'react'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { AnimatePresence, motion } from 'framer-motion'; -import { Loader } from 'lucide-react'; -import { usePlausible } from 'next-plausible'; -import { Controller, useForm } from 'react-hook-form'; -import { z } from 'zod'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { Input } from '@documenso/ui/primitives/input'; -import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { claimPlan } from '~/api/claim-plan/fetcher'; - -import { FormErrorMessage } from '../form/form-error-message'; - -const ZWidgetFormSchema = z - .object({ - email: z.string().email({ message: 'Please enter a valid email address.' }), - name: z.string().min(3, { message: 'Please enter a valid name.' }), - }) - .and( - z.union([ - z.object({ - signatureDataUrl: z.string().min(1), - signatureText: z.null().or(z.string().max(0)), - }), - z.object({ - signatureDataUrl: z.null().or(z.string().max(0)), - signatureText: z.string().min(1), - }), - ]), - ); - -export type TWidgetFormSchema = z.infer; - -export type WidgetProps = HTMLAttributes; - -export const Widget = ({ className, children, ...props }: WidgetProps) => { - const { toast } = useToast(); - const event = usePlausible(); - - const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL'); - const [showSigningDialog, setShowSigningDialog] = useState(false); - const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState(null); - - const { - control, - register, - handleSubmit, - setValue, - trigger, - watch, - formState: { errors, isSubmitting, isValid }, - } = useForm({ - mode: 'onChange', - defaultValues: { - email: '', - name: '', - signatureDataUrl: null, - signatureText: '', - }, - resolver: zodResolver(ZWidgetFormSchema), - }); - - const signatureDataUrl = watch('signatureDataUrl'); - const signatureText = watch('signatureText'); - - const stepsRemaining = useMemo(() => { - if (step === 'NAME') { - return 2; - } - - if (step === 'SIGN') { - return 1; - } - - return 3; - }, [step]); - - const onNextStepClick = () => { - if (step === 'EMAIL') { - setStep('NAME'); - - setTimeout(() => { - document.querySelector('#name')?.focus(); - }, 0); - } - - if (step === 'NAME') { - setStep('SIGN'); - - setTimeout(() => { - document.querySelector('#signatureText')?.focus(); - }, 0); - } - }; - - const onEnterPress = (callback: () => void) => { - return (e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - - callback(); - } - }; - }; - - const onSignatureConfirmClick = () => { - setValue('signatureDataUrl', draftSignatureDataUrl); - setValue('signatureText', ''); - - void trigger('signatureDataUrl'); - setShowSigningDialog(false); - }; - - const onFormSubmit = async ({ - email, - name, - signatureDataUrl, - signatureText, - }: TWidgetFormSchema) => { - try { - const delay = new Promise((resolve) => { - setTimeout(resolve, 1000); - }); - - // eslint-disable-next-line turbo/no-undeclared-env-vars - const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID; - - const claimPlanInput = signatureDataUrl - ? { - name, - email, - planId, - signatureDataUrl: signatureDataUrl!, - signatureText: null, - } - : { - name, - email, - planId, - signatureDataUrl: null, - signatureText: signatureText!, - }; - - const [result] = await Promise.all([claimPlan(claimPlanInput), delay]); - - event('claim-plan-widget'); - - window.location.href = result; - } catch (error) { - event('claim-plan-failed'); - - toast({ - title: 'Something went wrong', - description: error instanceof Error ? error.message : 'Please try again later.', - variant: 'destructive', - }); - } - }; - - return ( - <> - -
-
- {children} -
- -
-

Sign up for the community plan

-

- with Timur Ercan & Lucas Smith from Documenso -

- -
- - - - - - ( -
- - field.value !== '' && - !errors.email?.message && - onEnterPress(onNextStepClick)(e) - } - {...field} - /> - -
- -
-
- )} - /> - - -
- - {(step === 'NAME' || step === 'SIGN') && ( - - - - ( -
- - field.value !== '' && - !errors.name?.message && - onEnterPress(onNextStepClick)(e) - } - {...field} - /> - -
- -
-
- )} - /> - - -
- )} -
- -
- -
-

{stepsRemaining} step(s) until signed

-

Minimise contract

-
- -
-
-
- - - setShowSigningDialog(true)} - > -
- {!signatureText && signatureDataUrl && ( - user signature - )} - - {signatureText && ( -

- {signatureText} -

- )} -
- -
e.stopPropagation()} - > - { - if (e.target.value !== '') { - setValue('signatureDataUrl', null); - } - }, - })} - /> - - -
-
-
- -
- - - - - - Add your signature - - - - By signing you signal your support of Documenso's mission in a

- non-legally binding, but heartfelt way.

-

You also unlock the option to purchase the early supporter plan including - everything we build this year for fixed price. -
- - - - - - - - -
-
- - ); -}; diff --git a/apps/web/src/components/formatter/locale-date.tsx b/apps/web/src/components/formatter/locale-date.tsx index 837c6aa38..ecefb1e3b 100644 --- a/apps/web/src/components/formatter/locale-date.tsx +++ b/apps/web/src/components/formatter/locale-date.tsx @@ -2,16 +2,31 @@ import { HTMLAttributes, useEffect, useState } from 'react'; +import { DateTime, DateTimeFormatOptions } from 'luxon'; + +import { useLocale } from '@documenso/lib/client-only/providers/locale'; + export type LocaleDateProps = HTMLAttributes & { date: string | number | Date; + format?: DateTimeFormatOptions; }; -export const LocaleDate = ({ className, date, ...props }: LocaleDateProps) => { - const [localeDate, setLocaleDate] = useState(() => new Date(date).toISOString()); +/** + * Formats the date based on the user locale. + * + * Will use the estimated locale from the user headers on SSR, then will use + * the client browser locale once mounted. + */ +export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => { + const { locale } = useLocale(); + + const [localeDate, setLocaleDate] = useState(() => + DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format), + ); useEffect(() => { - setLocaleDate(new Date(date).toLocaleString()); - }, [date]); + setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format)); + }, [date, format]); return ( diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index d65a0ce27..8255742c9 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -21,7 +21,7 @@ import { FormErrorMessage } from '../form/form-error-message'; export const ZProfileFormSchema = z.object({ name: z.string().min(1), - signature: z.string().min(1), + signature: z.string().min(1, 'Signature Pad cannot be empty'), }); export type TProfileFormSchema = z.infer; @@ -44,7 +44,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { } = useForm({ values: { name: user.name ?? '', - signature: '', + signature: user.signature || '', }, resolver: zodResolver(ZProfileFormSchema), }); @@ -118,10 +118,12 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { render={({ field: { onChange } }) => ( onChange(v ?? '')} /> )} /> +
diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 2fda8eda5..068d7577f 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -1,6 +1,8 @@ 'use client'; -import { useState } from 'react'; +import { useEffect } from 'react'; + +import { useSearchParams } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; import { Eye, EyeOff, Loader } from 'lucide-react'; @@ -9,12 +11,22 @@ import { useForm } from 'react-hook-form'; import { FcGoogle } from 'react-icons/fc'; import { z } from 'zod'; +import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { useToast } from '@documenso/ui/primitives/use-toast'; +const ERROR_MESSAGES = { + [ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect', + [ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect', + [ErrorCode.USER_MISSING_PASSWORD]: + 'This account appears to be using a social login method, please sign in using that method', +}; + +const LOGIN_REDIRECT_PATH = '/documents'; + export const ZSignInFormSchema = z.object({ email: z.string().email().min(1), password: z.string().min(6).max(72), @@ -27,6 +39,8 @@ export type SignInFormProps = { }; export const SignInForm = ({ className }: SignInFormProps) => { + const searchParams = useSearchParams(); + const { toast } = useToast(); const [showPassword, setShowPassword] = useState(false); @@ -42,17 +56,36 @@ export const SignInForm = ({ className }: SignInFormProps) => { resolver: zodResolver(ZSignInFormSchema), }); + const errorCode = searchParams?.get('error'); + + useEffect(() => { + let timeout: NodeJS.Timeout | null = null; + + if (isErrorCode(errorCode)) { + timeout = setTimeout(() => { + toast({ + variant: 'destructive', + description: ERROR_MESSAGES[errorCode] ?? 'An unknown error occurred', + }); + }, 0); + } + + return () => { + if (timeout) { + clearTimeout(timeout); + } + }; + }, [errorCode, toast]); + const onFormSubmit = async ({ email, password }: TSignInFormSchema) => { try { await signIn('credentials', { email, password, - callbackUrl: '/documents', + callbackUrl: LOGIN_REDIRECT_PATH, }).catch((err) => { console.error(err); }); - - // throw new Error('Not implemented'); } catch (err) { toast({ title: 'An unknown error occurred', @@ -64,8 +97,7 @@ export const SignInForm = ({ className }: SignInFormProps) => { const onSignInWithGoogleClick = async () => { try { - await signIn('google', { callbackUrl: '/dashboard' }); - // throw new Error('Not implemented'); + await signIn('google', { callbackUrl: LOGIN_REDIRECT_PATH }); } catch (err) { toast({ title: 'An unknown error occurred', diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index a49a9bcd8..ae8bfbc46 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -5,13 +5,14 @@ import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { Eye, EyeOff, Loader } from 'lucide-react'; import { signIn } from 'next-auth/react'; -import { useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { z } from 'zod'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; +import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; @@ -21,6 +22,7 @@ export const ZSignUpFormSchema = z.object({ name: z.string().min(1), email: z.string().email().min(1), password: z.string().min(6).max(72), + signature: z.string().min(1, { message: 'We need your signature to sign documents' }), }); export type TSignUpFormSchema = z.infer; @@ -34,6 +36,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => { const [showPassword, setShowPassword] = useState(false); const { + control, register, handleSubmit, formState: { errors, isSubmitting }, @@ -42,15 +45,16 @@ export const SignUpForm = ({ className }: SignUpFormProps) => { name: '', email: '', password: '', + signature: '', }, resolver: zodResolver(ZSignUpFormSchema), }); const { mutateAsync: signup } = trpc.auth.signup.useMutation(); - const onFormSubmit = async ({ name, email, password }: TSignUpFormSchema) => { + const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => { try { - await signup({ name, email, password }); + await signup({ name, email, password, signature }); await signIn('credentials', { email, @@ -138,8 +142,19 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
- + ( + onChange(v ?? '')} + /> + )} + />
+ +