Merge branch 'main' into admin/stats

This commit is contained in:
Ephraim Duncan
2024-06-13 05:47:17 +00:00
committed by GitHub
295 changed files with 14696 additions and 60128 deletions

View File

@ -13,6 +13,10 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
NEXT_PRIVATE_GOOGLE_CLIENT_ID="" NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET="" NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
NEXT_PRIVATE_OIDC_CLIENT_ID=""
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
# [[URLS]] # [[URLS]]
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001" NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
@ -40,16 +44,6 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS=
# OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport. # OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS= NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS=
# [[SIGNING]]
# OPTIONAL: Defines the signing transport to use. Available options: local (default)
NEXT_PRIVATE_SIGNING_TRANSPORT="local"
# OPTIONAL: Defines the passphrase for the signing certificate.
NEXT_PRIVATE_SIGNING_PASSPHRASE=
# OPTIONAL: Defines the file contents for the signing certificate as a base64 encoded string.
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS=
# OPTIONAL: Defines the file path for the signing certificate. defaults to ./example/cert.p12
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=
# [[STORAGE]] # [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
NEXT_PUBLIC_UPLOAD_TRANSPORT="database" NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
@ -85,7 +79,7 @@ NEXT_PRIVATE_SMTP_APIKEY=
# OPTIONAL: Defines whether to force the use of TLS. # OPTIONAL: Defines whether to force the use of TLS.
NEXT_PRIVATE_SMTP_SECURE= NEXT_PRIVATE_SMTP_SECURE=
# REQUIRED: Defines the sender name to use for the from address. # REQUIRED: Defines the sender name to use for the from address.
NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso" NEXT_PRIVATE_SMTP_FROM_NAME="Documenso"
# REQUIRED: Defines the email address to use as the from address. # REQUIRED: Defines the email address to use as the from address.
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com" NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
# OPTIONAL: The API key to use for Resend.com # OPTIONAL: The API key to use for Resend.com

View File

@ -41,7 +41,7 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Cache Docker layers - name: Cache Docker layers
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: /tmp/.buildx-cache path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }} key: ${{ runner.os }}-buildx-${{ github.sha }}

View File

@ -33,9 +33,9 @@ jobs:
- uses: ./.github/actions/cache-build - uses: ./.github/actions/cache-build
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2 uses: github/codeql-action/analyze@v3

View File

@ -33,7 +33,7 @@ jobs:
- name: Run Playwright tests - name: Run Playwright tests
run: npm run ci run: npm run ci
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: test-results name: test-results

View File

@ -27,7 +27,7 @@ jobs:
- name: Check Assigned User's Issue Count - name: Check Assigned User's Issue Count
id: parse-comment id: parse-comment
uses: actions/github-script@v5 uses: actions/github-script@v6
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |

25
.github/workflows/issue-labeler.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Auto Label Assigned Issues
on:
issues:
types: [assigned]
jobs:
label-when-assigned:
runs-on: ubuntu-latest
steps:
- name: Label issue
uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const issue = context.issue;
// To run only on issues and not on PR
if (github.context.payload.issue.pull_request === undefined) {
const labelResponse = await github.rest.issues.addLabels({
owner: issue.owner,
repo: issue.repo,
issue_number: issue.number,
labels: ['status: assigned']
});
}

View File

@ -17,5 +17,5 @@ jobs:
issue_number: context.issue.number, issue_number: context.issue.number,
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
labels: ["needs triage"] labels: ["status: triage"]
}) })

View File

@ -2,14 +2,14 @@ name: 'PR Review Reminder'
on: on:
pull_request: pull_request:
types: ['opened', 'reopened', 'ready_for_review', 'review_requested'] types: ['opened', 'ready_for_review']
permissions: permissions:
pull-requests: write pull-requests: write
jobs: jobs:
checkPRs: checkPRs:
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested') if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'ready_for_review')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout

View File

@ -12,7 +12,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/stale@v4 - uses: actions/stale@v5
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-pr-stale: 90 days-before-pr-stale: 90

View File

@ -29,16 +29,6 @@ ports:
visibility: private visibility: private
onOpen: ignore onOpen: ignore
github:
prebuilds:
master: true
pullRequests: true
pullRequestsFromForks: true
addCheck: true
addComment: true
addBadge: true
vscode: vscode:
extensions: extensions:
- aaron-bond.better-comments - aaron-bond.better-comments
@ -47,9 +37,5 @@ vscode:
- esbenp.prettier-vscode - esbenp.prettier-vscode
- mikestead.dotenv - mikestead.dotenv
- unifiedjs.vscode-mdx - unifiedjs.vscode-mdx
- GitHub.copilot-chat
- GitHub.copilot-labs
- GitHub.copilot
- GitHub.vscode-pull-request-github - GitHub.vscode-pull-request-github
- Prisma.prisma - Prisma.prisma
- VisualStudioExptTeam.vscodeintellicode

View File

@ -82,7 +82,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
- [NextAuth.js](https://next-auth.js.org/) - Authentication - [NextAuth.js](https://next-auth.js.org/) - Authentication
- [react-email](https://react.email/) - Email Templates - [react-email](https://react.email/) - Email Templates
- [tRPC](https://trpc.io/) - API - [tRPC](https://trpc.io/) - API
- [Node SignPDF](https://github.com/vbuch/node-signpdf) - Digital Signature - [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs - [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation - [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
- [Stripe](https://stripe.com/) - Payments - [Stripe](https://stripe.com/) - Payments

View File

@ -17,7 +17,8 @@ For the digital signature of your documents you need a signing certificate in .p
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt` `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**) 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` (If the path does not exist, it needs to be created)
## Docker ## Docker

View File

@ -1,6 +1,6 @@
--- ---
title: 'Building Documenso — Part 1: Certificates' title: 'Building Documenso — Part 1: Certificates'
description: In today's fast-paced world, productivity and efficiency are crucial for success, both in personal and professional endeavors. We all strive to make the most of our time and energy to achieve our goals effectively. However, it's not always easy to stay on track and maintain peak performance. In this blog post, we'll explore 10 valuable tips to help you boost productivity and efficiency in your daily life. description: Let's take a look why you need a signing certificate and how Documenso does it.
authorName: 'Timur Ercan' authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg' authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder' authorRole: 'Co-Founder'

View File

@ -0,0 +1,117 @@
---
title: 'Building Documenso — Part 2: Signature Validity'
description: Is a signature valid? And what does that mean? It's a surprisingly complex question; let's take a look.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-04-05
tags:
- Document Signature
- Certificates
- Signing
---
<figure>
<MdxNextImage
src="/blog/eu-validate-1.png"
width= "650"
height= "650"
alt= "A report card for signature validity."
/>
<figcaption className="text-center">
If a tree does not comply with the EU trust list, does it make a sound when validating?r
</figcaption>
</figure>
> TLDR; Signatures can be valid and compliant for different signature levels, even if some validators show higher-level errors. Not all helpful security measures are mandated by law.
# A valid question
A few days ago, an early adopter brought up this question in our [Discord](https://documen.so/discord):
<figure>
<MdxNextImage
src="/blog/eu-validate-2.png"
width= "650"
height= "650"
alt= "A report card for signature validity."
/>
<figcaption className="text-center">
You can check out the validator here: [https://documen.so/eu-validator](https://documen.so/eu-validator)
</figcaption>
</figure>
For those unfamiliar with the tool, he used the validator tool of the EU's Digital Signature Service (DSS) Framework to check the signature of a document signed with Documenso. The EU provides this tool to help users and providers check the validity level of their signatures.
A short refresher from [Building Documenso — Part 1: Certificates](https://documen.so/certs):
> Documenso inserts all visual signatures into the document and then seals it using the "Documenso Inc." corporate certificate. This makes the resulting PDF document tamper-proof and guarantees it hasn't changed since signing.
Before we answer if the document was signed correctly, we need to understand what the goal was.
There are three signature levels in the European eIDAS regulation:
1. **Simple Electronic Signatures (Level 1/ SES):** This is just a visual signature or even a checkbox on a document.
2. **Advanded Electronic Signatures (Level 2/ AES)**: An actual crypographic signature (not just a seal on the whole document, but a specific signature), using a certificate linked to the identification data of the signer.
3. **Qualified Electronic Signatures (Level 3/ QES):** Same as 2. but done by a government-certified entity on certified hardware and after identifying the signer with an official ID document (e.g., passport)
> 💡 Side Note: Number 2 (AES) is how most people imagine digital signatures. But most of the market uses 1. plus a seal on the whole document under the name of the signing provider (e.g., Documenso). The signer's data is only inserted visually, not in the actual signature. Why? One of the reasons is that it's much easier, and without a readily available open source framework to draw from, it is quite tricky to build. This is something we aim to build (which many have done) and open source (which no one has done).
From the perspective of eIDAS, Documenso offers Level 1/ SES signatures since it does not adhere to all of the requirements of Level 2/ AES. This means that, technically, there is no legal need to seal the document to achieve this level of validity (at least within eIDAS). We do it anyway since it improves the level of confidence users can have in the signed document. Sealing the document, even though not legally required, is a great example of Documenso's approach to signatures. First, we aim to provide all legal requirements for a given use case. Then, we add any protection that can be added without unwarranted friction to the creation of the signature.
## Not if valid, but how valid
**Q: So, is the signature in the image valid?**
A: Yes, as an eidas Level 1 SES.
**Q: Then why does it say "Unable to build a certificate chain up to a trusted list"**
A: The certificate we use to seal the document after inserting the signatures is not on the EU Trust list.
**Q: Does that mean it is less secure?**
A: No, it means the provider (Wisekey) is not on a list maintained by the EU. The cryptographic signature is just as strong as any other
For someone who does not deal with this stuff daily, this can be hard to comprehend. Whether you use a certificate you generated yourself, one generated by a certificate authority (CA) like Wisekey, or one by another on the EU trust list (e.g., Bundesdruckerei), the cryptographic security guaranteeing that the document has not been tampered with is always the same. Many providers like Documenso, DocuSign, PandaDoc, and Digisigner all use this method for their regular plans. That means if you were to run a document signed by them through the validator above, the result would be the same[1]. The interesting question is why? Why do it like this?
## Certificate Infrastructure is broken
While there are some actual expenses involved in providing AES and QES, the blunt reality is that it's just good business to charge for them per signature, making it unsuitable for the "standard offerings"; almost no one has the resources to set this up themselves. While this initial process of becoming a QES-certified entity is really expensive, selling the certificates afterward is very lucrative. This leads to less innovation in the space and only big players providing these high-compliance services. Even certificates only used to seal documents without being QES certified are sold for a large range of prices, and they cost almost nothing to produce.
## Why Though?
**Q: Why do people buy a certificate for money and not just generate one themselves? Isn't the cryptographic security the same?**
A: Self-generated certificates are not recognized for higher-level compliance signatures like QES
**Q: So if you don't need higher-level signatures, you could just generate one yourself?**
A: Yes, you could. Since eIDAS Level 1 does not require a cert, you could use your own.
**Q: Why don't more people?**
A: One reason is that apart from the EU trust list, there are others, like the Adobe trust list. While not legally required, being on that one (like Wisekey) gives you a green checkmark in Adobe PDF, which is how most people check signature validity.
**Q: Not a question, but all of this sounds weird**
A: It is. This is one of the reasons why Documenso exists. We plan to make this easier.
**Q: How?**
A: By explaining and providing easy-to-use tools and eventually free, highly compliant signature certificates for everyone.
Eventually, we plan to start a free certificate authority called Let's Sign, named after another instituion that broke the paid certificate paradigm to the benefit of the internet: [Let's Encrypt](https://letsencrypt.org/).
As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments.
Best from Hamburg\
Timur
\
\
\
[1] The signature format (e.g. PKCS7-B) will vary. It's the format what the signature inserted into the document looks like. eIDAS itself does not specifically require any given format, but the PAdES defined by the EU is mostly used by european providers.

View File

@ -0,0 +1,49 @@
---
title: Sunsetting the Early Adopters Plan
description: We reached or Early Adopter cap and not transition to our regular pricing 🎉
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-06-12
tags:
- Early Adopters
- Pricing
- Open Startup
---
<figure>
<MdxNextImage
src="/blog/sunset.jpg"
width="1260"
height="630"
alt="A beautiful sunset as a metaphor for the Early Adopter phase ending"
/>
<figcaption className="text-center">
"Being early is, uh, good." -Unknown
</figcaption>
</figure>
> TLDR; The Early Adopters Plan ended, and we have a new pricing. If you are an Early Adopter, reach out for a Discord community badge 🏅
# The End of the Beginning
12 months, 13 releases, and 344 merged pull requests after announcing the Early Adopter plan in our first-ever launch week, and we hit the cap of 100. Documenso has changed and grown a lot since then. For us, this is a great milestone towards our broader mission of bringing open signing to the world since now we are joined by a community consisting of contributors and early customers all around the world.
# The New Plans
Starting today, we are sunsetting the Early Adopter Plan in favor of our new, more nuanced pricing model. The Early Adopter plan will succeeded by the **Individual plan**, which is still priced at $30/mo. The Individual plans will still include unlimited signatures and recipients since this aligns with our core belief of empowering our users wherever possible. If you managed to grab an Early Adopter plan, reach out on X or Discord to receive a special community badge. Early Adopters are meant to get preferential treatment where possible.
Previously soft-launched as part of Early Adopters, we are officially introducing the **Team Plan** to our pricing for customers requiring multi-user accounts. Priced at $50/ mo. for 5 users, this plan offers unlimited signature volume as well. Additional users can be added for $10/mo. as needed. We have carefully crafted the billing of teams to ensure that dynamic changes are accurately reflected at the end of each billing cycle, providing you with a fair-value pricing structure.
Our **Free Plan** stays unchanged, offering coverage to casual users and an easy way to try out Documenso or start developing.
Check out our [new pricing page here](https://documen.so/pricing). We also updated our [open page](https://documen.so/open) to reflect the end of Early Adopters. The metric now counts active subscriptions from Individuals and Teams.
# API Access
All plans include access to the API as per our philosophy, making Documenso an open platform, and allowing everyone to build on it, no matter how big or small. Besides the Free Plan's 5 signatures per month limit, the API does not have access restrictions. Even the free plan can keep using the API after using its signature volume for non-signing operations like reading, editing, and even creating documents. Since the individual plan technically allows for running a Fortune 500 company for $30/ mo., plan we are adding a fair use clause here: You are free to use the API "a lot" if you are a big organization trying to stay on the Individual Plan we will ask to have a word about upgrading (which might make sense anyway considering your requirements). Fair use excludes Early Adopters, which we consider limitless by any measure. If you need clarification on whether your case is covered under fair use, you can contact us on Discord or support@documenso.com. It's probably fine, though.
We also have a lot in the pipeline, and we are excited to share everything with you soon. A Big Shoutout to all Early Adopters. We salute you, and you will receive the preferred treatment where possible.
If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord).
Best from Hamburg\
Timur

View File

@ -2,6 +2,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { withContentlayer } = require('next-contentlayer'); const { withContentlayer } = require('next-contentlayer');
const { withAxiom } = require('next-axiom');
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`]; const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
@ -17,11 +18,15 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'), path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
); );
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
);
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const config = { const config = {
experimental: { experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'), outputFileTracingRoot: path.join(__dirname, '../../'),
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'], serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
serverActions: { serverActions: {
bodySizeLimit: '50mb', bodySizeLimit: '50mb',
}, },
@ -37,6 +42,7 @@ const config = {
env: { env: {
NEXT_PUBLIC_PROJECT: 'marketing', NEXT_PUBLIC_PROJECT: 'marketing',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`, FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
}, },
modularizeImports: { modularizeImports: {
'lucide-react': { 'lucide-react': {
@ -95,4 +101,4 @@ const config = {
}, },
}; };
module.exports = withContentlayer(config); module.exports = withAxiom(withContentlayer(config));

View File

@ -19,13 +19,18 @@
"@documenso/trpc": "*", "@documenso/trpc": "*",
"@documenso/ui": "*", "@documenso/ui": "*",
"@hookform/resolvers": "^3.1.0", "@hookform/resolvers": "^3.1.0",
"@openstatus/react": "^0.0.3",
"contentlayer": "^0.3.4", "contentlayer": "^0.3.4",
"embla-carousel": "^8.1.3",
"embla-carousel-autoplay": "^8.1.3",
"embla-carousel-react": "^8.1.3",
"framer-motion": "^10.12.8", "framer-motion": "^10.12.8",
"lucide-react": "^0.279.0", "lucide-react": "^0.279.0",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "14.0.3", "next": "14.0.3",
"next-auth": "4.24.5", "next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-contentlayer": "^0.3.4", "next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1", "next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0", "perfect-freehand": "^1.2.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -1,4 +1,5 @@
import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types'; import type { TClaimPlanRequestSchema } from './types';
import { ZClaimPlanResponseSchema } from './types';
export const claimPlan = async ({ export const claimPlan = async ({
name, name,

View File

@ -2,10 +2,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -48,16 +46,8 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
})} })}
> >
{showProfilesAnnouncementBar && ( {showProfilesAnnouncementBar && (
<div className="relative inline-flex w-full items-center justify-center overflow-hidden px-4 py-2.5"> <div className="relative inline-flex w-full items-center justify-center overflow-hidden bg-[#e7f3df] px-4 py-2.5">
<div className="absolute inset-0 -z-[1]"> <div className="text-black text-center text-sm font-medium">
<Image
src={launchWeekTwoImage}
className="h-full w-full object-cover"
alt="Launch Week 2"
/>
</div>
<div className="text-background text-center text-sm text-white">
Claim your documenso public profile username now!{' '} Claim your documenso public profile username now!{' '}
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span> <span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block"> <div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">

View File

@ -55,6 +55,7 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
cursor={{ fill: 'hsl(var(--primary) / 10%)' }} cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/> />
<Bar <Bar
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
dataKey={metricKey as string} dataKey={metricKey as string}
maxBarSize={60} maxBarSize={60}
fill="hsl(var(--primary))" fill="hsl(var(--primary))"

View File

@ -13,6 +13,7 @@ export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => { export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => {
const formattedData = data.map((item) => ({ const formattedData = data.map((item) => ({
amount: Number(item.amount), amount: Number(item.amount),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
date: formatMonth(item.date as string), date: formatMonth(item.date as string),
})); }));

View File

@ -1,4 +1,4 @@
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';

View File

@ -3,11 +3,11 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; import type { GetCompletedDocumentsMonthlyResult } from '@documenso/lib/server-only/user/get-monthly-completed-document';
export type MonthlyCompletedDocumentsChartProps = { export type MonthlyCompletedDocumentsChartProps = {
className?: string; className?: string;
data: GetUserMonthlyGrowthResult; data: GetCompletedDocumentsMonthlyResult;
}; };
export const MonthlyCompletedDocumentsChart = ({ export const MonthlyCompletedDocumentsChart = ({

View File

@ -247,8 +247,8 @@ export default async function OpenPage() {
<BarMetric<EarlyAdoptersType> <BarMetric<EarlyAdoptersType>
data={EARLY_ADOPTERS_DATA} data={EARLY_ADOPTERS_DATA}
metricKey="earlyAdopters" metricKey="earlyAdopters"
title="Early Adopters" title="Total Customers"
label="Early Adopters" label="Total Customers"
className="col-span-12 lg:col-span-6" className="col-span-12 lg:col-span-6"
extraInfo={<OpenPageTooltip />} extraInfo={<OpenPageTooltip />}
/> />

View File

@ -1,4 +1,4 @@
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { import {

View File

@ -29,7 +29,7 @@ export function OpenPageTooltip() {
</svg> </svg>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Active Subscriptions.</p> <p>Customers with an Active Subscriptions.</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>

View File

@ -3,11 +3,11 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; import type { GetCompletedDocumentsMonthlyResult } from '@documenso/lib/server-only/user/get-monthly-completed-document';
export type TotalSignedDocumentsChartProps = { export type TotalSignedDocumentsChartProps = {
className?: string; className?: string;
data: GetUserMonthlyGrowthResult; data: GetCompletedDocumentsMonthlyResult;
}; };
export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocumentsChartProps) => { export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocumentsChartProps) => {

View File

@ -2,13 +2,14 @@
import Link from 'next/link'; import Link from 'next/link';
import { Variants, motion } from 'framer-motion'; import type { Variants } from 'framer-motion';
import { motion } from 'framer-motion';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card'; import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
import { TOSSFriendsSchema } from './schema'; import type { TOSSFriendsSchema } from './schema';
const ContainerVariants: Variants = { const ContainerVariants: Variants = {
initial: { initial: {

View File

@ -9,6 +9,7 @@ import {
} from '@documenso/ui/primitives/accordion'; } from '@documenso/ui/primitives/accordion';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Enterprise } from '~/components/(marketing)/enterprise';
import { PricingTable } from '~/components/(marketing)/pricing-table'; import { PricingTable } from '~/components/(marketing)/pricing-table';
export const metadata: Metadata = { export const metadata: Metadata = {
@ -42,6 +43,10 @@ export default function PricingPage() {
<PricingTable /> <PricingTable />
</div> </div>
<div className="mt-12">
<Enterprise />
</div>
<div className="mx-auto mt-36 max-w-2xl"> <div className="mx-auto mt-36 max-w-2xl">
<h2 className="text-center text-2xl font-semibold"> <h2 className="text-center text-2xl font-semibold">
None of these work for you? Try self-hosting! None of these work for you? Try self-hosting!

View File

@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64'; import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentDataType, Prisma } from '@documenso/prisma/client'; import { DocumentDataType, Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -115,7 +115,7 @@ export const SinglePlayerClient = () => {
} }
try { try {
const putFileData = await putFile(uploadedFile.file); const putFileData = await putPdfFile(uploadedFile.file);
const documentToken = await createSinglePlayerDocument({ const documentToken = await createSinglePlayerDocument({
documentData: { documentData: {
@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
expired: null, expired: null,
signedAt: null, signedAt: null,
readStatus: 'OPENED', readStatus: 'OPENED',
documentDeletedAt: null,
signingStatus: 'NOT_SIGNED', signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT', sendStatus: 'NOT_SENT',
role: 'SIGNER', role: 'SIGNER',
@ -247,6 +248,7 @@ export const SinglePlayerClient = () => {
recipients={uploadedFile ? [placeholderRecipient] : []} recipients={uploadedFile ? [placeholderRecipient] : []}
fields={fields} fields={fields}
onSubmit={onFieldsSubmit} onSubmit={onFieldsSubmit}
canGoBack={true}
isDocumentPdfLoaded={true} isDocumentPdfLoaded={true}
/> />
</fieldset> </fieldset>

View File

@ -2,6 +2,7 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google'; import { Caveat, Inter } from 'next/font/google';
import { AxiomWebVitals } from 'next-axiom';
import { PublicEnvScript } from 'next-runtime-env'; import { PublicEnvScript } from 'next-runtime-env';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag'; import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
@ -67,6 +68,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<PublicEnvScript /> <PublicEnvScript />
</head> </head>
<AxiomWebVitals />
<Suspense> <Suspense>
<PostHogPageview /> <PostHogPageview />
</Suspense> </Suspense>

View File

@ -1,4 +1,4 @@
import { MetadataRoute } from 'next'; import type { MetadataRoute } from 'next';
import { getBaseUrl } from '@documenso/lib/universal/get-base-url'; import { getBaseUrl } from '@documenso/lib/universal/get-base-url';

View File

@ -1,4 +1,4 @@
import { MetadataRoute } from 'next'; import type { MetadataRoute } from 'next';
import { allBlogPosts, allGenericPages } from 'contentlayer/generated'; import { allBlogPosts, allGenericPages } from 'contentlayer/generated';

View File

@ -34,17 +34,18 @@ export const Callout = ({ starCount }: CalloutProps) => {
return ( return (
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4"> <div className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4">
<Link href="https://app.documenso.com/signup?utm_source=marketing-callout">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="rounded-full bg-transparent backdrop-blur-sm" className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
> >
Claim Early Adopter Plan Try our Free Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium"> <span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
$30/mo No Credit Card required
</span> </span>
</Button> </Button>
</Link>
<Link <Link
href="https://github.com/documenso/documenso" href="https://github.com/documenso/documenso"

View File

@ -0,0 +1,261 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Autoplay from 'embla-carousel-autoplay';
import useEmblaCarousel from 'embla-carousel-react';
import { useTheme } from 'next-themes';
import { Card } from '@documenso/ui/primitives/card';
import { Progress } from '@documenso/ui/primitives/progress';
import { Slide } from './slide';
const SLIDES = [
{
label: 'Signing Process',
type: 'video',
srcLight: 'https://github.com/documenso/design/raw/main/marketing/signing.webm',
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/signing.webm',
},
{
label: 'Teams',
type: 'video',
srcLight: 'https://github.com/documenso/design/raw/main/marketing/teams.webm',
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/teams.webm',
},
{
label: 'Zapier',
type: 'video',
srcLight: 'https://github.com/documenso/design/raw/main/marketing/zapier.webm',
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/zapier.webm',
},
{
label: 'Webhooks',
type: 'video',
srcLight: 'https://github.com/documenso/design/raw/main/marketing/webhooks.webm',
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/webhooks.webm',
},
{
label: 'API',
type: 'video',
srcLight: 'https://github.com/documenso/design/raw/main/marketing/api.webm',
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/api.webm',
},
{
label: 'Profile',
type: 'video',
srcLight: 'https://github.com/documenso/design/raw/main/marketing/profile_teaser.webm',
srcDark: 'https://github.com/documenso/design/raw/main/marketing/dark/profile_teaser.webm',
},
];
export const Carousel = () => {
const slides = SLIDES;
const [_isPlaying, setIsPlaying] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [progress, setProgress] = useState(0);
const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
const [autoplayDelay, setAutoplayDelay] = useState<number[]>([]);
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [
Autoplay({ playOnInit: true, delay: autoplayDelay[selectedIndex] || 5000 }),
]);
const [emblaThumbsRef, emblaThumbsApi] = useEmblaCarousel(
{
loop: true,
containScroll: 'keepSnaps',
dragFree: true,
},
[Autoplay({ playOnInit: true, delay: autoplayDelay[selectedIndex] || 5000 })],
);
const onThumbClick = useCallback(
(index: number) => {
if (!emblaApi || !emblaThumbsApi) return;
emblaApi.scrollTo(index);
},
[emblaApi, emblaThumbsApi],
);
const onSelect = useCallback(() => {
if (!emblaApi || !emblaThumbsApi) return;
setSelectedIndex(emblaApi.selectedScrollSnap());
emblaThumbsApi.scrollTo(emblaApi.selectedScrollSnap());
resetProgress();
const autoplay = emblaApi.plugins()?.autoplay;
if (autoplay) {
autoplay.reset();
}
}, [emblaApi, emblaThumbsApi, setSelectedIndex]);
const resetProgress = useCallback(() => {
setProgress(0);
}, []);
useEffect(() => {
const setVideoDurations = async () => {
const durations = await Promise.all(
videoRefs.current.map(
async (video) =>
new Promise<number>((resolve) => {
if (video) {
video.onloadedmetadata = () => resolve(video.duration * 1000);
} else {
resolve(5000);
}
}),
),
);
setAutoplayDelay(durations);
};
void setVideoDurations();
}, [slides, mounted, resolvedTheme]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const video = entry.target as HTMLVideoElement;
video
.play()
.catch((error) => console.log('Error attempting to play the video:', error));
} else {
const video = entry.target as HTMLVideoElement;
video.pause();
}
});
},
{
threshold: 0.5,
},
);
videoRefs.current.forEach((video) => {
if (video) {
observer.observe(video);
}
});
return () => {
observer.disconnect();
};
}, [slides, mounted, resolvedTheme]);
useEffect(() => {
if (!emblaApi) return;
onSelect();
emblaApi.on('select', onSelect).on('reInit', onSelect);
}, [emblaApi, onSelect, mounted, resolvedTheme]);
useEffect(() => {
const autoplay = emblaApi?.plugins()?.autoplay;
if (!autoplay) return;
setIsPlaying(autoplay.isPlaying());
emblaApi
.on('autoplay:play', () => setIsPlaying(true))
.on('autoplay:stop', () => setIsPlaying(false))
.on('reInit', () => setIsPlaying(autoplay.isPlaying()));
}, [emblaApi, mounted, resolvedTheme]);
useEffect(() => {
if (autoplayDelay[selectedIndex] === undefined) return;
const updateInterval = 50;
const increment = 100 / (autoplayDelay[selectedIndex] / updateInterval);
let progressValue = 0;
const timer = setInterval(() => {
setProgress((prevProgress) => {
progressValue = prevProgress + increment;
if (progressValue >= 100) {
clearInterval(timer);
if (emblaApi) {
emblaApi.scrollNext();
}
return 100;
}
return progressValue;
});
}, updateInterval);
return () => clearInterval(timer);
}, [selectedIndex, autoplayDelay, emblaApi, mounted, resolvedTheme]);
useEffect(() => {
if (!emblaApi) return;
const resetCarousel = () => {
emblaApi.reInit();
emblaApi.scrollTo(0);
};
resetCarousel();
}, [emblaApi, autoplayDelay, mounted, resolvedTheme]);
// Ensure the component renders only after mounting to avoid theme issues
if (!mounted) return null;
return (
<>
<Card className="mx-auto mt-12 w-full max-w-4xl rounded-2xl p-1 before:rounded-2xl" gradient>
<div className="overflow-hidden rounded-xl" ref={emblaRef}>
<div className="flex touch-pan-y rounded-xl">
{slides.map((slide, index) => (
<div className="min-w-[10rem] flex-none basis-full rounded-xl" key={index}>
{slide.type === 'video' && (
<video
key={`${resolvedTheme}-${index}`}
ref={(el) => (videoRefs.current[index] = el)}
muted
loop
className="h-auto w-full rounded-xl"
>
<source
src={resolvedTheme === 'dark' ? slide.srcDark : slide.srcLight}
type="video/webm"
/>
Your browser does not support the video tag.
</video>
)}
</div>
))}
</div>
</div>
<div className="dark:bg-background absolute bottom-2 right-2 flex w-[5%] flex-col items-center space-y-1 rounded-lg bg-white p-1.5">
<span className="text-foreground dark:text-muted-foreground text-xs">
{selectedIndex + 1}/{slides.length}
</span>
<Progress value={progress} className="h-1" />
</div>
</Card>
<div className="mx-auto mt-12 max-w-4xl px-2">
<div className="mt-2 flex justify-between" ref={emblaThumbsRef}>
{slides.map((slide, index) => (
<Slide
key={index}
onClick={() => onThumbClick(index)}
selected={index === selectedIndex}
index={index}
label={slide.label}
/>
))}
</div>
</div>
</>
);
};

View File

@ -0,0 +1,36 @@
'use client';
import Link from 'next/link';
import { usePlausible } from 'next-plausible';
import { Button } from '@documenso/ui/primitives/button';
export const Enterprise = () => {
const event = usePlausible();
return (
<div className="mx-auto mt-36 max-w-2xl">
<h2 className="text-center text-2xl font-semibold">
Enterprise Compliance, License or Technical Needs?
</h2>
<p className="text-muted-foreground mt-4 text-center leading-relaxed">
Our Enterprise License is great large organizations looking to switch to Documenso for all
their signing needs. It's availible for our cloud offering as well as self-hosted setups and
offer a wide range of compliance and Adminstration Features.
</p>
<div className="mt-4 flex justify-center">
<Link
href="https://dub.sh/enterprise"
target="_blank"
className="mt-6"
onClick={() => event('enterprise-contact')}
>
<Button className="rounded-full text-base">Contact Us</Button>
</Link>
</div>
</div>
);
};

View File

@ -13,6 +13,8 @@ import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher'; import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
// import { StatusWidgetContainer } from './status-widget-container';
export type FooterProps = HTMLAttributes<HTMLDivElement>; export type FooterProps = HTMLAttributes<HTMLDivElement>;
const SOCIAL_LINKS = [ const SOCIAL_LINKS = [
@ -62,6 +64,10 @@ export const Footer = ({ className, ...props }: FooterProps) => {
</Link> </Link>
))} ))}
</div> </div>
{/* <div className="mt-6">
<StatusWidgetContainer />
</div> */}
</div> </div>
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8"> <div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">

View File

@ -14,7 +14,7 @@ import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-fl
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Widget } from './widget'; import { Carousel } from './carousel';
export type HeroProps = { export type HeroProps = {
className?: string; className?: string;
@ -50,6 +50,21 @@ const HeroTitleVariants: Variants = {
}, },
}; };
const HeroCarouselVariants: Variants = {
initial: {
opacity: 0,
y: 60,
},
animate: {
opacity: 1,
y: 0,
transition: {
delay: 0.5,
duration: 0.8,
},
},
};
export const Hero = ({ className, ...props }: HeroProps) => { export const Hero = ({ className, ...props }: HeroProps) => {
const event = usePlausible(); const event = usePlausible();
@ -57,23 +72,6 @@ export const Hero = ({ className, ...props }: HeroProps) => {
const heroMarketingCTA = getFlag('marketing_landing_hero_cta'); const heroMarketingCTA = getFlag('marketing_landing_hero_cta');
const onSignUpClick = () => {
const el = document.getElementById('email');
if (el) {
const { top } = el.getBoundingClientRect();
window.scrollTo({
top: top - 120,
behavior: 'smooth',
});
requestAnimationFrame(() => {
el.focus();
});
}
};
return ( return (
<motion.div className={cn('relative', className)} {...props}> <motion.div className={cn('relative', className)} {...props}>
<div className="absolute -inset-24 -z-10"> <div className="absolute -inset-24 -z-10">
@ -96,7 +94,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
variants={HeroTitleVariants} variants={HeroTitleVariants}
initial="initial" initial="initial"
animate="animate" animate="animate"
className="text-center text-4xl font-bold leading-tight tracking-tight lg:text-[64px]" className="text-center text-4xl font-bold leading-tight tracking-tight md:text-[48px] lg:text-[64px]"
> >
Document signing, Document signing,
<span className="block" /> finally open source. <span className="block" /> finally open source.
@ -108,18 +106,18 @@ export const Hero = ({ className, ...props }: HeroProps) => {
animate="animate" animate="animate"
className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4" className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4"
> >
<Link href="https://app.documenso.com/signup?utm_source=marketing-hero">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="rounded-full bg-transparent backdrop-blur-sm" className="rounded-full bg-transparent backdrop-blur-sm"
onClick={onSignUpClick}
> >
Claim Early Adopter Plan Try our Free Plan
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium"> <span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs font-medium">
$30/mo No Credit Card required
</span> </span>
</Button> </Button>
</Link>
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}> <Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm"> <Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
<LuGithub className="mr-2 h-5 w-5" /> <LuGithub className="mr-2 h-5 w-5" />
@ -170,74 +168,11 @@ export const Hero = ({ className, ...props }: HeroProps) => {
<motion.div <motion.div
className="mt-12" className="mt-12"
variants={{ variants={HeroCarouselVariants}
initial: {
scale: 0.2,
opacity: 0,
},
animate: {
scale: 1,
opacity: 1,
transition: {
ease: 'easeInOut',
delay: 0.5,
duration: 0.8,
},
},
}}
initial="initial" initial="initial"
animate="animate" animate="animate"
> >
<Widget className="mt-12"> <Carousel />
<strong>Documenso Supporter Pledge</strong>
<p className="w-full max-w-[70ch]">
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.
</p>
<p className="w-full max-w-[70ch]">
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.
</p>
<p className="w-full max-w-[70ch]">
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.
</p>
<p className="w-full max-w-[70ch]">
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.
</p>
<p className="w-full max-w-[70ch]">
Today we invite you to join us on this journey: By signing this mission statement you
signal your support of Documenso's mission{' '}
<span className="bg-primary text-black">
(in a non-legally binding, but heartfelt way)
</span>{' '}
and lock in the early adopter plan for forever, including everything we build this
year.
</p>
<div className="flex h-24 items-center">
<p className={cn('text-5xl [font-family:var(--font-caveat)]')}>Timur & Lucas</p>
</div>
<div>
<strong>Timur Ercan & Lucas Smith</strong>
<p className="mt-1">Co-Founders, Documenso</p>
</div>
</Widget>
</motion.div> </motion.div>
</div> </div>
</motion.div> </motion.div>

View File

@ -1,4 +1,4 @@
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import Image from 'next/image'; import Image from 'next/image';

View File

@ -58,7 +58,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
> >
Yearly Yearly
<div className="bg-muted text-foreground block rounded-full px-2 py-0.5 text-xs"> <div className="bg-muted text-foreground block rounded-full px-2 py-0.5 text-xs">
Save $60 Save $60 or $100
</div> </div>
{period === 'YEARLY' && ( {period === 'YEARLY' && (
<motion.div <motion.div
@ -75,7 +75,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
data-plan="free" data-plan="free"
className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg" className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg"
> >
<p className="text-foreground text-4xl font-medium">Free Plan</p> <p className="text-foreground text-4xl font-medium">Free</p>
<p className="text-primary mt-2.5 text-xl font-medium">$0</p> <p className="text-primary mt-2.5 text-xl font-medium">$0</p>
<p className="text-foreground mt-4 max-w-[30ch] text-center"> <p className="text-foreground mt-4 max-w-[30ch] text-center">
@ -102,10 +102,10 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</div> </div>
<div <div
data-plan="early-adopter" data-plan="individual"
className="border-primary bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border-2 px-8 py-12 shadow-[0px_0px_0px_4px_#E3E3E380]" className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border-2 px-8 py-12 shadow-[0px_0px_0px_4px_#E3E3E380]"
> >
<p className="text-foreground text-4xl font-medium">Early Adopters</p> <p className="text-foreground text-4xl font-medium">Individual</p>
<div className="text-primary mt-2.5 text-xl font-medium"> <div className="text-primary mt-2.5 text-xl font-medium">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{period === 'MONTHLY' && <motion.div layoutId="pricing">$30</motion.div>} {period === 'MONTHLY' && <motion.div layoutId="pricing">$30</motion.div>}
@ -114,12 +114,12 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</div> </div>
<p className="text-foreground mt-4 max-w-[30ch] text-center"> <p className="text-foreground mt-4 max-w-[30ch] text-center">
For fast-growing companies that aim to scale across multiple teams. Everything you need for a great signing experience.
</p> </p>
<Button className="mt-6 rounded-full text-base" asChild> <Button className="mt-6 rounded-full text-base" asChild>
<Link <Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-early-adopter`} href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-individual-plan`}
target="_blank" target="_blank"
> >
Signup Now Signup Now
@ -127,51 +127,46 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</Button> </Button>
<div className="mt-8 flex w-full flex-col divide-y"> <div className="mt-8 flex w-full flex-col divide-y">
<p className="text-foreground py-4"> <p className="text-foreground py-4">Unlimited Documents per Month</p>
<a <p className="text-foreground py-4">API Accesss</p>
href="https://documen.so/early-adopters-pricing-page" <p className="text-foreground py-4">Email and Discord Support</p>
target="_blank" <p className="text-foreground py-4">Premium Profile Name</p>
rel="noreferrer"
>
Limited Time Offer: <span className="text-documenso-700">Read More</span>
</a>
</p>
<p className="text-foreground py-4">Unlimited Teams</p>
<p className="text-foreground py-4">Unlimited Users</p>
<p className="text-foreground py-4">Unlimited Documents per month</p>
<p className="text-foreground py-4">Includes all upcoming features</p>
<p className="text-foreground py-4">Email, Discord and Slack assistance</p>
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
</div> </div>
<div <div
data-plan="enterprise" data-plan="teams"
className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg" className="border-primary bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg"
> >
<p className="text-foreground text-4xl font-medium">Enterprise</p> <p className="text-foreground text-4xl font-medium">Teams</p>
<p className="text-primary mt-2.5 text-xl font-medium">Pricing on request</p> <div className="text-primary mt-2.5 text-xl font-medium">
<AnimatePresence mode="wait">
{period === 'MONTHLY' && <motion.div layoutId="pricingTeams">$50</motion.div>}
{period === 'YEARLY' && <motion.div layoutId="pricingTeams">$500</motion.div>}
</AnimatePresence>
</div>
<p className="text-foreground mt-4 max-w-[30ch] text-center"> <p className="text-foreground mt-4 max-w-[30ch] text-center">
For large organizations that need extra flexibility and control. For companies looking to scale across multiple teams.
</p> </p>
<Button className="mt-6 rounded-full text-base" asChild>
<Link <Link
href="https://dub.sh/enterprise" href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup?utm_source=pricing-teams-plan`}
target="_blank" target="_blank"
className="mt-6"
onClick={() => event('enterprise-contact')}
> >
<Button className="rounded-full text-base">Contact Us</Button> Signup Now
</Link> </Link>
</Button>
<div className="mt-8 flex w-full flex-col divide-y"> <div className="mt-8 flex w-full flex-col divide-y">
<p className="text-foreground py-4 font-medium">Everything in Early Adopters, plus:</p> <p className="text-foreground py-4">Unlimited Documents per Month</p>
<p className="text-foreground py-4">Custom Subdomain</p> <p className="text-foreground py-4">API Accesss</p>
<p className="text-foreground py-4">Compliance Check</p> <p className="text-foreground py-4">Email and Discord Support</p>
<p className="text-foreground py-4">Guaranteed Uptime</p> <p className="text-foreground py-4 font-medium">Team Inbox</p>
<p className="text-foreground py-4">Reporting & Analysis</p> <p className="text-foreground py-4">5 Users Included</p>
<p className="text-foreground py-4">24/7 Support</p> <p className="text-foreground py-4">Add More Users for $10/ mo.</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import Image from 'next/image'; import Image from 'next/image';
@ -51,7 +51,7 @@ export const ShareConnectPaidWidgetBento = ({
<Card className="col-span-2 lg:col-span-1" spotlight> <Card className="col-span-2 lg:col-span-1" spotlight>
<CardContent className="grid grid-cols-1 gap-8 p-6"> <CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="text-foreground/80 leading-relaxed"> <p className="text-foreground/80 leading-relaxed">
<strong className="block">Connections (Soon).</strong> <strong className="block">Connections</strong>
Create connections and automations with Zapier and more to integrate with your Create connections and automations with Zapier and more to integrate with your
favorite tools. favorite tools.
</p> </p>
@ -70,7 +70,7 @@ export const ShareConnectPaidWidgetBento = ({
<CardContent className="grid grid-cols-1 gap-8 p-6"> <CardContent className="grid grid-cols-1 gap-8 p-6">
<p className="text-foreground/80 leading-relaxed"> <p className="text-foreground/80 leading-relaxed">
<strong className="block">Get paid (Soon).</strong> <strong className="block">Get paid (Soon).</strong>
Integrated payments with stripe so you dont have to worry about getting paid. Integrated payments with Stripe so you dont have to worry about getting paid.
</p> </p>
<div className="flex items-center justify-center p-8"> <div className="flex items-center justify-center p-8">

View File

@ -0,0 +1,29 @@
import React from 'react';
import { cn } from '@documenso/ui/lib/utils';
type SlideProps = {
selected: boolean;
index: number;
onClick: () => void;
label: string;
};
export const Slide: React.FC<SlideProps> = (props) => {
const { selected, label, onClick } = props;
return (
<button
onClick={onClick}
type="button"
className={cn(
'text-muted-foreground dark:text-muted-foreground/60 border-b-2 border-transparent py-4',
{
'border-primary text-foreground dark:text-muted-foreground border-b-2': selected,
},
)}
>
{label}
</button>
);
};

View File

@ -0,0 +1,21 @@
// https://github.com/documenso/documenso/pull/1044/files#r1538258462
import { Suspense } from 'react';
import { StatusWidget } from './status-widget';
export function StatusWidgetContainer() {
return (
<Suspense fallback={<StatusWidgetFallback />}>
<StatusWidget slug="documenso-status" />
</Suspense>
);
}
function StatusWidgetFallback() {
return (
<div className="border-border inline-flex max-w-fit items-center justify-between space-x-2 rounded-md border border-gray-200 px-2 py-2 pr-3 text-sm">
<span className="bg-muted h-2 w-36 animate-pulse rounded-md" />
<span className="bg-muted relative inline-flex h-2 w-2 rounded-full" />
</div>
);
}

View File

@ -0,0 +1,73 @@
import { memo, use } from 'react';
import { type Status, getStatus } from '@openstatus/react';
import { cn } from '@documenso/ui/lib/utils';
const getStatusLevel = (level: Status) => {
return {
operational: {
label: 'Operational',
color: 'bg-green-500',
color2: 'bg-green-400',
},
degraded_performance: {
label: 'Degraded Performance',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
partial_outage: {
label: 'Partial Outage',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
major_outage: {
label: 'Major Outage',
color: 'bg-red-500',
color2: 'bg-red-400',
},
unknown: {
label: 'Unknown',
color: 'bg-gray-500',
color2: 'bg-gray-400',
},
incident: {
label: 'Incident',
color: 'bg-yellow-500',
color2: 'bg-yellow-400',
},
under_maintenance: {
label: 'Under Maintenance',
color: 'bg-gray-500',
color2: 'bg-gray-400',
},
}[level];
};
export const StatusWidget = memo(function StatusWidget({ slug }: { slug: string }) {
const { status } = use(getStatus(slug));
const level = getStatusLevel(status);
return (
<a
className="border-border inline-flex max-w-fit items-center justify-between gap-2 space-x-2 rounded-md border border-gray-200 px-3 py-1 text-sm"
href="https://status.documenso.com"
target="_blank"
rel="noreferrer"
>
<div>
<p className="text-sm">{level.label}</p>
</div>
<span className="relative ml-auto flex h-1.5 w-1.5">
<span
className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
level.color2,
)}
/>
<span className={cn('relative inline-flex h-1.5 w-1.5 rounded-full', level.color)} />
</span>
</a>
);
});

View File

@ -1,421 +0,0 @@
'use client';
import type { HTMLAttributes, KeyboardEvent } from 'react';
import { 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 { env } from 'next-runtime-env';
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 { STEP } from '../constants';
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().trim().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().trim().min(1),
}),
]),
);
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
type StepKeys = keyof typeof STEP;
type StepValues = (typeof STEP)[StepKeys];
export type WidgetProps = HTMLAttributes<HTMLDivElement>;
export const Widget = ({ className, children, ...props }: WidgetProps) => {
const { toast } = useToast();
const event = usePlausible();
const [step, setStep] = useState<StepValues>(STEP.EMAIL);
const [showSigningDialog, setShowSigningDialog] = useState(false);
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
const {
control,
register,
handleSubmit,
setValue,
trigger,
watch,
formState: { errors, isSubmitting, isValid },
} = useForm<TWidgetFormSchema>({
mode: 'onChange',
defaultValues: {
email: '',
name: '',
signatureDataUrl: null,
signatureText: '',
},
resolver: zodResolver(ZWidgetFormSchema),
});
const signatureDataUrl = watch('signatureDataUrl');
const signatureText = watch('signatureText');
const stepsRemaining = useMemo(() => {
if (step === STEP.NAME) {
return 2;
}
if (step === STEP.EMAIL) {
return 3;
}
return 1;
}, [step]);
const onNextStepClick = () => {
if (step === STEP.EMAIL) {
setStep(STEP.NAME);
setTimeout(() => {
document.querySelector<HTMLElement>('#name')?.focus();
}, 0);
}
if (step === STEP.NAME) {
setStep(STEP.SIGN);
setTimeout(() => {
document.querySelector<HTMLElement>('#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<void>((resolve) => {
setTimeout(resolve, 1000);
});
const planId = env('NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID');
if (!planId) {
throw new Error('No plan ID found.');
}
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 (
<>
<Card
className={cn('mx-auto w-full max-w-4xl rounded-3xl before:rounded-3xl', className)}
gradient
{...props}
>
<div className="grid grid-cols-12 gap-y-8 overflow-hidden p-2 lg:gap-x-8">
<div className="text-muted-foreground col-span-12 flex flex-col gap-y-4 p-4 text-xs leading-relaxed lg:col-span-7">
{children}
</div>
<form
className="bg-foreground/5 col-span-12 flex flex-col rounded-2xl p-6 lg:col-span-5"
onSubmit={handleSubmit(onFormSubmit)}
>
<h3 className="text-xl font-semibold">Sign up to Early Adopter Plan</h3>
<p className="text-muted-foreground mt-2 text-xs">
with Timur Ercan & Lucas Smith from Documenso
</p>
<hr className="mb-6 mt-4" />
<AnimatePresence>
<motion.div key="email">
<label htmlFor="email" className="text-foreground font-medium ">
Whats your email?
</label>
<Controller
control={control}
name="email"
render={({ field }) => (
<div className="relative mt-2">
<Input
id="email"
type="email"
placeholder="your@example.com"
className="bg-background w-full pr-16"
disabled={isSubmitting}
onKeyDown={(e) =>
field.value !== '' &&
!errors.email?.message &&
onEnterPress(onNextStepClick)(e)
}
{...field}
/>
<div className="absolute inset-y-0 right-0 p-1.5">
<Button
type="button"
className="bg-primary h-full w-14 rounded"
disabled={!field.value || !!errors.email?.message}
onClick={() => step === STEP.EMAIL && onNextStepClick()}
>
Next
</Button>
</div>
</div>
)}
/>
<FormErrorMessage error={errors.email} className="mt-1" />
</motion.div>
{(step === STEP.NAME || step === STEP.SIGN) && (
<motion.div
key="name"
className="mt-4"
animate={{
opacity: 1,
transform: 'translateX(0)',
}}
initial={{
opacity: 0,
transform: 'translateX(-25%)',
}}
exit={{
opacity: 0,
transform: 'translateX(25%)',
}}
>
<label htmlFor="name" className="text-foreground font-medium ">
And your name?
</label>
<Controller
control={control}
name="name"
render={({ field }) => (
<div className="relative mt-2">
<Input
id="name"
type="text"
placeholder=""
className="bg-background w-full pr-16"
disabled={isSubmitting}
onKeyDown={(e) =>
field.value !== '' &&
!errors.name?.message &&
onEnterPress(onNextStepClick)(e)
}
{...field}
/>
<div className="absolute inset-y-0 right-0 p-1.5">
<Button
type="button"
className="bg-primary h-full w-14 rounded"
disabled={!field.value || !!errors.name?.message}
onClick={() => onNextStepClick()}
>
Next
</Button>
</div>
</div>
)}
/>
<FormErrorMessage error={errors.name} className="mt-1" />
</motion.div>
)}
</AnimatePresence>
<div className="mt-12 flex-1" />
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-xs">
{isValid ? 'Ready for Signing' : `${stepsRemaining} step(s) until signed`}
</p>
<p className="text-muted-foreground block text-xs md:hidden">Minimise contract</p>
</div>
<div className="bg-background relative mt-2.5 h-[2px] w-full">
<div
className={cn('bg-primary/60 absolute inset-y-0 left-0 duration-200', {
'w-1/3': stepsRemaining === 3,
'w-2/3': stepsRemaining === 2,
'w-11/12': stepsRemaining === 1,
'w-full': isValid,
})}
/>
</div>
<Card id="signature" className="mt-4" degrees={-140} gradient>
<CardContent
role="button"
className="relative cursor-pointer pt-6"
onClick={() => setShowSigningDialog(true)}
>
<div className="flex h-28 items-center justify-center pb-6">
{!signatureText && signatureDataUrl && (
<img
src={signatureDataUrl}
alt="user signature"
className="h-full dark:invert"
/>
)}
{signatureText && (
<p
className={cn(
'text-foreground text-4xl font-semibold [font-family:var(--font-caveat)]',
)}
>
{signatureText}
</p>
)}
</div>
<div
className="absolute inset-x-0 bottom-0 flex cursor-auto items-center justify-between px-4 pb-2"
onClick={(e) => e.stopPropagation()}
>
<Input
id="signatureText"
className="text-foreground placeholder:text-muted-foreground border-none p-0 text-sm focus-visible:ring-0"
placeholder="Draw or type name here"
disabled={isSubmitting}
{...register('signatureText', {
onChange: (e) => {
if (e.target.value !== '') {
setValue('signatureDataUrl', null);
}
},
})}
/>
<Button
type="submit"
className="disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted h-8"
disabled={!isValid || isSubmitting}
>
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
Sign
</Button>
</div>
</CardContent>
</Card>
</form>
</div>
</Card>
<Dialog open={showSigningDialog} onOpenChange={setShowSigningDialog}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Add your signature</DialogTitle>
</DialogHeader>
<DialogDescription>
By signing you signal your support of Documenso's mission in a <br></br>
<strong>non-legally binding, but heartfelt way</strong>. <br></br>
<br></br>You also unlock the option to purchase the early supporter plan including
everything we build this year for fixed price.
</DialogDescription>
<SignaturePad
disabled={isSubmitting}
className="aspect-video w-full rounded-md border"
defaultValue={signatureDataUrl || ''}
onChange={setDraftSignatureDataUrl}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowSigningDialog(false)}>
Cancel
</Button>
<Button onClick={() => onSignatureConfirmClick()}>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -1,5 +1,5 @@
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { FieldError } from 'react-hook-form'; import type { FieldError } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';

View File

@ -1,4 +1,4 @@
import { SVGAttributes } from 'react'; import type { SVGAttributes } from 'react';
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>; export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;

View File

@ -13,6 +13,7 @@ import { updateFile } from '@documenso/lib/universal/upload/update-file';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { import {
DocumentDataType, DocumentDataType,
DocumentSource,
DocumentStatus, DocumentStatus,
FieldType, FieldType,
ReadStatus, ReadStatus,
@ -104,6 +105,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const document = await prisma.document.create({ const document = await prisma.document.create({
data: { data: {
source: DocumentSource.DOCUMENT,
title: 'Documenso Supporter Pledge.pdf', title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED, status: DocumentStatus.COMPLETED,
userId: user.id, userId: user.id,

View File

@ -3,7 +3,7 @@
import * as React from 'react'; import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes'; import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { ThemeProviderProps } from 'next-themes/dist/types'; import type { ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>; return <NextThemesProvider {...props}>{children}</NextThemesProvider>;

View File

@ -2,6 +2,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { version } = require('./package.json'); const { version } = require('./package.json');
const { withAxiom } = require('next-axiom');
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`]; const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
@ -17,12 +18,16 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'), path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
); );
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
);
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const config = { const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined, output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
experimental: { experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'), outputFileTracingRoot: path.join(__dirname, '../../'),
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'], serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
serverActions: { serverActions: {
bodySizeLimit: '50mb', bodySizeLimit: '50mb',
}, },
@ -41,6 +46,7 @@ const config = {
APP_VERSION: version, APP_VERSION: version,
NEXT_PUBLIC_PROJECT: 'web', NEXT_PUBLIC_PROJECT: 'web',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`, FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
}, },
modularizeImports: { modularizeImports: {
'lucide-react': { 'lucide-react': {
@ -91,4 +97,4 @@ const config = {
}, },
}; };
module.exports = config; module.exports = withAxiom(config);

View File

@ -28,13 +28,16 @@
"cookie-es": "^1.0.0", "cookie-es": "^1.0.0",
"formidable": "^2.1.1", "formidable": "^2.1.1",
"framer-motion": "^10.12.8", "framer-motion": "^10.12.8",
"input-otp": "^1.2.4",
"lucide-react": "^0.279.0", "lucide-react": "^0.279.0",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "14.0.3", "next": "14.0.3",
"next-auth": "4.24.5", "next-auth": "4.24.5",
"next-axiom": "^1.1.1",
"next-plausible": "^3.10.1", "next-plausible": "^3.10.1",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"papaparse": "^5.4.1",
"perfect-freehand": "^1.2.0", "perfect-freehand": "^1.2.0",
"posthog-js": "^1.75.3", "posthog-js": "^1.75.3",
"posthog-node": "^3.1.1", "posthog-node": "^3.1.1",
@ -58,6 +61,7 @@
"@types/formidable": "^2.0.6", "@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1", "@types/luxon": "^3.3.1",
"@types/node": "20.1.0", "@types/node": "20.1.0",
"@types/papaparse": "^5.3.14",
"@types/react": "18.2.18", "@types/react": "18.2.18",
"@types/react-dom": "18.2.7", "@types/react-dom": "18.2.7",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",

View File

@ -12,5 +12,9 @@ declare namespace NodeJS {
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string; NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string; NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
NEXT_PRIVATE_OIDC_WELL_KNOWN: string;
NEXT_PRIVATE_OIDC_CLIENT_ID: string;
NEXT_PRIVATE_OIDC_CLIENT_SECRET: string;
} }
} }

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,8 @@
import Link from 'next/link'; import Link from 'next/link';
import { type Document, DocumentStatus } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { type Document, SigningStatus } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -17,9 +18,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminActionsProps = { export type AdminActionsProps = {
className?: string; className?: string;
document: Document; document: Document;
recipients: Recipient[];
}; };
export const AdminActions = ({ className, document }: AdminActionsProps) => { export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { mutate: resealDocument, isLoading: isResealDocumentLoading } = const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
@ -47,7 +49,9 @@ export const AdminActions = ({ className, document }: AdminActionsProps) => {
<Button <Button
variant="outline" variant="outline"
loading={isResealDocumentLoading} loading={isResealDocumentLoading}
disabled={document.status !== DocumentStatus.COMPLETED} disabled={recipients.some(
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
)}
onClick={() => resealDocument({ id: document.id })} onClick={() => resealDocument({ id: document.id })}
> >
Reseal document Reseal document

View File

@ -53,7 +53,7 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
<h2 className="text-lg font-semibold">Admin Actions</h2> <h2 className="text-lg font-semibold">Admin Actions</h2>
<AdminActions className="mt-2" document={document} /> <AdminActions className="mt-2" document={document} recipients={document.Recipient} />
<hr className="my-4" /> <hr className="my-4" />
<h2 className="text-lg font-semibold">Recipients</h2> <h2 className="text-lg font-semibold">Recipients</h2>

View File

@ -19,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const [{ users, totalPages }, individualPrices] = await Promise.all([ const [{ users, totalPages }, individualPrices] = await Promise.all([
search(searchString, page, perPage), search(searchString, page, perPage),
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY).catch(() => []), getPricesByPlan([STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY]).catch(() => []),
]); ]);
const individualPriceIds = individualPrices.map((price) => price.id); const individualPriceIds = individualPrices.map((price) => price.id);

View File

@ -4,13 +4,22 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react'; import {
Copy,
Download,
Edit,
Loader,
MoreHorizontal,
ScrollTextIcon,
Share,
Trash2,
} from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; import type { Document, Recipient, Team, TeamEmail, User } from '@documenso/prisma/client';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { import {
@ -32,7 +41,7 @@ export type DocumentPageViewDropdownProps = {
Recipient: Recipient[]; Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
team?: Pick<Team, 'id' | 'url'>; team?: Pick<Team, 'id' | 'url'> & { teamEmail: TeamEmail | null };
}; };
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => { export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
@ -50,9 +59,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
const isOwner = document.User.id === session.user.id; const isOwner = document.User.id === session.user.id;
const isDraft = document.status === DocumentStatus.DRAFT; const isDraft = document.status === DocumentStatus.DRAFT;
const isDeleted = document.deletedAt !== null;
const isComplete = document.status === DocumentStatus.COMPLETED; const isComplete = document.status === DocumentStatus.COMPLETED;
const isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && document.team?.url === team.url; const isCurrentTeamDocument = team && document.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url); const documentsPath = formatDocumentsPath(team?.url);
@ -106,12 +116,22 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem asChild>
<Link href={`${documentsPath}/${document.id}/logs`}>
<ScrollTextIcon className="mr-2 h-4 w-4" />
Audit Log
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}> <DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" /> <Copy className="mr-2 h-4 w-4" />
Duplicate Duplicate
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> <DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
@ -138,15 +158,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
/> />
</DropdownMenuContent> </DropdownMenuContent>
{isDocumentDeletable && (
<DeleteDocumentDialog <DeleteDocumentDialog
id={document.id} id={document.id}
status={document.status} status={document.status}
documentTitle={document.title} documentTitle={document.title}
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
canManageDocument={canManageDocument}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
/> />
)}
{isDuplicateDialogOpen && ( {isDuplicateDialogOpen && (
<DuplicateDocumentDialog <DuplicateDocumentDialog
id={document.id} id={document.id}

View File

@ -8,17 +8,20 @@ import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getCompletedFieldsForDocument } from '@documenso/lib/server-only/field/get-completed-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import type { Team } from '@documenso/prisma/client'; import type { Team, TeamEmail } from '@documenso/prisma/client';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentHistorySheet } from '~/components/document/document-history-sheet'; import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { import {
DocumentStatus as DocumentStatusComponent, DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP, FRIENDLY_STATUS_MAP,
@ -34,7 +37,7 @@ export type DocumentPageViewProps = {
params: { params: {
id: string; id: string;
}; };
team?: Team; team?: Team & { teamEmail: TeamEmail | null };
}; };
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => { export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
@ -83,11 +86,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
documentMeta.password = securePassword; documentMeta.password = securePassword;
} }
const recipients = await getRecipientsForDocument({ const [recipients, completedFields] = await Promise.all([
getRecipientsForDocument({
documentId, documentId,
teamId: team?.id, teamId: team?.id,
userId: user.id, userId: user.id,
}); }),
getCompletedFieldsForDocument({
documentId,
}),
]);
const documentWithRecipients = { const documentWithRecipients = {
...document, ...document,
@ -118,11 +126,17 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<div className="text-muted-foreground flex items-center"> <div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" /> <Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom"> <StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span> <span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip> </StackAvatarsWithTooltip>
</div> </div>
)} )}
{document.deletedAt && <Badge variant="destructive">Document deleted</Badge>}
</div> </div>
</div> </div>
@ -148,6 +162,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
</CardContent> </CardContent>
</Card> </Card>
{document.status === DocumentStatus.PENDING && (
<DocumentReadOnlyFields
fields={completedFields}
documentMeta={document.documentMeta || undefined}
/>
)}
<div className="col-span-12 lg:col-span-6 xl:col-span-5"> <div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6"> <div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6"> <section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">

View File

@ -332,6 +332,7 @@ export const EditDocumentForm = ({
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onAddSettingsFormSubmit} onSubmit={onAddSettingsFormSubmit}
/> />
<AddSignersFormPartial <AddSignersFormPartial
key={recipients.length} key={recipients.length}
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}

View File

@ -36,11 +36,6 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
const { user } = await getRequiredServerComponentSession(); const { user } = await getRequiredServerComponentSession();
const isDocumentEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
const document = await getDocumentWithDetailsById({ const document = await getDocumentWithDetailsById({
id: documentId, id: documentId,
userId: user.id, userId: user.id,
@ -74,6 +69,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
documentMeta.password = securePassword; documentMeta.password = securePassword;
} }
const isDocumentEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
return ( return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8"> <div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80"> <Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
@ -92,7 +92,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
<div className="text-muted-foreground flex items-center"> <div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" /> <Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom"> <StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span> <span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip> </StackAvatarsWithTooltip>
</div> </div>
@ -100,7 +104,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
</div> </div>
<EditDocumentForm <EditDocumentForm
className="mt-8" className="mt-6"
initialDocument={document} initialDocument={document}
documentRootPath={documentRootPath} documentRootPath={documentRootPath}
isDocumentEnterprise={isDocumentEnterprise} isDocumentEnterprise={isDocumentEnterprise}

View File

@ -2,6 +2,8 @@ import Link from 'next/link';
import { ChevronLeft, Loader } from 'lucide-react'; import { ChevronLeft, Loader } from 'lucide-react';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
export default function Loading() { export default function Loading() {
return ( return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8"> <div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
@ -13,7 +15,12 @@ export default function Loading() {
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl"> <h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
Loading Document... Loading Document...
</h1> </h1>
<div className="mt-8 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
<div className="flex h-10 items-center">
<Skeleton className="my-6 h-4 w-24 rounded-2xl" />
</div>
<div className="mt-4 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7"> <div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center"> <div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
<Loader className="text-documenso h-12 w-12 animate-spin" /> <Loader className="text-documenso h-12 w-12 animate-spin" />

View File

@ -1,19 +1,25 @@
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { ChevronLeft, DownloadIcon } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { DateTime } from 'luxon';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Recipient, Team } from '@documenso/prisma/client'; import type { Recipient, Team } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { Card } from '@documenso/ui/primitives/card'; import { Card } from '@documenso/ui/primitives/card';
import { FRIENDLY_STATUS_MAP } from '~/components/formatter/document-status'; import {
DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP,
} from '~/components/formatter/document-status';
import { DocumentLogsDataTable } from './document-logs-data-table'; import { DocumentLogsDataTable } from './document-logs-data-table';
import { DownloadAuditLogButton } from './download-audit-log-button';
import { DownloadCertificateButton } from './download-certificate-button';
export type DocumentLogsPageViewProps = { export type DocumentLogsPageViewProps = {
params: { params: {
@ -23,6 +29,8 @@ export type DocumentLogsPageViewProps = {
}; };
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => { export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
const locale = getLocale();
const { id } = params; const { id } = params;
const documentId = Number(id); const documentId = Number(id);
@ -67,15 +75,21 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
}, },
{ {
description: 'Created by', description: 'Created by',
value: document.User.name ?? document.User.email, value: document.User.name
? `${document.User.name} (${document.User.email})`
: document.User.email,
}, },
{ {
description: 'Date created', description: 'Date created',
value: document.createdAt.toISOString(), value: DateTime.fromJSDate(document.createdAt)
.setLocale(locale)
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
}, },
{ {
description: 'Last updated', description: 'Last updated',
value: document.updatedAt.toISOString(), value: DateTime.fromJSDate(document.updatedAt)
.setLocale(locale)
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
}, },
{ {
description: 'Time zone', description: 'Time zone',
@ -90,7 +104,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
text = `${recipient.name} (${recipient.email})`; text = `${recipient.name} (${recipient.email})`;
} }
return `${text} - ${recipient.role}`; return `[${recipient.role}] ${text}`;
}; };
return ( return (
@ -104,20 +118,28 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
</Link> </Link>
<div className="flex flex-col justify-between sm:flex-row"> <div className="flex flex-col justify-between sm:flex-row">
<div>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}> <h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title} {document.title}
</h1> </h1>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end"> <div className="mt-2.5 flex items-center gap-x-6">
<Button variant="outline" className="mr-2 w-full sm:w-auto"> <DocumentStatusComponent
<DownloadIcon className="mr-1.5 h-4 w-4" /> inheritColor
Download certificate status={document.status}
</Button> className="text-muted-foreground"
/>
</div>
</div>
<Button className="w-full sm:w-auto"> <div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<DownloadIcon className="mr-1.5 h-4 w-4" /> <DownloadCertificateButton
Download PDF className="mr-2"
</Button> documentId={document.id}
documentStatus={document.status}
/>
<DownloadAuditLogButton documentId={document.id} />
</div> </div>
</div> </div>

View File

@ -0,0 +1,74 @@
'use client';
import { DownloadIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadAuditLogButtonProps = {
className?: string;
documentId: number;
};
export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => {
const { toast } = useToast();
const { mutateAsync: downloadAuditLogs, isLoading } =
trpc.document.downloadAuditLogs.useMutation();
const onDownloadAuditLogsClick = async () => {
try {
const { url } = await downloadAuditLogs({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
toast({
title: 'Something went wrong',
description: 'Sorry, we were unable to download the audit logs. Please try again later.',
variant: 'destructive',
});
}
};
return (
<Button
className={cn('w-full sm:w-auto', className)}
loading={isLoading}
onClick={() => void onDownloadAuditLogsClick()}
>
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
Download Audit Logs
</Button>
);
};

View File

@ -0,0 +1,82 @@
'use client';
import { DownloadIcon } from 'lucide-react';
import { DocumentStatus } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadCertificateButtonProps = {
className?: string;
documentId: number;
documentStatus: DocumentStatus;
};
export const DownloadCertificateButton = ({
className,
documentId,
documentStatus,
}: DownloadCertificateButtonProps) => {
const { toast } = useToast();
const { mutateAsync: downloadCertificate, isLoading } =
trpc.document.downloadCertificate.useMutation();
const onDownloadCertificatesClick = async () => {
try {
const { url } = await downloadCertificate({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
toast({
title: 'Something went wrong',
description: 'Sorry, we were unable to download the certificate. Please try again later.',
variant: 'destructive',
});
}
};
return (
<Button
className={cn('w-full sm:w-auto', className)}
loading={isLoading}
variant="outline"
disabled={documentStatus !== DocumentStatus.COMPLETED}
onClick={() => void onDownloadCertificatesClick()}
>
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
Download Certificate
</Button>
);
};

View File

@ -15,7 +15,6 @@ import {
Pencil, Pencil,
Share, Share,
Trash2, Trash2,
XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
@ -45,7 +44,7 @@ export type DataTableActionDropdownProps = {
Recipient: Recipient[]; Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
team?: Pick<Team, 'id' | 'url'>; team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
}; };
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => { export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
@ -67,8 +66,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
// const isPending = row.status === DocumentStatus.PENDING; // const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && row.team?.url === team.url; const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url); const documentsPath = formatDocumentsPath(team?.url);
@ -107,14 +106,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger data-testid="document-table-action-btn">
<MoreHorizontal className="text-muted-foreground h-5 w-5" /> <MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount> <DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel> <DropdownMenuLabel>Action</DropdownMenuLabel>
{recipient && recipient?.role !== RecipientRole.CC && ( {!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild> <DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}> <Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && ( {recipient?.role === RecipientRole.VIEWER && (
@ -141,7 +140,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild> <DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
<Link href={`${documentsPath}/${row.id}/edit`}> <Link href={`${documentsPath}/${row.id}/edit`}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Edit
@ -158,14 +157,18 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
Duplicate Duplicate
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem disabled> {/* No point displaying this if there's no functionality. */}
{/* <DropdownMenuItem disabled>
<XCircle className="mr-2 h-4 w-4" /> <XCircle className="mr-2 h-4 w-4" />
Void Void
</DropdownMenuItem> </DropdownMenuItem> */}
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> <DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail)}
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete {canManageDocument ? 'Delete' : 'Hide'}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel> <DropdownMenuLabel>Share</DropdownMenuLabel>
@ -186,7 +189,6 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
/> />
</DropdownMenuContent> </DropdownMenuContent>
{isDocumentDeletable && (
<DeleteDocumentDialog <DeleteDocumentDialog
id={row.id} id={row.id}
status={row.status} status={row.status}
@ -194,8 +196,9 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
teamId={team?.id} teamId={team?.id}
canManageDocument={canManageDocument}
/> />
)}
{isDuplicateDialogOpen && ( {isDuplicateDialogOpen && (
<DuplicateDocumentDialog <DuplicateDocumentDialog
id={row.id} id={row.id}

View File

@ -3,6 +3,7 @@
import { useTransition } from 'react'; import { useTransition } from 'react';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
@ -29,7 +30,7 @@ export type DocumentsDataTableProps = {
} }
>; >;
showSenderColumn?: boolean; showSenderColumn?: boolean;
team?: Pick<Team, 'id' | 'url'>; team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
}; };
export const DocumentsDataTable = ({ export const DocumentsDataTable = ({
@ -62,7 +63,12 @@ export const DocumentsDataTable = ({
{ {
header: 'Created', header: 'Created',
accessorKey: 'createdAt', accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />, cell: ({ row }) => (
<LocaleDate
date={row.original.createdAt}
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
/>
),
}, },
{ {
header: 'Title', header: 'Title',
@ -76,7 +82,12 @@ export const DocumentsDataTable = ({
{ {
header: 'Recipient', header: 'Recipient',
accessorKey: 'recipient', accessorKey: 'recipient',
cell: ({ row }) => <StackAvatarsWithTooltip recipients={row.original.Recipient} />, cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.Recipient}
documentStatus={row.original.status}
/>
),
}, },
{ {
header: 'Status', header: 'Status',

View File

@ -2,8 +2,11 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { match } from 'ts-pattern';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -23,6 +26,7 @@ type DeleteDocumentDialogProps = {
status: DocumentStatus; status: DocumentStatus;
documentTitle: string; documentTitle: string;
teamId?: number; teamId?: number;
canManageDocument: boolean;
}; };
export const DeleteDocumentDialog = ({ export const DeleteDocumentDialog = ({
@ -32,6 +36,7 @@ export const DeleteDocumentDialog = ({
status, status,
documentTitle, documentTitle,
teamId, teamId,
canManageDocument,
}: DeleteDocumentDialogProps) => { }: DeleteDocumentDialogProps) => {
const router = useRouter(); const router = useRouter();
@ -83,33 +88,70 @@ export const DeleteDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}> <Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle> <DialogTitle>Are you sure?</DialogTitle>
<DialogDescription> <DialogDescription>
Please note that this action is irreversible. Once confirmed, your document will be You are about to {canManageDocument ? 'delete' : 'hide'}{' '}
permanently deleted. <strong>"{documentTitle}"</strong>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{status !== DocumentStatus.DRAFT && ( {canManageDocument ? (
<div className="mt-4"> <Alert variant="warning" className="-mt-1">
{match(status)
.with(DocumentStatus.DRAFT, () => (
<AlertDescription>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</AlertDescription>
))
.with(DocumentStatus.PENDING, () => (
<AlertDescription>
<p>
Please note that this action is <strong>irreversible</strong>.
</p>
<p className="mt-1">Once confirmed, the following will occur:</p>
<ul className="mt-0.5 list-inside list-disc">
<li>Document will be permanently deleted</li>
<li>Document signing process will be cancelled</li>
<li>All inserted signatures will be voided</li>
<li>All recipients will be notified</li>
</ul>
</AlertDescription>
))
.with(DocumentStatus.COMPLETED, () => (
<AlertDescription>
<p>By deleting this document, the following will occur:</p>
<ul className="mt-0.5 list-inside list-disc">
<li>The document will be hidden from your account</li>
<li>Recipients will still retain their copy of the document</li>
</ul>
</AlertDescription>
))
.exhaustive()}
</Alert>
) : (
<Alert variant="warning" className="-mt-1">
<AlertDescription>
Please contact support if you would like to revert this action.
</AlertDescription>
</Alert>
)}
{status !== DocumentStatus.DRAFT && canManageDocument && (
<Input <Input
type="text" type="text"
value={inputValue} value={inputValue}
onChange={onInputChange} onChange={onInputChange}
placeholder="Type 'delete' to confirm" placeholder="Type 'delete' to confirm"
/> />
</div>
)} )}
<DialogFooter> <DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4"> <Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel Cancel
</Button> </Button>
@ -117,13 +159,11 @@ export const DeleteDocumentDialog = ({
type="button" type="button"
loading={isLoading} loading={isLoading}
onClick={onDelete} onClick={onDelete}
disabled={!isDeleteEnabled} disabled={!isDeleteEnabled && canManageDocument}
variant="destructive" variant="destructive"
className="flex-1"
> >
Delete {canManageDocument ? 'Delete' : 'Hide'}
</Button> </Button>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -41,7 +41,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
const page = Number(searchParams.page) || 1; const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20; const perPage = Number(searchParams.perPage) || 20;
const senderIds = parseToIntegerArray(searchParams.senderIds ?? ''); const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
const currentTeam = team ? { id: team.id, url: team.url } : undefined; const currentTeam = team
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
: undefined;
const getStatOptions: GetStatsInput = { const getStatOptions: GetStatsInput = {
user, user,

View File

@ -37,7 +37,10 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
})); }));
return ( return (
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"> <div
className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"
data-testid="empty-document-state"
>
<Icon className="h-12 w-12" strokeWidth={1.5} /> <Icon className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center"> <div className="text-center">

View File

@ -10,8 +10,9 @@ import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -57,7 +58,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
try { try {
setIsLoading(true); setIsLoading(true);
const { type, data } = await putFile(file); const { type, data } = await putPdfFile(file);
const { id: documentDataId } = await createDocumentData({ const { id: documentDataId } = await createDocumentData({
type, type,
@ -83,13 +84,21 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
}); });
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`); router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
} catch (error) { } catch (err) {
console.error(error); const error = AppError.parseError(err);
if (error instanceof TRPCClientError) { console.error(err);
if (error.code === 'INVALID_DOCUMENT_FILE') {
toast({
title: 'Invalid file',
description: 'You cannot upload encrypted PDFs',
variant: 'destructive',
});
} else if (err instanceof TRPCClientError) {
toast({ toast({
title: 'Error', title: 'Error',
description: error.message, description: err.message,
variant: 'destructive', variant: 'destructive',
}); });
} else { } else {

View File

@ -39,7 +39,7 @@ export default async function BillingSettingsPage() {
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([ const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }), getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }), getPricesByInterval({ plan: STRIPE_PLAN_TYPE.REGULAR }),
getPrimaryAccountPlanPrices(), getPrimaryAccountPlanPrices(),
]); ]);

View File

@ -1,10 +1,14 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client'; import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -19,52 +23,135 @@ import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients'; import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-settings';
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type EditTemplateFormProps = { export type EditTemplateFormProps = {
className?: string; className?: string;
user: User; initialTemplate: TemplateWithDetails;
template: Template; isEnterprise: boolean;
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
templateRootPath: string; templateRootPath: string;
}; };
type EditTemplateStep = 'signers' | 'fields'; type EditTemplateStep = 'settings' | 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields']; const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
export const EditTemplateForm = ({ export const EditTemplateForm = ({
initialTemplate,
className, className,
template, isEnterprise,
recipients,
fields,
user: _user,
documentData,
templateRootPath, templateRootPath,
}: EditTemplateFormProps) => { }: EditTemplateFormProps) => {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const [step, setStep] = useState<EditTemplateStep>('signers'); const team = useOptionalCurrentTeam();
const [step, setStep] = useState<EditTemplateStep>('settings');
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
const utils = trpc.useUtils();
const { data: template, refetch: refetchTemplate } =
trpc.template.getTemplateWithDetailsById.useQuery(
{
id: initialTemplate.id,
},
{
initialData: initialTemplate,
...SKIP_QUERY_BATCH_META,
},
);
const { Recipient: recipients, Field: fields, templateDocumentData } = template;
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = { const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
settings: {
title: 'General',
description: 'Configure general settings for the template.',
stepIndex: 1,
},
signers: { signers: {
title: 'Add Placeholders', title: 'Add Placeholders',
description: 'Add all relevant placeholders for each recipient.', description: 'Add all relevant placeholders for each recipient.',
stepIndex: 1, stepIndex: 2,
}, },
fields: { fields: {
title: 'Add Fields', title: 'Add Fields',
description: 'Add all relevant fields for each recipient.', description: 'Add all relevant fields for each recipient.',
stepIndex: 2, stepIndex: 3,
}, },
}; };
const currentDocumentFlow = documentFlow[step]; const currentDocumentFlow = documentFlow[step];
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation(); const { mutateAsync: updateTemplateSettings } = trpc.template.updateTemplateSettings.useMutation({
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation(); ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateWithDetailsById.setData(
{
id: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateWithDetailsById.setData(
{
id: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateWithDetailsById.setData(
{
id: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
try {
await updateTemplateSettings({
templateId: template.id,
teamId: team?.id,
data: {
title: data.title,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
meta: data.meta,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
setStep('signers');
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while updating the document settings.',
variant: 'destructive',
});
}
};
const onAddTemplatePlaceholderFormSubmit = async ( const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema, data: TAddTemplatePlacholderRecipientsFormSchema,
@ -72,9 +159,11 @@ export const EditTemplateForm = ({
try { try {
await addTemplateSigners({ await addTemplateSigners({
templateId: template.id, templateId: template.id,
teamId: team?.id,
signers: data.signers, signers: data.signers,
}); });
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh(); router.refresh();
setStep('fields'); setStep('fields');
@ -100,6 +189,9 @@ export const EditTemplateForm = ({
duration: 5000, duration: 5000,
}); });
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
router.push(templateRootPath); router.push(templateRootPath);
} catch (err) { } catch (err) {
toast({ toast({
@ -110,6 +202,15 @@ export const EditTemplateForm = ({
} }
}; };
/**
* Refresh the data in the background when steps change.
*/
useEffect(() => {
void refetchTemplate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
return ( return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}> <div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card <Card
@ -117,7 +218,11 @@ export const EditTemplateForm = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer key={documentData.id} documentData={documentData} /> <LazyPDFViewer
key={templateDocumentData.id}
documentData={templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent> </CardContent>
</Card> </Card>
@ -135,12 +240,26 @@ export const EditTemplateForm = ({
currentStep={currentDocumentFlow.stepIndex} currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])} setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
> >
<AddTemplateSettingsFormPartial
key={recipients.length}
template={template}
documentFlow={documentFlow.settings}
recipients={recipients}
fields={fields}
onSubmit={onAddSettingsFormSubmit}
isEnterprise={isEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplatePlaceholderRecipientsFormPartial <AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length} key={recipients.length}
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit} onSubmit={onAddTemplatePlaceholderFormSubmit}
isEnterprise={isEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/> />
<AddTemplateFieldsFormPartial <AddTemplateFieldsFormPartial

View File

@ -0,0 +1,40 @@
'use client';
import React, { useState } from 'react';
import { LinkIcon } from 'lucide-react';
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
export type TemplatePageViewProps = {
template: Template & { directLink?: TemplateDirectLink | null; Recipient: Recipient[] };
};
export const TemplateDirectLinkDialogWrapper = ({ template }: TemplatePageViewProps) => {
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
return (
<div>
<Button
variant="outline"
className="px-3"
onClick={(e) => {
e.preventDefault();
setTemplateDirectLinkOpen(true);
}}
>
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
{template.directLink ? 'Manage' : 'Create'} Direct Link
</Button>
<TemplateDirectLinkDialog
template={template}
open={isTemplateDirectLinkOpen}
onOpenChange={setTemplateDirectLinkOpen}
/>
</div>
);
};

View File

@ -5,16 +5,17 @@ import { redirect } from 'next/navigation';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template'; import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client'; import type { Team } from '@documenso/prisma/client';
import { TemplateType } from '~/components/formatter/template-type'; import { TemplateType } from '~/components/formatter/template-type';
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
import { EditTemplateForm } from './edit-template'; import { EditTemplateForm } from './edit-template';
import { TemplateDirectLinkDialogWrapper } from './template-direct-link-dialog-wrapper';
export type TemplatePageViewProps = { export type TemplatePageViewProps = {
params: { params: {
@ -35,7 +36,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
const { user } = await getRequiredServerComponentSession(); const { user } = await getRequiredServerComponentSession();
const template = await getTemplateById({ const template = await getTemplateWithDetailsById({
id: templateId, id: templateId,
userId: user.id, userId: user.id,
}).catch(() => null); }).catch(() => null);
@ -44,21 +45,15 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
redirect(templateRootPath); redirect(templateRootPath);
} }
const { templateDocumentData } = template; const isTemplateEnterprise = await isUserEnterprise({
const [templateRecipients, templateFields] = await Promise.all([
getRecipientsForTemplate({
templateId,
userId: user.id, userId: user.id,
}), teamId: team?.id,
getFieldsForTemplate({ });
templateId,
userId: user.id,
}),
]);
return ( return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8"> <div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
<div className="flex flex-col justify-between sm:flex-row">
<div>
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80"> <Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" /> <ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates Templates
@ -68,18 +63,29 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
{template.title} {template.title}
</h1> </h1>
<div className="mt-2.5 flex items-center gap-x-6"> <div className="mt-2.5 flex items-center">
<TemplateType inheritColor type={template.type} className="text-muted-foreground" /> <TemplateType inheritColor className="text-muted-foreground" type={template.type} />
{template.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-4"
token={template.directLink.token}
enabled={template.directLink.enabled}
/>
)}
</div>
</div>
<div className="mt-2 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
</div>
</div> </div>
<EditTemplateForm <EditTemplateForm
className="mt-8" className="mt-6"
template={template} initialTemplate={template}
user={user}
recipients={templateRecipients}
fields={templateFields}
documentData={templateDocumentData}
templateRootPath={templateRootPath} templateRootPath={templateRootPath}
isEnterprise={isTemplateEnterprise}
/> />
</div> </div>
); );

View File

@ -4,10 +4,10 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react'; import { Copy, Edit, MoreHorizontal, Share2Icon, Trash2 } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import type { Template } from '@documenso/prisma/client'; import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -18,9 +18,10 @@ import {
import { DeleteTemplateDialog } from './delete-template-dialog'; import { DeleteTemplateDialog } from './delete-template-dialog';
import { DuplicateTemplateDialog } from './duplicate-template-dialog'; import { DuplicateTemplateDialog } from './duplicate-template-dialog';
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
export type DataTableActionDropdownProps = { export type DataTableActionDropdownProps = {
row: Template; row: FindTemplateRow;
templateRootPath: string; templateRootPath: string;
teamId?: number; teamId?: number;
}; };
@ -33,6 +34,7 @@ export const DataTableActionDropdown = ({
const { data: session } = useSession(); const { data: session } = useSession();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
if (!session) { if (!session) {
@ -66,6 +68,11 @@ export const DataTableActionDropdown = ({
Duplicate Duplicate
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setTemplateDirectLinkDialogOpen(true)}>
<Share2Icon className="mr-2 h-4 w-4" />
Direct link
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
disabled={!isOwner && !isTeamTemplate} disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)} onClick={() => setDeleteDialogOpen(true)}
@ -82,6 +89,12 @@ export const DataTableActionDropdown = ({
onOpenChange={setDuplicateDialogOpen} onOpenChange={setDuplicateDialogOpen}
/> />
<TemplateDirectLinkDialog
template={row}
open={isTemplateDirectLinkDialogOpen}
onOpenChange={setTemplateDirectLinkDialogOpen}
/>
<DeleteTemplateDialog <DeleteTemplateDialog
id={row.id} id={row.id}
open={isDeleteDialogOpen} open={isDeleteDialogOpen}

View File

@ -4,32 +4,26 @@ import { useTransition } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { AlertTriangle, Loader } from 'lucide-react'; import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from 'lucide-react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Recipient, Template } from '@documenso/prisma/client'; import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { LocaleDate } from '~/components/formatter/locale-date'; import { LocaleDate } from '~/components/formatter/locale-date';
import { TemplateType } from '~/components/formatter/template-type'; import { TemplateType } from '~/components/formatter/template-type';
import { DataTableActionDropdown } from './data-table-action-dropdown'; import { DataTableActionDropdown } from './data-table-action-dropdown';
import { DataTableTitle } from './data-table-title'; import { DataTableTitle } from './data-table-title';
import { TemplateDirectLinkBadge } from './template-direct-link-badge';
import { UseTemplateDialog } from './use-template-dialog'; import { UseTemplateDialog } from './use-template-dialog';
type TemplateWithRecipient = Template & {
Recipient: Recipient[];
};
type TemplatesDataTableProps = { type TemplatesDataTableProps = {
templates: Array< templates: FindTemplateRow[];
TemplateWithRecipient & {
team: { id: number; url: string } | null;
}
>;
perPage: number; perPage: number;
page: number; page: number;
totalPages: number; totalPages: number;
@ -48,6 +42,7 @@ export const TemplatesDataTable = ({
teamId, teamId,
}: TemplatesDataTableProps) => { }: TemplatesDataTableProps) => {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams(); const updateSearchParams = useUpdateSearchParams();
const { remaining } = useLimits(); const { remaining } = useLimits();
@ -88,9 +83,70 @@ export const TemplatesDataTable = ({
cell: ({ row }) => <DataTableTitle row={row.original} />, cell: ({ row }) => <DataTableTitle row={row.original} />,
}, },
{ {
header: 'Type', header: () => (
<div className="flex flex-row items-center">
Type
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Globe2Icon className="mr-2 h-5 w-5 text-green-500 dark:text-green-300" />
Public
</h2>
<p>
Public templates are connected to your public profile. Any modifications
to public templates will also appear in your public profile.
</p>
</li>
<li>
<div className="mb-2 flex w-fit flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600">
<Link2Icon className="mr-1 h-3 w-3" />
direct link
</div>
<p>
Direct link templates contain one dynamic recipient placeholder. Anyone
with access to this link can sign the document, and it will then appear on
your documents page.
</p>
</li>
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<LockIcon className="mr-2 h-5 w-5 text-blue-600 dark:text-blue-300" />
{teamId ? 'Team Only' : 'Private'}
</h2>
<p>
{teamId
? 'Team only templates are not linked anywhere and are visible only to your team.'
: 'Private templates can only be modified and viewed by you.'}
</p>
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
),
accessorKey: 'type', accessorKey: 'type',
cell: ({ row }) => <TemplateType type={row.original.type} />, cell: ({ row }) => (
<div className="flex flex-row items-center">
<TemplateType type="PRIVATE" />
{row.original.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-2"
token={row.original.directLink.token}
enabled={row.original.directLink.enabled}
/>
)}
</div>
),
}, },
{ {
header: 'Actions', header: 'Actions',

View File

@ -1,48 +1,29 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { FilePlus, Loader } from 'lucide-react';
import { FilePlus, X } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { base64 } from '@documenso/lib/universal/base64'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ZCreateTemplateFormSchema = z.object({
name: z.string(),
});
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
type NewTemplateDialogProps = { type NewTemplateDialogProps = {
teamId?: number; teamId?: number;
templateRootPath: string; templateRootPath: string;
@ -54,51 +35,20 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
const { data: session } = useSession(); const { data: session } = useSession();
const { toast } = useToast(); const { toast } = useToast();
const form = useForm<TCreateTemplateFormSchema>({ const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
defaultValues: {
name: '',
},
resolver: zodResolver(ZCreateTemplateFormSchema),
});
const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } =
trpc.template.createTemplate.useMutation();
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false); const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>(); const [isUploadingFile, setIsUploadingFile] = useState(false);
const onFileDrop = async (file: File) => { const onFileDrop = async (file: File) => {
try { if (isUploadingFile) {
const arrayBuffer = await file.arrayBuffer();
const base64String = base64.encode(new Uint8Array(arrayBuffer));
setUploadedFile({
file,
fileBase64: `data:application/pdf;base64,${base64String}`,
});
if (!form.getValues('name')) {
form.setValue('name', file.name);
}
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
const onSubmit = async (values: TCreateTemplateFormSchema) => {
if (!uploadedFile) {
return; return;
} }
const file: File = uploadedFile.file; setIsUploadingFile(true);
try { try {
const { type, data } = await putFile(file); const { type, data } = await putPdfFile(file);
const { id: templateDocumentDataId } = await createDocumentData({ const { id: templateDocumentDataId } = await createDocumentData({
type, type,
data, data,
@ -106,7 +56,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
const { id } = await createTemplate({ const { id } = await createTemplate({
teamId, teamId,
title: values.name ? values.name : file.name, title: file.name,
templateDocumentDataId, templateDocumentDataId,
}); });
@ -126,25 +76,16 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
description: 'Please try again later.', description: 'Please try again later.',
variant: 'destructive', variant: 'destructive',
}); });
setIsUploadingFile(false);
} }
}; };
const resetForm = () => {
if (form.getValues('name') === uploadedFile?.file.name) {
form.reset();
}
setUploadedFile(null);
};
useEffect(() => {
if (!showNewTemplateDialog) {
form.reset();
}
}, [form, showNewTemplateDialog]);
return ( return (
<Dialog open={showNewTemplateDialog} onOpenChange={setShowNewTemplateDialog}> <Dialog
open={showNewTemplateDialog}
onOpenChange={(value) => !isUploadingFile && setShowNewTemplateDialog(value)}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}> <Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" /> <FilePlus className="-ml-1 mr-2 h-4 w-4" />
@ -154,81 +95,29 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
<DialogContent className="w-full max-w-xl"> <DialogContent className="w-full max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="mb-4">New Template</DialogTitle> <DialogTitle>New Template</DialogTitle>
<DialogDescription>
Templates allow you to quickly generate documents with pre-filled recipients and fields.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div> <div className="relative">
<Form {...form}> <DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name your template</FormLabel>
<FormControl>
<Input id="email" type="text" className="bg-background mt-1.5" {...field} />
</FormControl>
<FormDescription>
<span className="text-muted-foreground text-xs">
Leave this empty if you would like to use your document's name for the
template
</span>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div> {isUploadingFile && (
<Label htmlFor="template">Upload a Document</Label> <div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
<div className="my-3">
{uploadedFile ? (
<Card gradient className="h-[40vh]">
<CardContent className="flex h-full flex-col items-center justify-center p-2">
<button
onClick={() => resetForm()}
title="Remove Template"
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
>
<X className="h-6 w-6" />
<span className="sr-only">Remove Template</span>
</button>
<div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm">
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
</div> </div>
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
Uploaded Document
</p>
<span className="text-muted-foreground/80 mt-1 text-sm">
{uploadedFile.file.name}
</span>
</CardContent>
</Card>
) : (
<DocumentDropzone
className="mt-1.5 h-[40vh]"
onDrop={onFileDrop}
type="template"
/>
)} )}
</div> </div>
</div>
<div className="flex w-full justify-end"> <DialogFooter>
<Button loading={isCreatingTemplate} type="submit"> <DialogClose asChild>
Create Template <Button type="button" variant="secondary" disabled={isUploadingFile}>
Close
</Button> </Button>
</div> </DialogClose>
</form> </DialogFooter>
</Form>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -0,0 +1,45 @@
'use client';
import { Link2Icon } from 'lucide-react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDirectLinkBadgeProps = {
token: string;
enabled: boolean;
className?: string;
};
export const TemplateDirectLinkBadge = ({
token,
enabled,
className,
}: TemplateDirectLinkBadgeProps) => {
const [, copy] = useCopyToClipboard();
const { toast } = useToast();
const onCopyClick = async (token: string) =>
copy(formatDirectTemplatePath(token)).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The direct link has been copied to your clipboard',
});
});
return (
<button
title="Copy direct link"
className={cn(
'flex flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600',
className,
)}
onClick={async () => onCopyClick(token)}
>
<Link2Icon className="mr-1 h-3 w-3" />
direct link {!enabled && 'disabled'}
</button>
);
};

View File

@ -0,0 +1,448 @@
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import {
DIRECT_TEMPLATE_DOCUMENTATION,
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
} from '@documenso/lib/constants/template';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import {
type Recipient,
RecipientRole,
type Template,
type TemplateDirectLink,
} from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Switch } from '@documenso/ui/primitives/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDirectLinkDialogProps = {
template: Template & {
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
Recipient: Recipient[];
};
open: boolean;
onOpenChange: (_open: boolean) => void;
};
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
export const TemplateDirectLinkDialog = ({
template,
open,
onOpenChange,
}: TemplateDirectLinkDialogProps) => {
const { toast } = useToast();
const { quota, remaining } = useLimits();
const [, copy] = useCopyToClipboard();
const router = useRouter();
const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false);
const [token, setToken] = useState(template.directLink?.token ?? null);
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
const [currentStep, setCurrentStep] = useState<TemplateDirectLinkStep>(
token ? 'MANAGE' : 'ONBOARD',
);
const validDirectTemplateRecipients = useMemo(
() => template.Recipient.filter((recipient) => recipient.role !== RecipientRole.CC),
[template.Recipient],
);
const {
mutateAsync: createTemplateDirectLink,
isLoading: isCreatingTemplateDirectLink,
reset: resetCreateTemplateDirectLink,
} = trpcReact.template.createTemplateDirectLink.useMutation({
onSuccess: (data) => {
setToken(data.token);
setIsEnabled(data.enabled);
setCurrentStep('MANAGE');
router.refresh();
},
onError: () => {
setSelectedRecipientId(null);
toast({
title: 'Something went wrong',
description: 'Unable to create direct template access. Please try again later.',
variant: 'destructive',
});
},
});
const { mutateAsync: toggleTemplateDirectLink, isLoading: isTogglingTemplateAccess } =
trpcReact.template.toggleTemplateDirectLink.useMutation({
onSuccess: (data) => {
toast({
title: 'Success',
description: `Direct link signing has been ${data.enabled ? 'enabled' : 'disabled'}`,
});
},
onError: (_ctx, data) => {
toast({
title: 'Something went wrong',
description: `An error occurred while ${
data.enabled ? 'enabling' : 'disabling'
} direct link signing.`,
variant: 'destructive',
});
},
});
const { mutateAsync: deleteTemplateDirectLink, isLoading: isDeletingTemplateDirectLink } =
trpcReact.template.deleteTemplateDirectLink.useMutation({
onSuccess: () => {
onOpenChange(false);
setToken(null);
toast({
title: 'Success',
description: 'Direct template link deleted',
duration: 5000,
});
router.refresh();
setToken(null);
},
onError: () => {
toast({
title: 'Something went wrong',
description:
'We encountered an error while removing the direct template link. Please try again later.',
variant: 'destructive',
});
},
});
const onCopyClick = async (token: string) =>
copy(formatDirectTemplatePath(token)).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The direct link has been copied to your clipboard',
});
});
const onRecipientTableRowClick = async (recipientId: number) => {
if (isLoading) {
return;
}
setSelectedRecipientId(recipientId);
await createTemplateDirectLink({
templateId: template.id,
directRecipientId: recipientId,
});
};
const isLoading =
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
useEffect(() => {
resetCreateTemplateDirectLink();
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
setSelectedRecipientId(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<fieldset disabled={isLoading} className="relative">
<AnimateGenericFadeInOut motionKey={currentStep}>
{match({ token, currentStep })
.with({ token: P.nullish, currentStep: 'ONBOARD' }, () => (
<DialogContent>
<DialogHeader>
<DialogTitle>Create Direct Signing Link</DialogTitle>
<DialogDescription>Here's how it works:</DialogDescription>
</DialogHeader>
<ul className="mt-4 space-y-4 pl-12">
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
<li className="relative" key={index}>
<div className="absolute -left-12">
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 text-sm font-bold">
{index + 1}
</div>
</div>
<h3 className="font-semibold">{step.title}</h3>
<p className="text-muted-foreground mt-1 text-sm">{step.description}</p>
</li>
))}
</ul>
{remaining.directTemplates === 0 && (
<Alert variant="warning">
<AlertTitle>
Direct template link usage exceeded ({quota.directTemplates}/
{quota.directTemplates})
</AlertTitle>
<AlertDescription>
You have reached the maximum limit of {quota.directTemplates} direct
templates.{' '}
<Link
className="mt-1 block underline underline-offset-4"
href="/settings/billing"
>
Upgrade your account to continue!
</Link>
</AlertDescription>
</Alert>
)}
{remaining.directTemplates !== 0 && (
<DialogFooter className="mx-auto mt-4">
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
Enable direct link signing
</Button>
</DialogFooter>
)}
</DialogContent>
))
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
<DialogContent className="relative">
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
</div>
)}
<DialogHeader>
<DialogTitle>Choose Direct Link Recipient</DialogTitle>
<DialogDescription>
Choose an existing recipient from below to continue
</DialogDescription>
</DialogHeader>
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Recipient</TableHead>
<TableHead>Role</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{validDirectTemplateRecipients.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="h-16 text-center">
<p className="text-muted-foreground">No valid recipients found</p>
</TableCell>
</TableRow>
)}
{validDirectTemplateRecipients.map((row) => (
<TableRow
className="cursor-pointer"
key={row.id}
onClick={async () => onRecipientTableRowClick(row.id)}
>
<TableCell>
<div className="text-muted-foreground text-sm">
<p>{row.name}</p>
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{RECIPIENT_ROLES_DESCRIPTION[row.role].roleName}
</TableCell>
<TableCell>
{selectedRecipientId === row.id ? (
<CircleDotIcon className="h-5 w-5 text-neutral-300" />
) : (
<CircleIcon className="h-5 w-5 text-neutral-300" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
{!template.Recipient.some(
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
) && (
<DialogFooter className="mx-auto">
<div className="flex flex-col items-center justify-center">
{validDirectTemplateRecipients.length !== 0 && (
<p className="text-muted-foreground text-sm">Or</p>
)}
<Button
type="button"
className="mt-2"
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
onClick={async () =>
createTemplateDirectLink({
templateId: template.id,
})
}
>
Create one automatically
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
))
.with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>Direct Link Signing</DialogTitle>
<DialogDescription>
Manage the direct link signing for this template
</DialogDescription>
</DialogHeader>
<div>
<div className="flex flex-row items-center justify-between">
<Label className="flex flex-row">
Enable Direct Link Signing
<Tooltip>
<TooltipTrigger tabIndex={-1} className="ml-2">
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
Disabling direct link signing will prevent anyone from accessing the link.
</TooltipContent>
</Tooltip>
</Label>
<Switch
className="mt-2"
checked={isEnabled}
onCheckedChange={(value) => setIsEnabled(value)}
/>
</div>
<div className="mt-2">
<Label htmlFor="copy-direct-link">Copy Shareable Link</Label>
<div className="relative mt-1">
<Input
id="copy-direct-link"
disabled
value={formatDirectTemplatePath(token).replace(/https?:\/\//, '')}
readOnly
className="pr-12"
/>
<div className="absolute bottom-0 right-1 top-0 flex items-center justify-center">
<Button
variant="none"
type="button"
className="h-8 w-8"
onClick={() => void onCopyClick(token)}
>
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
</Button>
</div>
</div>
</div>
</div>
<DialogFooter className='mt-4'>
<Button
type="button"
variant="destructive"
className="mr-auto w-full sm:w-auto"
loading={isDeletingTemplateDirectLink}
onClick={() => setCurrentStep('CONFIRM_DELETE')}
>
Remove
</Button>
<Button
type="button"
loading={isTogglingTemplateAccess}
onClick={async () =>
toggleTemplateDirectLink({
templateId: template.id,
enabled: isEnabled,
})
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
))
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
Please note that proceeding will remove direct linking recipient and turn it
into a placeholder.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setCurrentStep('MANAGE')}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
loading={isDeletingTemplateDirectLink}
onClick={() => void deleteTemplateDirectLink({ templateId: template.id })}
>
Confirm
</Button>
</DialogFooter>
</DialogContent>
))
.otherwise(() => null)}
</AnimateGenericFadeInOut>
</fieldset>
</Dialog>
);
};

View File

@ -1,14 +1,21 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react'; import { InfoIcon, Plus } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@ -19,23 +26,58 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons'; import type { Toast } from '@documenso/ui/primitives/use-toast';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z.object({ const ZAddRecipientsForNewDocumentSchema = z
.object({
sendDocument: z.boolean(),
recipients: z.array( recipients: z.array(
z.object({ z.object({
id: z.number(),
email: z.string().email(), email: z.string().email(),
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole),
}), }),
), ),
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, recipients] of items.recipients.entries()) {
const email = recipients.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', firstFoundIndex, 'email'],
});
}
}); });
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>; type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@ -54,35 +96,33 @@ export function UseTemplateDialog({
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const [open, setOpen] = useState(false);
const team = useOptionalCurrentTeam(); const team = useOptionalCurrentTeam();
const { const form = useForm<TAddRecipientsForNewDocumentSchema>({
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema), resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: { defaultValues: {
recipients: sendDocument: false,
recipients.length > 0 recipients: recipients.map((recipient) => {
? recipients.map((recipient) => ({ const isRecipientEmailPlaceholder = recipient.email.match(
nativeId: recipient.id, TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
formId: String(recipient.id), );
name: recipient.name,
email: recipient.email, const isRecipientNamePlaceholder = recipient.name.match(
role: recipient.role, TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
})) );
: [
{ return {
name: '', id: recipient.id,
email: '', name: !isRecipientNamePlaceholder ? recipient.name : '',
role: RecipientRole.SIGNER, email: !isRecipientEmailPlaceholder ? recipient.email : '',
}, };
], }),
}, },
}); });
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } = const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation(); trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => { const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
@ -91,6 +131,7 @@ export function UseTemplateDialog({
templateId, templateId,
teamId: team?.id, teamId: team?.id,
recipients: data.recipients, recipients: data.recipients,
sendDocument: data.sendDocument,
}); });
toast({ toast({
@ -101,146 +142,147 @@ export function UseTemplateDialog({
router.push(`${documentRootPath}/${id}`); router.push(`${documentRootPath}/${id}`);
} catch (err) { } catch (err) {
toast({ const error = AppError.parseError(err);
const toastPayload: Toast = {
title: 'Error', title: 'Error',
description: 'An error occurred while creating document from template.', description: 'An error occurred while creating document from template.',
variant: 'destructive', variant: 'destructive',
}); };
if (error.code === 'DOCUMENT_SEND_FAILED') {
toastPayload.description = 'The document was created but could not be sent to recipients.';
}
toast(toastPayload);
} }
}; };
const onCreateDocumentFromTemplate = handleSubmit(onSubmit);
const { fields: formRecipients } = useFieldArray({ const { fields: formRecipients } = useFieldArray({
control, control: form.control,
name: 'recipients', name: 'recipients',
}); });
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return ( return (
<Dialog> <Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="cursor-pointer"> <Button variant="outline" className="bg-background">
<Plus className="-ml-1 mr-2 h-4 w-4" /> <Plus className="-ml-1 mr-2 h-4 w-4" />
Use Template Use Template
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Document Recipients</DialogTitle> <DialogTitle>Create document from template</DialogTitle>
<DialogDescription>Add the recipients to create the template with.</DialogDescription> <DialogDescription>
{recipients.length === 0
? 'A draft document will be created'
: 'Add the recipients to create the document with'}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col space-y-4">
{formRecipients.map((recipient, index) => (
<div
key={recipient.id}
data-native-id={recipient.id}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-email`}>
Email
<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Controller <Form {...form}>
control={control} <form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{formRecipients.map((recipient, index) => (
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
<FormField
control={form.control}
name={`recipients.${index}.email`} name={`recipients.${index}.email`}
render={({ field }) => ( render={({ field }) => (
<Input <FormItem className="w-full">
id={`recipient-${recipient.id}-email`} {index === 0 && <FormLabel required>Email</FormLabel>}
type="email"
className="bg-background mt-2" <FormControl>
disabled={isSubmitting} <Input {...field} placeholder={recipients[index].email || 'Email'} />
{...field} </FormControl>
/> <FormMessage />
</FormItem>
)} )}
/> />
</div>
<div className="flex-1"> <FormField
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label> control={form.control}
<Controller
control={control}
name={`recipients.${index}.name`} name={`recipients.${index}.name`}
render={({ field }) => ( render={({ field }) => (
<Input <FormItem className="w-full">
id={`recipient-${recipient.id}-name`} {index === 0 && <FormLabel>Name</FormLabel>}
type="text"
className="bg-background mt-2" <FormControl>
disabled={isSubmitting} <Input {...field} placeholder={recipients[index].name || 'Name'} />
{...field} </FormControl>
/> <FormMessage />
</FormItem>
)} )}
/> />
</div> </div>
<div className="w-[60px]">
<Controller
control={control}
name={`recipients.${index}.role`}
render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
<SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="w-full">
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
</div>
</div>
))} ))}
</div> </div>
<DialogFooter className="justify-end"> {recipients.length > 0 && (
<div className="mt-4 flex flex-row items-center">
<FormField
control={form.control}
name="sendDocument"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="sendDocument"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="sendDocument"
>
Send document
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
The document will be immediately sent to recipients if this is
checked.
</p>
<p>Otherwise, the document will be created as a draft.</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</FormItem>
)}
/>
</div>
)}
<DialogFooter>
<DialogClose asChild> <DialogClose asChild>
<Button type="button" variant="secondary"> <Button type="button" variant="secondary">
Close Close
</Button> </Button>
</DialogClose> </DialogClose>
<Button <Button type="submit" loading={form.formState.isSubmitting}>
type="button" {form.getValues('sendDocument') ? 'Create and send' : 'Create as draft'}
loading={isCreatingDocumentFromTemplate}
disabled={isCreatingDocumentFromTemplate}
onClick={onCreateDocumentFromTemplate}
>
Create Document
</Button> </Button>
</DialogFooter> </DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -0,0 +1,89 @@
'use client';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { UAParser } from 'ua-parser-js';
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { LocaleDate } from '~/components/formatter/locale-date';
export type AuditLogDataTableProps = {
logs: TDocumentAuditLog[];
};
const dateFormat: DateTimeFormatOptions = {
...DateTime.DATETIME_SHORT,
hourCycle: 'h12',
};
export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
const parser = new UAParser();
const uppercaseFistLetter = (text: string) => {
return text.charAt(0).toUpperCase() + text.slice(1);
};
return (
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Browser</TableHead>
</TableRow>
</TableHeader>
<TableBody className="print:text-xs">
{logs.map((log, i) => (
<TableRow className="break-inside-avoid" key={i}>
<TableCell>
<LocaleDate format={dateFormat} date={log.createdAt} />
</TableCell>
<TableCell>
{log.name || log.email ? (
<div>
{log.name && (
<p className="break-all" title={log.name}>
{log.name}
</p>
)}
{log.email && (
<p className="text-muted-foreground break-all" title={log.email}>
{log.email}
</p>
)}
</div>
) : (
<p>N/A</p>
)}
</TableCell>
<TableCell>
{uppercaseFistLetter(formatDocumentAuditLogAction(log).description)}
</TableCell>
<TableCell>{log.ipAddress}</TableCell>
<TableCell>
{log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};

View File

@ -0,0 +1,139 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Logo } from '~/components/branding/logo';
import { LocaleDate } from '~/components/formatter/locale-date';
import { AuditLogDataTable } from './data-table';
type AuditLogProps = {
searchParams: {
d: string;
};
};
export default async function AuditLog({ searchParams }: AuditLogProps) {
const { d } = searchParams;
if (typeof d !== 'string' || !d) {
return redirect('/');
}
const rawDocumentId = decryptSecondaryData(d);
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
return redirect('/');
}
const documentId = Number(rawDocumentId);
const document = await getEntireDocument({
id: documentId,
}).catch(() => null);
if (!document) {
return redirect('/');
}
const { data: auditLogs } = await findDocumentAuditLogs({
documentId: documentId,
userId: document.userId,
perPage: 100_000,
});
return (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<div className="flex items-center">
<h1 className="my-8 text-2xl font-bold">Version History</h1>
</div>
<Card>
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
<p>
<span className="font-medium">Document ID</span>
<span className="mt-1 block break-words">{document.id}</span>
</p>
<p>
<span className="font-medium">Enclosed Document</span>
<span className="mt-1 block break-words">{document.title}</span>
</p>
<p>
<span className="font-medium">Status</span>
<span className="mt-1 block">{document.deletedAt ? 'DELETED' : document.status}</span>
</p>
<p>
<span className="font-medium">Owner</span>
<span className="mt-1 block break-words">
{document.User.name} ({document.User.email})
</span>
</p>
<p>
<span className="font-medium">Created At</span>
<span className="mt-1 block">
<LocaleDate date={document.createdAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
</span>
</p>
<p>
<span className="font-medium">Last Updated</span>
<span className="mt-1 block">
<LocaleDate date={document.updatedAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
</span>
</p>
<p>
<span className="font-medium">Time Zone</span>
<span className="mt-1 block break-words">
{document.documentMeta?.timezone ?? 'N/A'}
</span>
</p>
<div>
<p className="font-medium">Recipients</p>
<ul className="mt-1 list-inside list-disc">
{document.Recipient.map((recipient) => (
<li key={recipient.id}>
<span className="text-muted-foreground">
[{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}]
</span>{' '}
{recipient.name} ({recipient.email})
</li>
))}
</ul>
</div>
</CardContent>
</Card>
<Card className="mt-8">
<CardContent className="p-0">
<AuditLogDataTable logs={auditLogs} />
</CardContent>
</Card>
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
<Logo className="max-h-6 print:max-h-4" />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,299 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_SIGNING_REASONS,
} from '@documenso/lib/constants/recipient-roles';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { FieldType } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { Logo } from '~/components/branding/logo';
import { LocaleDate } from '~/components/formatter/locale-date';
type SigningCertificateProps = {
searchParams: {
d: string;
};
};
const FRIENDLY_SIGNING_REASONS = {
['__OWNER__']: 'I am the owner of this document',
...RECIPIENT_ROLE_SIGNING_REASONS,
};
export default async function SigningCertificate({ searchParams }: SigningCertificateProps) {
const { d } = searchParams;
if (typeof d !== 'string' || !d) {
return redirect('/');
}
const rawDocumentId = decryptSecondaryData(d);
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
return redirect('/');
}
const documentId = Number(rawDocumentId);
const document = await getEntireDocument({
id: documentId,
}).catch(() => null);
if (!document) {
return redirect('/');
}
const auditLogs = await getDocumentCertificateAuditLogs({
id: documentId,
});
const isOwner = (email: string) => {
return email.toLowerCase() === document.User.email.toLowerCase();
};
const getDevice = (userAgent?: string | null) => {
if (!userAgent) {
return 'Unknown';
}
const parser = new UAParser(userAgent);
parser.setUA(userAgent);
const result = parser.getResult();
return `${result.os.name} - ${result.browser.name} ${result.browser.version}`;
};
const getAuthenticationLevel = (recipientId: number) => {
const recipient = document.Recipient.find((recipient) => recipient.id === recipientId);
if (!recipient) {
return 'Unknown';
}
const extractedAuthMethods = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth)
.with('ACCOUNT', () => 'Account Re-Authentication')
.with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication')
.with('PASSKEY', () => 'Passkey Re-Authentication')
.with('EXPLICIT_NONE', () => 'Email')
.with(null, () => null)
.exhaustive();
if (!authLevel) {
authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth)
.with('ACCOUNT', () => 'Account Authentication')
.with(null, () => 'Email')
.exhaustive();
}
return authLevel;
};
const getRecipientAuditLogs = (recipientId: number) => {
return {
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT].filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.data.recipientId === recipientId,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs[
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED
].filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED &&
log.data.recipientId === recipientId,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs[
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED
].filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
log.data.recipientId === recipientId,
),
};
};
const getRecipientSignatureField = (recipientId: number) => {
return document.Recipient.find((recipient) => recipient.id === recipientId)?.Field.find(
(field) => field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE,
);
};
return (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<div className="flex items-center">
<h1 className="my-8 text-2xl font-bold">Signing Certificate</h1>
</div>
<Card>
<CardContent className="p-0">
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>Signer Events</TableHead>
<TableHead>Signature</TableHead>
<TableHead>Details</TableHead>
{/* <TableHead>Security</TableHead> */}
</TableRow>
</TableHeader>
<TableBody className="print:text-xs">
{document.Recipient.map((recipient, i) => {
const logs = getRecipientAuditLogs(recipient.id);
const signature = getRecipientSignatureField(recipient.id);
return (
<TableRow key={i} className="print:break-inside-avoid">
<TableCell truncate={false} className="w-[min-content] max-w-[220px] align-top">
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
<div className="break-all">{recipient.email}</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">Authentication Level:</span>{' '}
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
</p>
</TableCell>
<TableCell truncate={false} className="w-[min-content] align-top">
{signature ? (
<>
<div
className="inline-block rounded-lg p-1"
style={{
boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`,
}}
>
<img
src={`${signature.Signature?.signatureImageAsBase64}`}
alt="Signature"
className="max-h-12 max-w-full"
/>
</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">Signature ID:</span>{' '}
<span className="block font-mono uppercase">
{signature.secondaryId}
</span>
</p>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">IP Address:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'}
</span>
</p>
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
<span className="font-medium">Device:</span>{' '}
<span className="inline-block">
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
</span>
</p>
</>
) : (
<p className="text-muted-foreground">N/A</p>
)}
</TableCell>
<TableCell truncate={false} className="w-[min-content] align-top">
<div className="space-y-1">
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Sent:</span>{' '}
<span className="inline-block">
{logs.EMAIL_SENT[0] ? (
<LocaleDate
date={logs.EMAIL_SENT[0].createdAt}
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
/>
) : (
'Unknown'
)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Viewed:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_OPENED[0] ? (
<LocaleDate
date={logs.DOCUMENT_OPENED[0].createdAt}
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
/>
) : (
'Unknown'
)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Signed:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0] ? (
<LocaleDate
date={logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt}
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
/>
) : (
'Unknown'
)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Reason:</span>{' '}
<span className="inline-block">
{isOwner(recipient.email)
? FRIENDLY_SIGNING_REASONS['__OWNER__']
: FRIENDLY_SIGNING_REASONS[recipient.role]}
</span>
</p>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
Signing certificate provided by:
</p>
<Logo className="max-h-6 print:max-h-4" />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,158 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { Field, Recipient } from '@documenso/prisma/client';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { ShowFieldItem } from '@documenso/ui/primitives/document-flow/show-field-item';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useStep } from '@documenso/ui/primitives/stepper';
import { useRequiredDocumentAuthContext } from '~/app/(signing)/sign/[token]/document-auth-provider';
const ZConfigureDirectTemplateFormSchema = z.object({
email: z.string().email('Email is invalid'),
});
export type TConfigureDirectTemplateFormSchema = z.infer<typeof ZConfigureDirectTemplateFormSchema>;
export type ConfigureDirectTemplateFormProps = {
flowStep: DocumentFlowStep;
isDocumentPdfLoaded: boolean;
template: TemplateWithDetails;
directTemplateRecipient: Recipient & { Field: Field[] };
initialEmail?: string;
onSubmit: (_data: TConfigureDirectTemplateFormSchema) => void;
};
export const ConfigureDirectTemplateFormPartial = ({
flowStep,
isDocumentPdfLoaded,
template,
directTemplateRecipient,
initialEmail,
onSubmit,
}: ConfigureDirectTemplateFormProps) => {
const { Recipient } = template;
const { derivedRecipientAccessAuth } = useRequiredDocumentAuthContext();
const { data: session } = useSession();
const recipientsWithBlankDirectRecipientEmail = Recipient.map((recipient) => {
if (recipient.id === directTemplateRecipient.id) {
return {
...recipient,
email: '',
};
}
return recipient;
});
const form = useForm<TConfigureDirectTemplateFormSchema>({
resolver: zodResolver(
ZConfigureDirectTemplateFormSchema.superRefine((items, ctx) => {
if (template.Recipient.map((recipient) => recipient.email).includes(items.email)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Email cannot already exist in the template',
path: ['email'],
});
}
}),
),
defaultValues: {
email: initialEmail || '',
},
});
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
return (
<>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded &&
directTemplateRecipient.Field.map((field, index) => (
<ShowFieldItem
key={index}
field={field}
recipients={recipientsWithBlankDirectRecipientEmail}
/>
))}
<Form {...form}>
<fieldset
className="flex h-full flex-col space-y-6"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="email"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel required>Email</FormLabel>
<FormControl>
<Input
{...field}
disabled={
field.disabled ||
derivedRecipientAccessAuth !== null ||
session?.user.email !== undefined
}
placeholder="recipient@documenso.com"
/>
</FormControl>
{!fieldState.error && (
<p className="text-muted-foreground text-xs">
Enter your email address to receive the completed document.
</p>
)}
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</Form>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={flowStep.title}
step={currentStep}
maxStep={totalSteps}
/>
<DocumentFlowFormContainerActions
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}
canGoBack={stepIndex !== 0}
onGoBackClick={previousStep}
onGoNextClick={form.handleSubmit(onSubmit)}
/>
</DocumentFlowFormContainerFooter>
</>
);
};

View File

@ -0,0 +1,159 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { Field } from '@documenso/prisma/client';
import { type Recipient } from '@documenso/prisma/client';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from '~/app/(signing)/sign/[token]/document-auth-provider';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import type { TConfigureDirectTemplateFormSchema } from './configure-direct-template';
import { ConfigureDirectTemplateFormPartial } from './configure-direct-template';
import type { DirectTemplateLocalField } from './sign-direct-template';
import { SignDirectTemplateForm } from './sign-direct-template';
export type TemplatesDirectPageViewProps = {
template: TemplateWithDetails;
directTemplateToken: string;
directTemplateRecipient: Recipient & { Field: Field[] };
};
type DirectTemplateStep = 'configure' | 'sign';
const DirectTemplateSteps: DirectTemplateStep[] = ['configure', 'sign'];
export const DirectTemplatePageView = ({
template,
directTemplateRecipient,
directTemplateToken,
}: TemplatesDirectPageViewProps) => {
const router = useRouter();
const { toast } = useToast();
const { email, setEmail } = useRequiredSigningContext();
const { recipient, setRecipient } = useRequiredDocumentAuthContext();
const [step, setStep] = useState<DirectTemplateStep>('configure');
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
const recipientRoleDescription = RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role];
const directTemplateFlow: Record<DirectTemplateStep, DocumentFlowStep> = {
configure: {
title: 'General',
description: 'Preview and configure template.',
stepIndex: 1,
},
sign: {
title: `${recipientRoleDescription.actionVerb} document`,
description: `${recipientRoleDescription.actionVerb} the document to complete the process.`,
stepIndex: 2,
},
};
const { mutateAsync: createDocumentFromDirectTemplate } =
trpc.template.createDocumentFromDirectTemplate.useMutation();
/**
* Set the email into a temporary recipient so it can be used for reauth and signing email fields.
*/
const onConfigureDirectTemplateSubmit = ({ email }: TConfigureDirectTemplateFormSchema) => {
setEmail(email);
setRecipient({
...recipient,
email,
});
setStep('sign');
};
const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => {
try {
const token = await createDocumentFromDirectTemplate({
directTemplateToken,
directRecipientEmail: recipient.email,
templateUpdatedAt: template.updatedAt,
signedFieldValues: fields.map((field) => {
if (!field.signedValue) {
throw new Error('Invalid configuration');
}
return field.signedValue;
}),
});
const redirectUrl = template.templateMeta?.redirectUrl;
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${token}/complete`);
} catch (err) {
toast({
title: 'Something went wrong',
description: 'We were unable to submit this document at this time. Please try again later.',
variant: 'destructive',
});
throw err;
}
};
const currentDocumentFlow = directTemplateFlow[step];
return (
<div className="grid w-full grid-cols-12 gap-8">
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
key={template.id}
documentData={template.templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()}
>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(DirectTemplateSteps[step - 1])}
>
<ConfigureDirectTemplateFormPartial
flowStep={directTemplateFlow.configure}
template={template}
directTemplateRecipient={directTemplateRecipient}
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onConfigureDirectTemplateSubmit}
initialEmail={email}
/>
<SignDirectTemplateForm
flowStep={directTemplateFlow.sign}
directRecipient={recipient}
directRecipientFields={directTemplateRecipient.Field}
template={template}
onSubmit={onSignDirectTemplateSubmit}
/>
</Stepper>
</DocumentFlowFormContainer>
</div>
</div>
);
};

View File

@ -0,0 +1,33 @@
'use client';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export default function NotFound() {
return (
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
<div>
<p className="text-muted-foreground font-semibold">404 Template not found</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
<p className="text-muted-foreground mt-4 text-sm">
The template you are looking for may have been disabled, deleted or may have never
existed.
</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button asChild className="w-32">
<Link href="/">
<ChevronLeft className="mr-2 h-4 w-4" />
Go Back
</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,92 @@
import { notFound, redirect } from 'next/navigation';
import { UsersIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
import { truncateTitle } from '~/helpers/truncate-title';
import { DirectTemplatePageView } from './direct-template';
import { DirectTemplateAuthPageView } from './signing-auth-page';
export type TemplatesDirectPageProps = {
params: {
token: string;
};
};
export default async function TemplatesDirectPage({ params }: TemplatesDirectPageProps) {
const { token } = params;
if (!token) {
redirect('/');
}
const { user } = await getServerComponentSession();
const template = await getTemplateByDirectLinkToken({
token,
}).catch(() => null);
if (!template || !template.directLink?.enabled) {
notFound();
}
const directTemplateRecipient = template.Recipient.find(
(recipient) => recipient.id === template.directLink?.directTemplateRecipientId,
);
if (!directTemplateRecipient) {
notFound();
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
.with(null, () => true)
.exhaustive();
if (!isAccessAuthValid) {
return <DirectTemplateAuthPageView />;
}
return (
<SigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
<DocumentAuthProvider
documentAuthOptions={template.authOptions}
recipient={directTemplateRecipient}
user={user}
>
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{truncateTitle(template.title)}
</h1>
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
<UsersIcon className="h-4 w-4" />
<p className="text-muted-foreground/80">
{template.Recipient.length}{' '}
{template.Recipient.length > 1 ? 'recipients' : 'recipient'}
</p>
</div>
<DirectTemplatePageView
directTemplateRecipient={directTemplateRecipient}
directTemplateToken={template.directLink.token}
template={template}
/>
</div>
</DocumentAuthProvider>
</SigningProvider>
);
}

View File

@ -0,0 +1,278 @@
import { useMemo, useState } from 'react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Field, Recipient, Signature } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useStep } from '@documenso/ui/primitives/stepper';
import { DateField } from '~/app/(signing)/sign/[token]/date-field';
import { EmailField } from '~/app/(signing)/sign/[token]/email-field';
import { NameField } from '~/app/(signing)/sign/[token]/name-field';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog';
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
export type SignDirectTemplateFormProps = {
flowStep: DocumentFlowStep;
directRecipient: Recipient;
directRecipientFields: Field[];
template: TemplateWithDetails;
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
};
export type DirectTemplateLocalField = Field & {
signedValue?: TSignFieldWithTokenMutationSchema;
Signature?: Signature;
};
export const SignDirectTemplateForm = ({
flowStep,
directRecipient,
directRecipientFields,
template,
onSubmit,
}: SignDirectTemplateFormProps) => {
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(directRecipientFields);
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const { currentStep, totalSteps, previousStep } = useStep();
const onSignField = (value: TSignFieldWithTokenMutationSchema) => {
setLocalFields(
localFields.map((field) => {
if (field.id !== value.fieldId) {
return field;
}
const tempField: DirectTemplateLocalField = {
...field,
customText: value.value,
inserted: true,
signedValue: value,
};
if (field.type === FieldType.SIGNATURE) {
tempField.Signature = {
id: 1,
created: new Date(),
recipientId: 1,
fieldId: 1,
signatureImageAsBase64: value.value,
typedSignature: null,
};
}
if (field.type === FieldType.DATE) {
tempField.customText = DateTime.now()
.setZone(template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
.toFormat(template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
}
return tempField;
}),
);
};
const onUnsignField = (value: TRemovedSignedFieldWithTokenMutationSchema) => {
setLocalFields(
localFields.map((field) => {
if (field.id !== value.fieldId) {
return field;
}
return {
...field,
customText: '',
inserted: false,
signedValue: undefined,
Signature: undefined,
};
}),
);
};
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
}, [localFields]);
const fieldsValidated = () => {
setValidateUninsertedFields(true);
validateFieldsInserted(localFields);
};
const handleSubmit = async () => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(localFields);
if (!isFieldsValid) {
return;
}
setIsSubmitting(true);
try {
await onSubmit(localFields);
} catch {
setIsSubmitting(false);
}
// Do not reset to false since we do a redirect.
};
return (
<>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
Click to insert field
</FieldToolTip>
)}
{localFields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<SignatureField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.NAME, () => (
<NameField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.DATE, () => (
<DateField
key={field.id}
field={field}
recipient={directRecipient}
dateFormat={template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.EMAIL, () => (
<EmailField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.TEXT, () => (
<TextField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.otherwise(() => null),
)}
</ElementVisible>
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">Full Name</Label>
<Input
id="full-name"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
</div>
<div>
<Label htmlFor="Signature">Signature</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
/>
</CardContent>
</Card>
</div>
</div>
</div>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={flowStep.title}
step={currentStep}
maxStep={totalSteps}
/>
<div className="mt-4 flex gap-x-4">
<Button
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
size="lg"
variant="secondary"
disabled={isSubmitting}
onClick={previousStep}
>
Back
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit}
documentTitle={template.title}
fields={localFields}
fieldsValidated={fieldsValidated}
role={directRecipient.role}
/>
</div>
</DocumentFlowFormContainerFooter>
</>
);
};

View File

@ -0,0 +1,54 @@
'use client';
import { useState } from 'react';
import { signOut } from 'next-auth/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const DirectTemplateAuthPageView = () => {
const { toast } = useToast();
const [isSigningOut, setIsSigningOut] = useState(false);
const handleChangeAccount = async () => {
try {
setIsSigningOut(true);
await signOut({
callbackUrl: '/signin',
});
} catch {
toast({
title: 'Something went wrong',
description: 'We were unable to log you out at this time.',
duration: 10000,
variant: 'destructive',
});
}
setIsSigningOut(false);
};
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
<h1 className="text-3xl font-semibold">Authentication required</h1>
<p className="text-muted-foreground mt-2 text-sm">
You need to be logged in to view this page.
</p>
<Button
className="mt-4 w-full"
type="submit"
onClick={async () => handleChangeAccount()}
loading={isSigningOut}
>
Login
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,38 @@
import React from 'react';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
import { NextAuthProvider } from '~/providers/next-auth';
type RecipientLayoutProps = {
children: React.ReactNode;
};
/**
* A layout to handle scenarios where the user is a recipient of a given resource
* where we do not care whether they are authenticated or not.
*
* Such as direct template access, or signing.
*/
export default async function RecipientLayout({ children }: RecipientLayoutProps) {
const { user, session } = await getServerComponentSession();
let teams: GetTeamsResponse = [];
if (user && session) {
teams = await getTeams({ userId: user.id });
}
return (
<NextAuthProvider session={session}>
<div className="min-h-screen">
{user && <AuthenticatedHeader user={user} teams={teams} />}
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
</div>
</NextAuthProvider>
);
}

View File

@ -0,0 +1,155 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type ClaimAccountProps = {
defaultName: string;
defaultEmail: string;
trigger?: React.ReactNode;
};
export const ZClaimAccountFormSchema = z
.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().email().min(1),
password: ZPasswordSchema,
})
.refine(
(data) => {
const { name, email, password } = data;
return !password.includes(name) && !password.includes(email.split('@')[0]);
},
{
message: 'Password should not be common or based on personal information',
path: ['password'],
},
);
export type TClaimAccountFormSchema = z.infer<typeof ZClaimAccountFormSchema>;
export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) => {
const analytics = useAnalytics();
const { toast } = useToast();
const router = useRouter();
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
const form = useForm<TClaimAccountFormSchema>({
values: {
name: defaultName ?? '',
email: defaultEmail,
password: '',
},
resolver: zodResolver(ZClaimAccountFormSchema),
});
const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => {
try {
await signup({ name, email, password });
router.push(`/unverified-account`);
toast({
title: 'Registration Successful',
description:
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
duration: 5000,
});
analytics.capture('App: User Claim Account', {
email,
timestamp: new Date().toISOString(),
});
} catch (error) {
if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: error.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you up. Please try again later.',
variant: 'destructive',
});
}
}
};
return (
<div className="mt-2 w-full">
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="mt-4">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Enter your name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="email"
control={form.control}
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>Email address</FormLabel>
<FormControl>
<Input {...field} placeholder="Enter your email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="password"
control={form.control}
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>Set a password</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="Pick a password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="mt-6 w-full" loading={form.formState.isSubmitting}>
Claim account
</Button>
</fieldset>
</form>
</Form>
</div>
);
};

View File

@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
import { CheckCircle2, Clock8 } from 'lucide-react'; import { CheckCircle2, Clock8 } from 'lucide-react';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { env } from 'next-runtime-env';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import signingCelebration from '@documenso/assets/images/signing-celebration.png';
@ -16,10 +17,13 @@ import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/clie
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { truncateTitle } from '~/helpers/truncate-title'; import { truncateTitle } from '~/helpers/truncate-title';
import { SigningAuthPageView } from '../signing-auth-page'; import { SigningAuthPageView } from '../signing-auth-page';
import { ClaimAccount } from './claim-account';
import { DocumentPreviewButton } from './document-preview-button'; import { DocumentPreviewButton } from './document-preview-button';
export type CompletedSigningPageProps = { export type CompletedSigningPageProps = {
@ -31,6 +35,8 @@ export type CompletedSigningPageProps = {
export default async function CompletedSigningPage({ export default async function CompletedSigningPage({
params: { token }, params: { token },
}: CompletedSigningPageProps) { }: CompletedSigningPageProps) {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
if (!token) { if (!token) {
return notFound(); return notFound();
} }
@ -61,7 +67,7 @@ export default async function CompletedSigningPage({
const isDocumentAccessValid = await isRecipientAuthorized({ const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS', type: 'ACCESS',
document, documentAuthOptions: document.authOptions,
recipient, recipient,
userId: user?.id, userId: user?.id,
}); });
@ -79,9 +85,30 @@ export default async function CompletedSigningPage({
const sessionData = await getServerSession(); const sessionData = await getServerSession();
const isLoggedIn = !!sessionData?.user; const isLoggedIn = !!sessionData?.user;
const canSignUp = !isLoggedIn && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
return ( return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44"> <div
className={cn(
'-mx-4 flex flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
)}
>
<div
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
canSignUp,
})}
>
<div
className={cn('flex flex-col items-center', {
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
})}
>
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
{/* Card with recipient */} {/* Card with recipient */}
<SigningCard3D <SigningCard3D
name={recipientName} name={recipientName}
@ -89,16 +116,22 @@ export default async function CompletedSigningPage({
signingCelebrationImage={signingCelebration} signingCelebrationImage={signingCelebration}
/> />
<div className="relative mt-6 flex w-full flex-col items-center"> <h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
Document
{recipient.role === RecipientRole.SIGNER && ' Signed '}
{recipient.role === RecipientRole.VIEWER && ' Viewed '}
{recipient.role === RecipientRole.APPROVER && ' Approved '}
</h2>
{match({ status: document.status, deletedAt: document.deletedAt }) {match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => ( .with({ status: DocumentStatus.COMPLETED }, () => (
<div className="text-documenso-700 flex items-center text-center"> <div className="text-documenso-700 mt-4 flex items-center text-center">
<CheckCircle2 className="mr-2 h-5 w-5" /> <CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">Everyone has signed</span> <span className="text-sm">Everyone has signed</span>
</div> </div>
)) ))
.with({ deletedAt: null }, () => ( .with({ deletedAt: null }, () => (
<div className="flex items-center text-center text-blue-600"> <div className="mt-4 flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" /> <Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span> <span className="text-sm">Waiting for others to sign</span>
</div> </div>
@ -110,14 +143,6 @@ export default async function CompletedSigningPage({
</div> </div>
))} ))}
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
You have
{recipient.role === RecipientRole.SIGNER && ' signed '}
{recipient.role === RecipientRole.VIEWER && ' viewed '}
{recipient.role === RecipientRole.APPROVER && ' approved '}
<span className="mt-1.5 block">"{truncatedTitle}"</span>
</h2>
{match({ status: document.status, deletedAt: document.deletedAt }) {match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => ( .with({ status: DocumentStatus.COMPLETED }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base"> <p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
@ -131,8 +156,8 @@ export default async function CompletedSigningPage({
)) ))
.otherwise(() => ( .otherwise(() => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base"> <p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
This document has been cancelled by the owner and is no longer available for others to This document has been cancelled by the owner and is no longer available for others
sign. to sign.
</p> </p>
))} ))}
@ -154,21 +179,26 @@ export default async function CompletedSigningPage({
/> />
)} )}
</div> </div>
</div>
{isLoggedIn ? ( {canSignUp && (
<div className={`flex max-w-xl flex-col items-center justify-center p-4 md:p-12`}>
<h2 className="mt-8 text-center text-xl font-semibold md:mt-0">
Need to sign documents?
</h2>
<p className="text-muted-foreground/60 mt-4 max-w-[55ch] text-center leading-normal">
Create your account and start using state-of-the-art document signing.
</p>
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div>
)}
{isLoggedIn && (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36"> <Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
Go Back Home Go Back Home
</Link> </Link>
) : (
<p className="text-muted-foreground/60 mt-36 text-sm">
Want to send slick signing links like this one?{' '}
<Link
href="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
</Link>
</p>
)} )}
</div> </div>
</div> </div>

View File

@ -17,6 +17,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
@ -26,6 +30,8 @@ export type DateFieldProps = {
recipient: Recipient; recipient: Recipient;
dateFormat?: string | null; dateFormat?: string | null;
timezone?: string | null; timezone?: string | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
}; };
export const DateField = ({ export const DateField = ({
@ -33,6 +39,8 @@ export const DateField = ({
recipient, recipient,
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT, dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
timezone = DEFAULT_DOCUMENT_TIME_ZONE, timezone = DEFAULT_DOCUMENT_TIME_ZONE,
onSignField,
onUnsignField,
}: DateFieldProps) => { }: DateFieldProps) => {
const router = useRouter(); const router = useRouter();
@ -58,12 +66,19 @@ export const DateField = ({
const onSign = async (authOptions?: TRecipientActionAuth) => { const onSign = async (authOptions?: TRecipientActionAuth) => {
try { try {
await signFieldWithToken({ const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
authOptions, authOptions,
}); };
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
startTransition(() => router.refresh()); startTransition(() => router.refresh());
} catch (err) { } catch (err) {
@ -85,10 +100,17 @@ export const DateField = ({
const onRemove = async () => { const onRemove = async () => {
try { try {
await removeSignedFieldWithToken({ const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
}); };
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
startTransition(() => router.refresh()); startTransition(() => router.refresh());
} catch (err) { } catch (err) {

View File

@ -18,7 +18,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog'; import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
@ -138,7 +138,15 @@ export const DocumentActionAuth2FA = ({
<FormLabel required>2FA token</FormLabel> <FormLabel required>2FA token</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="Token" /> <PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@ -34,9 +34,9 @@ type PasskeyData = {
export type DocumentAuthContextValue = { export type DocumentAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>; executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
document: Document; documentAuthOptions: Document['authOptions'];
documentAuthOption: TDocumentAuthOptions; documentAuthOption: TDocumentAuthOptions;
setDocument: (_value: Document) => void; setDocumentAuthOptions: (_value: Document['authOptions']) => void;
recipient: Recipient; recipient: Recipient;
recipientAuthOption: TRecipientAuthOptions; recipientAuthOption: TRecipientAuthOptions;
setRecipient: (_value: Recipient) => void; setRecipient: (_value: Recipient) => void;
@ -69,19 +69,19 @@ export const useRequiredDocumentAuthContext = () => {
}; };
export interface DocumentAuthProviderProps { export interface DocumentAuthProviderProps {
document: Document; documentAuthOptions: Document['authOptions'];
recipient: Recipient; recipient: Recipient;
user?: User | null; user?: User | null;
children: React.ReactNode; children: React.ReactNode;
} }
export const DocumentAuthProvider = ({ export const DocumentAuthProvider = ({
document: initialDocument, documentAuthOptions: initialDocumentAuthOptions,
recipient: initialRecipient, recipient: initialRecipient,
user, user,
children, children,
}: DocumentAuthProviderProps) => { }: DocumentAuthProviderProps) => {
const [document, setDocument] = useState(initialDocument); const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions);
const [recipient, setRecipient] = useState(initialRecipient); const [recipient, setRecipient] = useState(initialRecipient);
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false); const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
@ -95,10 +95,10 @@ export const DocumentAuthProvider = ({
} = useMemo( } = useMemo(
() => () =>
extractDocumentAuthMethods({ extractDocumentAuthMethods({
documentAuth: document.authOptions, documentAuth: documentAuthOptions,
recipientAuth: recipient.authOptions, recipientAuth: recipient.authOptions,
}), }),
[document, recipient], [documentAuthOptions, recipient],
); );
const passkeyQuery = trpc.auth.findPasskeys.useQuery( const passkeyQuery = trpc.auth.findPasskeys.useQuery(
@ -189,8 +189,8 @@ export const DocumentAuthProvider = ({
<DocumentAuthContext.Provider <DocumentAuthContext.Provider
value={{ value={{
user, user,
document, documentAuthOptions,
setDocument, setDocumentAuthOptions,
executeActionAuthProcedure, executeActionAuthProcedure,
recipient, recipient,
setRecipient, setRecipient,

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