mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
31 Commits
v1.5.4-rc.
...
feat/updat
| Author | SHA1 | Date | |
|---|---|---|---|
| dc035ed08c | |||
| fb2fd17ad8 | |||
| 6285ef2cc0 | |||
| e2987b3ef1 | |||
| d97ab04d57 | |||
| 665c943d8f | |||
| 8fe6533ef5 | |||
| fd170f095b | |||
| 1400c335a5 | |||
| 03bf16522d | |||
| 627265f016 | |||
| 08b693ff95 | |||
| 97ce3530e0 | |||
| 33f3565715 | |||
| 950a697115 | |||
| fc70f78e61 | |||
| aa52316ee3 | |||
| ea64ccae29 | |||
| b87154001a | |||
| d4a7eb299e | |||
| 2ef619226e | |||
| 65c07032de | |||
| 56c550c9d2 | |||
| d1ffcb00f3 | |||
| 58481f66b8 | |||
| 484f603a6b | |||
| b436331d7d | |||
| 81ab220f1e | |||
| cc60437dcd | |||
| 171b8008f8 | |||
| 5c00b82894 |
10
.env.example
10
.env.example
@ -40,16 +40,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.
|
||||
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]]
|
||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
||||
|
||||
@ -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
|
||||
- [react-email](https://react.email/) - Email Templates
|
||||
- [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
|
||||
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
||||
- [Stripe](https://stripe.com/) - Payments
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
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'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
|
||||
117
apps/marketing/content/blog/building-documenso-pt2.mdx
Normal file
117
apps/marketing/content/blog/building-documenso-pt2.mdx
Normal file
@ -0,0 +1,117 @@
|
||||
---
|
||||
title: 'Building Documenso — Part 2: Signature Validity'
|
||||
description: Is a signature valid? And what does that mean? It's a surprisingly complex question; let's take a look.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-04-05
|
||||
tags:
|
||||
- Document Signature
|
||||
- Certificates
|
||||
- Signing
|
||||
---
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/eu-validate-1.png"
|
||||
width= "650"
|
||||
height= "650"
|
||||
alt= "A report card for signature validity."
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">
|
||||
If a tree does not comply with the EU trust list, does it make a sound when validating?r
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
> TLDR; Signatures can be valid and compliant for different signature levels, even if some validators show higher-level errors. Not all helpful security measures are mandated by law.
|
||||
|
||||
# A valid question
|
||||
|
||||
A few days ago, an early adopter brought up this question in our [Discord](https://documen.so/discord):
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/eu-validate-2.png"
|
||||
width= "650"
|
||||
height= "650"
|
||||
alt= "A report card for signature validity."
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">
|
||||
You can check out the validator here: [https://documen.so/eu-validator](https://documen.so/eu-validator)
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
For those unfamiliar with the tool, he used the validator tool of the EU's Digital Signature Service (DSS) Framework to check the signature of a document signed with Documenso. The EU provides this tool to help users and providers check the validity level of their signatures.
|
||||
|
||||
A short refresher from [Building Documenso — Part 1: Certificates](https://documen.so/certs):
|
||||
|
||||
> Documenso inserts all visual signatures into the document and then seals it using the "Documenso Inc." corporate certificate. This makes the resulting PDF document tamper-proof and guarantees it hasn't changed since signing.
|
||||
|
||||
Before we answer if the document was signed correctly, we need to understand what the goal was.
|
||||
|
||||
There are three signature levels in the European eIDAS regulation:
|
||||
|
||||
1. **Simple Electronic Signatures (Level 1/ SES):** This is just a visual signature or even a checkbox on a document.
|
||||
|
||||
2. **Advanded Electronic Signatures (Level 2/ AES)**: An actual crypographic signature (not just a seal on the whole document, but a specific signature), using a certificate linked to the identification data of the signer.
|
||||
|
||||
3. **Qualified Electronic Signatures (Level 3/ QES):** Same as 2. but done by a government-certified entity on certified hardware and after identifying the signer with an official ID document (e.g., passport)
|
||||
|
||||
> 💡 Side Note: Number 2 (AES) is how most people imagine digital signatures. But most of the market uses 1. plus a seal on the whole document under the name of the signing provider (e.g., Documenso). The signer's data is only inserted visually, not in the actual signature. Why? One of the reasons is that it's much easier, and without a readily available open source framework to draw from, it is quite tricky to build. This is something we aim to build (which many have done) and open source (which no one has done).
|
||||
|
||||
From the perspective of eIDAS, Documenso offers Level 1/ SES signatures since it does not adhere to all of the requirements of Level 2/ AES. This means that, technically, there is no legal need to seal the document to achieve this level of validity (at least within eIDAS). We do it anyway since it improves the level of confidence users can have in the signed document. Sealing the document, even though not legally required, is a great example of Documenso's approach to signatures. First, we aim to provide all legal requirements for a given use case. Then, we add any protection that can be added without unwarranted friction to the creation of the signature.
|
||||
|
||||
## Not if valid, but how valid
|
||||
|
||||
**Q: So, is the signature in the image valid?**
|
||||
|
||||
A: Yes, as an eidas Level 1 SES.
|
||||
|
||||
**Q: Then why does it say "Unable to build a certificate chain up to a trusted list"**
|
||||
|
||||
A: The certificate we use to seal the document after inserting the signatures is not on the EU Trust list.
|
||||
|
||||
**Q: Does that mean it is less secure?**
|
||||
|
||||
A: No, it means the provider (Wisekey) is not on a list maintained by the EU. The cryptographic signature is just as strong as any other
|
||||
|
||||
For someone who does not deal with this stuff daily, this can be hard to comprehend. Whether you use a certificate you generated yourself, one generated by a certificate authority (CA) like Wisekey, or one by another on the EU trust list (e.g., Bundesdruckerei), the cryptographic security guaranteeing that the document has not been tampered with is always the same. Many providers like Documenso, DocuSign, PandaDoc, and Digisigner all use this method for their regular plans. That means if you were to run a document signed by them through the validator above, the result would be the same[1]. The interesting question is why? Why do it like this?
|
||||
|
||||
## Certificate Infrastructure is broken
|
||||
|
||||
While there are some actual expenses involved in providing AES and QES, the blunt reality is that it's just good business to charge for them per signature, making it unsuitable for the "standard offerings"; almost no one has the resources to set this up themselves. While this initial process of becoming a QES-certified entity is really expensive, selling the certificates afterward is very lucrative. This leads to less innovation in the space and only big players providing these high-compliance services. Even certificates only used to seal documents without being QES certified are sold for a large range of prices, and they cost almost nothing to produce.
|
||||
|
||||
## Why Though?
|
||||
|
||||
**Q: Why do people buy a certificate for money and not just generate one themselves? Isn't the cryptographic security the same?**
|
||||
|
||||
A: Self-generated certificates are not recognized for higher-level compliance signatures like QES
|
||||
|
||||
**Q: So if you don't need higher-level signatures, you could just generate one yourself?**
|
||||
|
||||
A: Yes, you could. Since eIDAS Level 1 does not require a cert, you could use your own.
|
||||
|
||||
**Q: Why don't more people?**
|
||||
|
||||
A: One reason is that apart from the EU trust list, there are others, like the Adobe trust list. While not legally required, being on that one (like Wisekey) gives you a green checkmark in Adobe PDF, which is how most people check signature validity.
|
||||
|
||||
**Q: Not a question, but all of this sounds weird**
|
||||
|
||||
A: It is. This is one of the reasons why Documenso exists. We plan to make this easier.
|
||||
|
||||
**Q: How?**
|
||||
|
||||
A: By explaining and providing easy-to-use tools and eventually free, highly compliant signature certificates for everyone.
|
||||
|
||||
Eventually, we plan to start a free certificate authority called Let's Sign, named after another instituion that broke the paid certificate paradigm to the benefit of the internet: [Let's Encrypt](https://letsencrypt.org/).
|
||||
|
||||
As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments.
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
\
|
||||
\
|
||||
\
|
||||
[1] The signature format (e.g. PKCS7-B) will vary. It's the format what the signature inserted into the document looks like. eIDAS itself does not specifically require any given format, but the PAdES defined by the EU is mostly used by european providers.
|
||||
@ -2,6 +2,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { withContentlayer } = require('next-contentlayer');
|
||||
const { withAxiom } = require('next-axiom');
|
||||
|
||||
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
||||
|
||||
@ -95,4 +96,4 @@ const config = {
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = withContentlayer(config);
|
||||
module.exports = withAxiom(withContentlayer(config));
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@openstatus/react": "^0.0.3",
|
||||
"contentlayer": "^0.3.4",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.279.0",
|
||||
@ -26,6 +27,7 @@
|
||||
"micro": "^10.0.1",
|
||||
"next": "14.0.3",
|
||||
"next-auth": "4.24.5",
|
||||
"next-axiom": "^1.1.1",
|
||||
"next-contentlayer": "^0.3.4",
|
||||
"next-plausible": "^3.10.1",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
|
||||
BIN
apps/marketing/public/blog/eu-validate-1.png
Normal file
BIN
apps/marketing/public/blog/eu-validate-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
BIN
apps/marketing/public/blog/eu-validate-2.png
Normal file
BIN
apps/marketing/public/blog/eu-validate-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
@ -2,10 +2,8 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
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 { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -48,16 +46,8 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
})}
|
||||
>
|
||||
{showProfilesAnnouncementBar && (
|
||||
<div className="relative inline-flex w-full items-center justify-center overflow-hidden px-4 py-2.5">
|
||||
<div className="absolute inset-0 -z-[1]">
|
||||
<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">
|
||||
<div className="relative inline-flex w-full items-center justify-center overflow-hidden bg-[#e7f3df] px-4 py-2.5">
|
||||
<div className="text-foreground text-center text-sm font-medium">
|
||||
Claim your documenso public profile username now!{' '}
|
||||
<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">
|
||||
|
||||
@ -2,6 +2,7 @@ import { Suspense } from 'react';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { AxiomWebVitals } from 'next-axiom';
|
||||
import { PublicEnvScript } from 'next-runtime-env';
|
||||
|
||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
@ -67,6 +68,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<PublicEnvScript />
|
||||
</head>
|
||||
|
||||
<AxiomWebVitals />
|
||||
|
||||
<Suspense>
|
||||
<PostHogPageview />
|
||||
</Suspense>
|
||||
|
||||
@ -13,6 +13,8 @@ import LogoImage from '@documenso/assets/logo.png';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||
|
||||
import { StatusWidgetContainer } from './status-widget-container';
|
||||
|
||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const SOCIAL_LINKS = [
|
||||
@ -62,6 +64,10 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<StatusWidgetContainer />
|
||||
</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">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
@ -51,7 +51,7 @@ export const ShareConnectPaidWidgetBento = ({
|
||||
<Card className="col-span-2 lg:col-span-1" spotlight>
|
||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||
<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
|
||||
favorite tools.
|
||||
</p>
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
// https://github.com/documenso/documenso/pull/1044/files#r1538258462
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { StatusWidget } from './status-widget';
|
||||
|
||||
export function StatusWidgetContainer() {
|
||||
return (
|
||||
<Suspense fallback={<StatusWidgetFallback />}>
|
||||
<StatusWidget />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusWidgetFallback() {
|
||||
return (
|
||||
<div className="border-border inline-flex max-w-fit items-center justify-between space-x-2 rounded-md border border-gray-200 px-2 py-2 pr-3 text-sm">
|
||||
<span className="bg-muted h-2 w-36 animate-pulse rounded-md" />
|
||||
<span className="bg-muted relative inline-flex h-2 w-2 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
apps/marketing/src/components/(marketing)/status-widget.tsx
Normal file
75
apps/marketing/src/components/(marketing)/status-widget.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { use, useMemo } from 'react';
|
||||
|
||||
import type { Status } from '@openstatus/react';
|
||||
import { getStatus } from '@openstatus/react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
const getStatusLevel = (level: Status) => {
|
||||
return {
|
||||
operational: {
|
||||
label: 'Operational',
|
||||
color: 'bg-green-500',
|
||||
color2: 'bg-green-400',
|
||||
},
|
||||
degraded_performance: {
|
||||
label: 'Degraded Performance',
|
||||
color: 'bg-yellow-500',
|
||||
color2: 'bg-yellow-400',
|
||||
},
|
||||
partial_outage: {
|
||||
label: 'Partial Outage',
|
||||
color: 'bg-yellow-500',
|
||||
color2: 'bg-yellow-400',
|
||||
},
|
||||
major_outage: {
|
||||
label: 'Major Outage',
|
||||
color: 'bg-red-500',
|
||||
color2: 'bg-red-400',
|
||||
},
|
||||
unknown: {
|
||||
label: 'Unknown',
|
||||
color: 'bg-gray-500',
|
||||
color2: 'bg-gray-400',
|
||||
},
|
||||
incident: {
|
||||
label: 'Incident',
|
||||
color: 'bg-yellow-500',
|
||||
color2: 'bg-yellow-400',
|
||||
},
|
||||
under_maintenance: {
|
||||
label: 'Under Maintenance',
|
||||
color: 'bg-gray-500',
|
||||
color2: 'bg-gray-400',
|
||||
},
|
||||
}[level];
|
||||
};
|
||||
|
||||
export function StatusWidget() {
|
||||
const getStatusMemoized = useMemo(async () => getStatus('documenso-status'), []);
|
||||
const { status } = use(getStatusMemoized);
|
||||
const level = getStatusLevel(status);
|
||||
|
||||
return (
|
||||
<a
|
||||
className="border-border inline-flex max-w-fit items-center justify-between gap-2 space-x-2 rounded-md border border-gray-200 px-3 py-1 text-sm"
|
||||
href="https://status.documenso.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm">{level.label}</p>
|
||||
</div>
|
||||
|
||||
<span className="relative ml-auto flex h-1.5 w-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
|
||||
level.color2,
|
||||
)}
|
||||
/>
|
||||
<span className={cn('relative inline-flex h-1.5 w-1.5 rounded-full', level.color)} />
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { version } = require('./package.json');
|
||||
const { withAxiom } = require('next-axiom');
|
||||
|
||||
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
||||
|
||||
@ -91,4 +92,4 @@ const config = {
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
module.exports = withAxiom(config);
|
||||
|
||||
@ -33,8 +33,10 @@
|
||||
"micro": "^10.0.1",
|
||||
"next": "14.0.3",
|
||||
"next-auth": "4.24.5",
|
||||
"next-axiom": "^1.1.1",
|
||||
"next-plausible": "^3.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
"posthog-js": "^1.75.3",
|
||||
"posthog-node": "^3.1.1",
|
||||
@ -58,6 +60,7 @@
|
||||
"@types/formidable": "^2.0.6",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/node": "20.1.0",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@types/react": "18.2.18",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
|
||||
@ -100,7 +100,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
</div>
|
||||
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
className="mt-6"
|
||||
initialDocument={document}
|
||||
documentRootPath={documentRootPath}
|
||||
isDocumentEnterprise={isDocumentEnterprise}
|
||||
|
||||
@ -2,6 +2,8 @@ import Link from 'next/link';
|
||||
|
||||
import { ChevronLeft, Loader } from 'lucide-react';
|
||||
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<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">
|
||||
Loading Document...
|
||||
</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="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
|
||||
@ -2,16 +2,21 @@ import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft, DownloadIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Recipient, Team } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
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';
|
||||
|
||||
@ -23,6 +28,8 @@ export type DocumentLogsPageViewProps = {
|
||||
};
|
||||
|
||||
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
|
||||
const locale = getLocale();
|
||||
|
||||
const { id } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
@ -67,15 +74,21 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
||||
},
|
||||
{
|
||||
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',
|
||||
value: document.createdAt.toISOString(),
|
||||
value: DateTime.fromJSDate(document.createdAt)
|
||||
.setLocale(locale)
|
||||
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||
},
|
||||
{
|
||||
description: 'Last updated',
|
||||
value: document.updatedAt.toISOString(),
|
||||
value: DateTime.fromJSDate(document.updatedAt)
|
||||
.setLocale(locale)
|
||||
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||
},
|
||||
{
|
||||
description: 'Time zone',
|
||||
@ -90,7 +103,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
||||
text = `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
||||
return `${text} - ${recipient.role}`;
|
||||
return `[${recipient.role}] ${text}`;
|
||||
};
|
||||
|
||||
return (
|
||||
@ -104,9 +117,19 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
</h1>
|
||||
<div>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatusComponent
|
||||
inheritColor
|
||||
status={document.status}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||
<Button variant="outline" className="mr-2 w-full sm:w-auto">
|
||||
|
||||
@ -2,6 +2,7 @@ import { Suspense } from 'react';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { AxiomWebVitals } from 'next-axiom';
|
||||
import { PublicEnvScript } from 'next-runtime-env';
|
||||
|
||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
@ -71,6 +72,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<PublicEnvScript />
|
||||
</head>
|
||||
|
||||
<AxiomWebVitals />
|
||||
|
||||
<Suspense>
|
||||
<PostHogPageview />
|
||||
</Suspense>
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Mail, PlusCircle, Trash } from 'lucide-react';
|
||||
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
|
||||
import Papa, { type ParseResult } from 'papaparse';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
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,
|
||||
@ -39,6 +42,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type InviteTeamMembersDialogProps = {
|
||||
@ -51,18 +55,45 @@ const ZInviteTeamMembersFormSchema = z
|
||||
.object({
|
||||
invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase());
|
||||
// Display exactly which rows are duplicates.
|
||||
.superRefine((items, ctx) => {
|
||||
const uniqueEmails = new Map<string, number>();
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: 'Members must have unique emails', path: ['members__root'] },
|
||||
);
|
||||
for (const [index, invitation] of items.invitations.entries()) {
|
||||
const email = invitation.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: ['invitations', index, 'email'],
|
||||
});
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Emails must be unique',
|
||||
path: ['invitations', firstFoundIndex, 'email'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>;
|
||||
|
||||
type TabTypes = 'INDIVIDUAL' | 'BULK';
|
||||
|
||||
const ZImportTeamMemberSchema = z.array(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
role: z.nativeEnum(TeamMemberRole),
|
||||
}),
|
||||
);
|
||||
|
||||
export const InviteTeamMembersDialog = ({
|
||||
currentUserTeamRole,
|
||||
teamId,
|
||||
@ -70,6 +101,8 @@ export const InviteTeamMembersDialog = ({
|
||||
...props
|
||||
}: InviteTeamMembersDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -130,9 +163,75 @@ export const InviteTeamMembersDialog = ({
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setInvitationType('INDIVIDUAL');
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const csvFile = e.target.files[0];
|
||||
|
||||
Papa.parse(csvFile, {
|
||||
skipEmptyLines: true,
|
||||
comments: 'Work email,Job title',
|
||||
complete: (results: ParseResult<string[]>) => {
|
||||
const members = results.data.map((row) => {
|
||||
const [email, role] = row;
|
||||
|
||||
return {
|
||||
email: email.trim(),
|
||||
role: role.trim().toUpperCase(),
|
||||
};
|
||||
});
|
||||
|
||||
// Remove the first row if it contains the headers.
|
||||
if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') {
|
||||
members.shift();
|
||||
}
|
||||
|
||||
try {
|
||||
const importedInvitations = ZImportTeamMemberSchema.parse(members);
|
||||
|
||||
form.setValue('invitations', importedInvitations);
|
||||
form.clearErrors('invitations');
|
||||
|
||||
setInvitationType('INDIVIDUAL');
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Something went wrong',
|
||||
description: 'Please check the CSV file and make sure it is according to our format',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const data = [
|
||||
{ email: 'admin@documenso.com', role: 'Admin' },
|
||||
{ email: 'manager@documenso.com', role: 'Manager' },
|
||||
{ email: 'member@documenso.com', role: 'Member' },
|
||||
];
|
||||
|
||||
const csvContent =
|
||||
'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], {
|
||||
type: 'text/csv',
|
||||
});
|
||||
|
||||
downloadFile({
|
||||
filename: 'documenso-team-member-invites-template.csv',
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
@ -152,92 +251,144 @@ export const InviteTeamMembersDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{teamMemberInvites.map((teamMemberInvite, index) => (
|
||||
<div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invitations.${index}.email`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
{index === 0 && <FormLabel required>Email address</FormLabel>}
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Tabs
|
||||
defaultValue="INDIVIDUAL"
|
||||
value={invitationType}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
onValueChange={(value) => setInvitationType(value as TabTypes)}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
|
||||
<MailIcon size={20} className="mr-2" />
|
||||
Invite Members
|
||||
</TabsTrigger>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invitations.${index}.role`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
{index === 0 && <FormLabel required>Role</FormLabel>}
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
|
||||
<UsersIcon size={20} className="mr-2" /> Bulk Import
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{TEAM_MEMBER_ROLE_MAP[role] ?? role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<TabsContent value="INDIVIDUAL">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||
{teamMemberInvites.map((teamMemberInvite, index) => (
|
||||
<div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invitations.${index}.email`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
{index === 0 && <FormLabel required>Email address</FormLabel>}
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invitations.${index}.role`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
{index === 0 && <FormLabel required>Role</FormLabel>}
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{TEAM_MEMBER_ROLE_MAP[role] ?? role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
index === 0 ? 'mt-8' : 'mt-0',
|
||||
)}
|
||||
disabled={teamMemberInvites.length === 1}
|
||||
onClick={() => removeTeamMemberInvite(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className={cn(
|
||||
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
index === 0 ? 'mt-8' : 'mt-0',
|
||||
)}
|
||||
disabled={teamMemberInvites.length === 1}
|
||||
onClick={() => removeTeamMemberInvite(index)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-fit"
|
||||
onClick={() => onAddTeamMemberInvite()}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Add more
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-fit"
|
||||
onClick={() => onAddTeamMemberInvite()}
|
||||
>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Add more
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
|
||||
Invite
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="BULK">
|
||||
<div className="mt-4 space-y-4">
|
||||
<Card gradient className="h-32">
|
||||
<CardContent
|
||||
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
|
||||
<p className="mt-1 text-sm">Click here to upload</p>
|
||||
|
||||
<input
|
||||
onChange={onFileInputChange}
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept=".csv"
|
||||
hidden
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
|
||||
Invite
|
||||
<Button type="button" variant="secondary" onClick={downloadTemplate}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Template
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
48
package-lock.json
generated
48
package-lock.json
generated
@ -42,6 +42,7 @@
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@openstatus/react": "^0.0.3",
|
||||
"contentlayer": "^0.3.4",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.279.0",
|
||||
@ -49,6 +50,7 @@
|
||||
"micro": "^10.0.1",
|
||||
"next": "14.0.3",
|
||||
"next-auth": "4.24.5",
|
||||
"next-axiom": "^1.1.1",
|
||||
"next-contentlayer": "^0.3.4",
|
||||
"next-plausible": "^3.10.1",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
@ -111,8 +113,10 @@
|
||||
"micro": "^10.0.1",
|
||||
"next": "14.0.3",
|
||||
"next-auth": "4.24.5",
|
||||
"next-axiom": "^1.1.1",
|
||||
"next-plausible": "^3.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
"posthog-js": "^1.75.3",
|
||||
"posthog-node": "^3.1.1",
|
||||
@ -136,6 +140,7 @@
|
||||
"@types/formidable": "^2.0.6",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/node": "20.1.0",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@types/react": "18.2.18",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
@ -4138,6 +4143,14 @@
|
||||
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
||||
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="
|
||||
},
|
||||
"node_modules/@openstatus/react": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@openstatus/react/-/react-0.0.3.tgz",
|
||||
"integrity": "sha512-uDiegz7e3H67pG8lTT+op+6w5keTT7XpcENrREaqlWl5j53TYyO8nheOG1PeNw2/Qgd5KaGeRJJFn1crhTUSYw==",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz",
|
||||
@ -8079,6 +8092,15 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
|
||||
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="
|
||||
},
|
||||
"node_modules/@types/papaparse": {
|
||||
"version": "5.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz",
|
||||
"integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse5": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz",
|
||||
@ -16668,6 +16690,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-axiom": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/next-axiom/-/next-axiom-1.1.1.tgz",
|
||||
"integrity": "sha512-0r/TJ+/zetD+uDc7B+2E7WpC86hEtQ1U+DuWYrP/JNmUz+ZdPFbrZgzOSqaZ6TwYbXP56VVlPfYwq1YsKHTHYQ==",
|
||||
"dependencies": {
|
||||
"remeda": "^1.29.0",
|
||||
"whatwg-fetch": "^3.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": ">=13.4",
|
||||
"react": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/next-contentlayer": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/next-contentlayer/-/next-contentlayer-0.3.4.tgz",
|
||||
@ -17236,6 +17274,11 @@
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||
},
|
||||
"node_modules/papaparse": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
|
||||
"integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw=="
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@ -22936,6 +22979,11 @@
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"node_modules/whatwg-fetch": {
|
||||
"version": "3.6.20",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
|
||||
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
|
||||
@ -13,6 +13,7 @@ import { createField } from '@documenso/lib/server-only/field/create-field';
|
||||
import { deleteField } from '@documenso/lib/server-only/field/delete-field';
|
||||
import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id';
|
||||
import { updateField } from '@documenso/lib/server-only/field/update-field';
|
||||
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
|
||||
import { deleteRecipient } from '@documenso/lib/server-only/recipient/delete-recipient';
|
||||
import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
@ -20,6 +21,8 @@ import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/s
|
||||
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
|
||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
@ -156,6 +159,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
title: body.title,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
formValues: body.formValues,
|
||||
documentDataId: documentData.id,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
@ -217,12 +221,37 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
recipients: body.recipients,
|
||||
});
|
||||
|
||||
let documentDataId = document.documentDataId;
|
||||
|
||||
if (body.formValues) {
|
||||
const pdf = await getFile(document.documentData);
|
||||
|
||||
const prefilled = await insertFormValuesInPdf({
|
||||
pdf: Buffer.from(pdf),
|
||||
formValues: body.formValues,
|
||||
});
|
||||
|
||||
const newDocumentData = await putFile({
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||
});
|
||||
|
||||
documentDataId = newDocumentData.id;
|
||||
}
|
||||
|
||||
await updateDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
data: {
|
||||
title: fileName,
|
||||
formValues: body.formValues,
|
||||
documentData: {
|
||||
connect: {
|
||||
id: documentDataId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -73,6 +73,7 @@ export const ZCreateDocumentMutationSchema = z.object({
|
||||
redirectUrl: z.string(),
|
||||
})
|
||||
.partial(),
|
||||
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||
});
|
||||
|
||||
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
|
||||
@ -112,6 +113,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||
});
|
||||
|
||||
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
||||
|
||||
54
packages/app-tests/e2e/command-menu/document-search.spec.ts
Normal file
54
packages/app-tests/e2e/command-menu/document-search.spec.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const recipient = await seedUser();
|
||||
const document = await seedPendingDocument(user, [recipient]);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
|
||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[COMMAND_MENU]: should see received documents', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const recipient = await seedUser();
|
||||
const document = await seedPendingDocument(user, [recipient]);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipient.email,
|
||||
});
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
|
||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const recipient = await seedUser();
|
||||
const document = await seedPendingDocument(user, [recipient]);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipient.email,
|
||||
});
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
|
||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||
});
|
||||
@ -71,7 +71,6 @@ test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipientWithAccount.email,
|
||||
redirectPath: '/',
|
||||
});
|
||||
|
||||
// Check that the one logged in is granted access.
|
||||
|
||||
@ -14,7 +14,7 @@ import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/user
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
@ -4,10 +4,13 @@ import path from 'node:path';
|
||||
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from './fixtures/authentication';
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
// Can't use the function in server-only/document due to it indirectly using
|
||||
// require imports.
|
||||
const getDocumentByToken = async (token: string) => {
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
@ -20,11 +23,7 @@ const getDocumentByToken = async (token: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
test(`[PR-718]: should be able to create a document`, async ({ page }) => {
|
||||
await page.goto('/signin');
|
||||
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to upload a PDF document', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await apiSignin({
|
||||
@ -32,7 +31,7 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
// Upload document
|
||||
// Upload document.
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.locator('input[type=file]').evaluate((e) => {
|
||||
@ -42,10 +41,23 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
// Wait to be redirected to the edit page
|
||||
// Wait to be redirected to the edit page.
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
// Set general settings
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
@ -91,34 +103,23 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
|
||||
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
test('should be able to create a document with multiple recipients', async ({ page }) => {
|
||||
await page.goto('/signin');
|
||||
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Upload document
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.locator('input[type=file]').evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
|
||||
|
||||
// Wait to be redirected to the edit page
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
// Set title
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
@ -187,34 +188,21 @@ test('should be able to create a document with multiple recipients', async ({ pa
|
||||
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
test('should be able to create, send and sign a document', async ({ page }) => {
|
||||
await page.goto('/signin');
|
||||
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Upload document
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.locator('input[type=file]').evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
|
||||
|
||||
// Wait to be redirected to the edit page
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
// Set title
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
@ -262,7 +250,7 @@ test('should be able to create, send and sign a document', async ({ page }) => {
|
||||
expect(status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible();
|
||||
await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL(`/sign/${token}/complete`);
|
||||
@ -271,36 +259,23 @@ test('should be able to create, send and sign a document', async ({ page }) => {
|
||||
// Check if document has been signed
|
||||
const { status: completedStatus } = await getDocumentByToken(token);
|
||||
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
test('should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
|
||||
test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/signin');
|
||||
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Upload document
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.locator('input[type=file]').evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
|
||||
|
||||
// Wait to be redirected to the edit page
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
// Set title & advanced redirect
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
@ -347,7 +322,7 @@ test('should be able to create, send with redirect url, sign a document and redi
|
||||
expect(status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible();
|
||||
await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL('https://documenso.com');
|
||||
@ -355,4 +330,6 @@ test('should be able to create, send with redirect url, sign a document and redi
|
||||
// Check if document has been signed
|
||||
const { status: completedStatus } = await getDocumentByToken(token);
|
||||
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
172
packages/app-tests/e2e/documents/delete-documents.spec.ts
Normal file
172
packages/app-tests/e2e/documents/delete-documents.spec.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
seedCompletedDocument,
|
||||
seedDraftDocument,
|
||||
seedPendingDocument,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedDeleteDocumentsTestRequirements = async () => {
|
||||
const [sender, recipientA, recipientB] = await Promise.all([seedUser(), seedUser(), seedUser()]);
|
||||
|
||||
const [draftDocument, pendingDocument, completedDocument] = await Promise.all([
|
||||
seedDraftDocument(sender, [recipientA, recipientB], {
|
||||
createDocumentOptions: { title: 'Document 1 - Draft' },
|
||||
}),
|
||||
seedPendingDocument(sender, [recipientA, recipientB], {
|
||||
createDocumentOptions: { title: 'Document 1 - Pending' },
|
||||
}),
|
||||
seedCompletedDocument(sender, [recipientA, recipientB], {
|
||||
createDocumentOptions: { title: 'Document 1 - Completed' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
sender,
|
||||
recipients: [recipientA, recipientB],
|
||||
draftDocument,
|
||||
pendingDocument,
|
||||
completedDocument,
|
||||
};
|
||||
};
|
||||
|
||||
test('[DOCUMENTS]: seeded documents should be visible', async ({ page }) => {
|
||||
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.email,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipient.email,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting a completed document should not remove it from recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.email,
|
||||
});
|
||||
|
||||
// open actions menu
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByRole('cell', { name: 'Download' })
|
||||
.getByRole('button')
|
||||
.nth(1)
|
||||
.click();
|
||||
|
||||
// delete document
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipient.email,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
|
||||
await page.getByRole('link', { name: 'Document 1 - Completed' }).click();
|
||||
await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting a pending document should remove it from recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { sender, pendingDocument } = await seedDeleteDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.email,
|
||||
});
|
||||
|
||||
// open actions menu
|
||||
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
|
||||
|
||||
// delete document
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// signout
|
||||
await apiSignout({ page });
|
||||
|
||||
for (const recipient of pendingDocument.Recipient) {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipient.email,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
|
||||
|
||||
await page.goto('/documents');
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { sender } = await seedDeleteDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.email,
|
||||
});
|
||||
|
||||
// open actions menu
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Draft' })
|
||||
.getByRole('cell', { name: 'Edit' })
|
||||
.getByRole('button')
|
||||
.click();
|
||||
|
||||
// delete document
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
|
||||
});
|
||||
@ -13,38 +13,11 @@ type LoginOptions = {
|
||||
redirectPath?: string;
|
||||
};
|
||||
|
||||
export const manualLogin = async ({
|
||||
page,
|
||||
email = 'example@documenso.com',
|
||||
password = 'password',
|
||||
redirectPath,
|
||||
}: LoginOptions) => {
|
||||
await page.goto(`${WEBAPP_BASE_URL}/signin`);
|
||||
|
||||
await page.getByLabel('Email').click();
|
||||
await page.getByLabel('Email').fill(email);
|
||||
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
await page.getByLabel('Password', { exact: true }).press('Enter');
|
||||
|
||||
if (redirectPath) {
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/documents`);
|
||||
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const manualSignout = async ({ page }: LoginOptions) => {
|
||||
await page.waitForTimeout(1000);
|
||||
await page.getByTestId('menu-switcher').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
|
||||
};
|
||||
|
||||
export const apiSignin = async ({
|
||||
page,
|
||||
email = 'example@documenso.com',
|
||||
password = 'password',
|
||||
redirectPath = '/',
|
||||
redirectPath = '/documents',
|
||||
}: LoginOptions) => {
|
||||
const { request } = page.context();
|
||||
|
||||
@ -59,9 +32,7 @@ export const apiSignin = async ({
|
||||
},
|
||||
});
|
||||
|
||||
if (redirectPath) {
|
||||
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
|
||||
}
|
||||
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
|
||||
};
|
||||
|
||||
export const apiSignout = async ({ page }: { page: Page }) => {
|
||||
|
||||
@ -1,159 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents';
|
||||
|
||||
import { manualLogin, manualSignout } from './fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('[PR-711]: seeded documents should be visible', async ({ page }) => {
|
||||
const [sender, ...recipients] = TEST_USERS;
|
||||
|
||||
await page.goto('/signin');
|
||||
|
||||
await page.getByLabel('Email').fill(sender.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(sender.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
|
||||
|
||||
await manualSignout({ page });
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await page.waitForURL('/signin');
|
||||
await manualLogin({ page, email: recipient.email, password: recipient.password });
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
|
||||
|
||||
await manualSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
test('[PR-711]: deleting a completed document should not remove it from recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
const [sender, ...recipients] = TEST_USERS;
|
||||
|
||||
await page.goto('/signin');
|
||||
|
||||
// sign in
|
||||
await page.getByLabel('Email').fill(sender.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(sender.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// open actions menu
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByRole('cell', { name: 'Download' })
|
||||
.getByRole('button')
|
||||
.nth(1)
|
||||
.click();
|
||||
|
||||
// delete document
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
|
||||
await manualSignout({ page });
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await page.waitForURL('/signin');
|
||||
await page.goto('/signin');
|
||||
|
||||
// sign in
|
||||
await page.getByLabel('Email').fill(recipient.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
|
||||
|
||||
await page.goto(`/sign/completed-token-${recipients.indexOf(recipient)}`);
|
||||
await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
|
||||
|
||||
await page.goto('/documents');
|
||||
await manualSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
test('[PR-711]: deleting a pending document should remove it from recipients', async ({ page }) => {
|
||||
const [sender, ...recipients] = TEST_USERS;
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`);
|
||||
|
||||
await expect(page.getByText('Waiting for others to sign').nth(0)).toBeVisible();
|
||||
}
|
||||
|
||||
await page.goto('/signin');
|
||||
|
||||
await manualLogin({ page, email: sender.email, password: sender.password });
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// open actions menu
|
||||
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
|
||||
|
||||
// delete document
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// signout
|
||||
await manualSignout({ page });
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await page.waitForURL('/signin');
|
||||
|
||||
await manualLogin({ page, email: recipient.email, password: recipient.password });
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
|
||||
|
||||
await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`);
|
||||
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
|
||||
|
||||
await page.goto('/documents');
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await manualSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
test('[PR-711]: deleting a draft document should remove it without additional prompting', async ({
|
||||
page,
|
||||
}) => {
|
||||
const [sender] = TEST_USERS;
|
||||
|
||||
await manualLogin({ page, email: sender.email, password: sender.password });
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// open actions menu
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Draft' })
|
||||
.getByRole('cell', { name: 'Edit' })
|
||||
.getByRole('button')
|
||||
.click();
|
||||
|
||||
// delete document
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
|
||||
});
|
||||
@ -1,54 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { TEST_USERS } from '@documenso/prisma/seed/pr-713-add-document-search-to-command-menu';
|
||||
|
||||
test('[PR-713]: should see sent documents', async ({ page }) => {
|
||||
const [user] = TEST_USERS;
|
||||
|
||||
await page.goto('/signin');
|
||||
|
||||
await page.getByLabel('Email').fill(user.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(user.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill('sent');
|
||||
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[PR-713]: should see received documents', async ({ page }) => {
|
||||
const [user] = TEST_USERS;
|
||||
|
||||
await page.goto('/signin');
|
||||
|
||||
await page.getByLabel('Email').fill(user.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(user.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill('received');
|
||||
await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[PR-713]: should be able to search by recipient', async ({ page }) => {
|
||||
const [user, recipient] = TEST_USERS;
|
||||
|
||||
await page.goto('/signin');
|
||||
|
||||
await page.getByLabel('Email').fill(user.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(user.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
|
||||
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
||||
});
|
||||
@ -11,6 +11,11 @@ test.describe.configure({ mode: 'parallel' });
|
||||
test('[TEAMS]: create team', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
test.skip(
|
||||
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
|
||||
'Test skipped because billing is enabled.',
|
||||
);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
@ -26,9 +31,6 @@ test('[TEAMS]: create team', async ({ page }) => {
|
||||
|
||||
await page.getByTestId('dialog-create-team-button').waitFor({ state: 'hidden' });
|
||||
|
||||
const isCheckoutRequired = page.url().includes('pending');
|
||||
test.skip(isCheckoutRequired, 'Test skipped because billing is enabled.');
|
||||
|
||||
// Goto new team settings page.
|
||||
await page.getByRole('row').filter({ hasText: teamId }).getByRole('link').nth(1).click();
|
||||
|
||||
|
||||
@ -108,7 +108,7 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(page.getByText('Template deleted').first()).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
await unseedTeam(team.url);
|
||||
|
||||
@ -2,6 +2,7 @@ import { type Page, expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
extractUserVerificationToken,
|
||||
seedTestEmail,
|
||||
seedUser,
|
||||
unseedUser,
|
||||
unseedUserByEmail,
|
||||
@ -9,9 +10,9 @@ import {
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
|
||||
test('[USER] can sign up with email and password', async ({ page }: { page: Page }) => {
|
||||
const username = 'Test User';
|
||||
const email = `test-user-${Date.now()}@auth-flow.documenso.com`;
|
||||
const email = seedTestEmail();
|
||||
const password = 'Password123#';
|
||||
|
||||
await page.goto('/signup');
|
||||
@ -30,7 +31,7 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Next', exact: true }).click();
|
||||
await page.getByLabel('Public profile username').fill('username-123');
|
||||
await page.getByLabel('Public profile username').fill(Date.now().toString());
|
||||
|
||||
await page.getByRole('button', { name: 'Complete', exact: true }).click();
|
||||
|
||||
@ -50,7 +51,7 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
|
||||
await unseedUserByEmail(email);
|
||||
});
|
||||
|
||||
test('user can login with user and password', async ({ page }: { page: Page }) => {
|
||||
test('[USER] can sign in using email and password', async ({ page }: { page: Page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await page.goto('/signin');
|
||||
@ -4,19 +4,16 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { manualLogin } from './fixtures/authentication';
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test('delete user', async ({ page }) => {
|
||||
test('[USER] delete account', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/settings',
|
||||
});
|
||||
await apiSignin({ page, email: user.email, redirectPath: '/settings' });
|
||||
|
||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||
await page.getByLabel('Confirm Email').fill(user.email);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Confirm Deletion' }).click();
|
||||
|
||||
@ -3,16 +3,12 @@ import { expect, test } from '@playwright/test';
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { manualLogin } from './fixtures/authentication';
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test('update user name', async ({ page }) => {
|
||||
test('[USER] update full name', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
await apiSignin({ page, email: user.email, redirectPath: '/settings/profile' });
|
||||
|
||||
await page.getByLabel('Full Name').fill('John Doe');
|
||||
|
||||
@ -17,12 +17,11 @@ export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
workers: '50%',
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
retries: process.env.CI ? 2 : 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
|
||||
@ -9,9 +9,5 @@ type IsTwoFactorAuthenticationEnabledOptions = {
|
||||
export const isTwoFactorAuthenticationEnabled = ({
|
||||
user,
|
||||
}: IsTwoFactorAuthenticationEnabledOptions) => {
|
||||
return (
|
||||
user.twoFactorEnabled &&
|
||||
user.identityProvider === 'DOCUMENSO' &&
|
||||
typeof DOCUMENSO_ENCRYPTION_KEY === 'string'
|
||||
);
|
||||
return user.twoFactorEnabled && typeof DOCUMENSO_ENCRYPTION_KEY === 'string';
|
||||
};
|
||||
|
||||
@ -14,6 +14,7 @@ export type CreateDocumentOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
documentDataId: string;
|
||||
formValues?: Record<string, string | number | boolean>;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
@ -22,6 +23,7 @@ export const createDocument = async ({
|
||||
title,
|
||||
documentDataId,
|
||||
teamId,
|
||||
formValues,
|
||||
requestMetadata,
|
||||
}: CreateDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@ -51,6 +53,7 @@ export const createDocument = async ({
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
formValues,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -153,9 +153,19 @@ export const sealDocument = async ({
|
||||
await sendCompletedEmail({ documentId, requestMetadata });
|
||||
}
|
||||
|
||||
const updatedDocument = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||
data: document,
|
||||
data: updatedDocument,
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
});
|
||||
|
||||
@ -17,6 +17,9 @@ import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
} from '../../constants/recipient-roles';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { putFile } from '../../universal/upload/put-file';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type SendDocumentOptions = {
|
||||
@ -65,6 +68,7 @@ export const sendDocument = async ({
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -82,6 +86,38 @@ export const sendDocument = async ({
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
if (!documentData.data) {
|
||||
throw new Error('Document data not found');
|
||||
}
|
||||
|
||||
if (document.formValues) {
|
||||
const file = await getFile(documentData);
|
||||
|
||||
const prefilled = await insertFormValuesInPdf({
|
||||
pdf: Buffer.from(file),
|
||||
formValues: document.formValues as Record<string, string | number | boolean>,
|
||||
});
|
||||
|
||||
const newDocumentData = await putFile({
|
||||
name: document.title,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||
});
|
||||
|
||||
const result = await prisma.document.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
data: {
|
||||
documentDataId: newDocumentData.id,
|
||||
},
|
||||
});
|
||||
|
||||
Object.assign(document, result);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||
|
||||
54
packages/lib/server-only/pdf/insert-form-values-in-pdf.ts
Normal file
54
packages/lib/server-only/pdf/insert-form-values-in-pdf.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { PDFCheckBox, PDFDocument, PDFDropdown, PDFRadioGroup, PDFTextField } from 'pdf-lib';
|
||||
|
||||
export type InsertFormValuesInPdfOptions = {
|
||||
pdf: Buffer;
|
||||
formValues: Record<string, string | boolean | number>;
|
||||
};
|
||||
|
||||
export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValuesInPdfOptions) => {
|
||||
const doc = await PDFDocument.load(pdf);
|
||||
|
||||
const form = doc.getForm();
|
||||
|
||||
if (!form) {
|
||||
return pdf;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(formValues)) {
|
||||
try {
|
||||
const field = form.getField(key);
|
||||
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean' && field instanceof PDFCheckBox) {
|
||||
if (value) {
|
||||
field.check();
|
||||
} else {
|
||||
field.uncheck();
|
||||
}
|
||||
}
|
||||
|
||||
if (field instanceof PDFTextField) {
|
||||
field.setText(value.toString());
|
||||
}
|
||||
|
||||
if (field instanceof PDFDropdown) {
|
||||
field.select(value.toString());
|
||||
}
|
||||
|
||||
if (field instanceof PDFRadioGroup) {
|
||||
field.select(value.toString());
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
console.error(`Error setting value for field ${key}: ${err.message}`);
|
||||
} else {
|
||||
console.error(`Error setting value for field ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await doc.save().then((buf) => Buffer.from(buf));
|
||||
};
|
||||
@ -79,6 +79,7 @@ export const createDocumentFromTemplate = async ({
|
||||
id: 'asc',
|
||||
},
|
||||
},
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -236,11 +236,29 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
|
||||
data: z.string(),
|
||||
}),
|
||||
]),
|
||||
fieldSecurity: z
|
||||
.object({
|
||||
type: ZRecipientActionAuthTypesSchema,
|
||||
})
|
||||
.optional(),
|
||||
fieldSecurity: z.preprocess(
|
||||
(input) => {
|
||||
const legacyNoneSecurityType = JSON.stringify({
|
||||
type: 'NONE',
|
||||
});
|
||||
|
||||
// Replace legacy 'NONE' field security type with undefined.
|
||||
if (
|
||||
typeof input === 'object' &&
|
||||
input !== null &&
|
||||
JSON.stringify(input) === legacyNoneSecurityType
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return input;
|
||||
},
|
||||
z
|
||||
.object({
|
||||
type: ZRecipientActionAuthTypesSchema,
|
||||
})
|
||||
.optional(),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" ADD COLUMN "formValues" JSONB;
|
||||
@ -257,6 +257,7 @@ model Document {
|
||||
userId Int
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
authOptions Json?
|
||||
formValues Json?
|
||||
title String
|
||||
status DocumentStatus @default(DRAFT)
|
||||
Recipient Recipient[]
|
||||
|
||||
@ -213,7 +213,14 @@ export const seedPendingDocument = async (
|
||||
});
|
||||
}
|
||||
|
||||
return document;
|
||||
return prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const seedPendingDocumentNoFields = async ({
|
||||
|
||||
@ -1,223 +0,0 @@
|
||||
import type { User } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { hashSync } from '@documenso/lib/server-only/auth/hash';
|
||||
|
||||
import { prisma } from '..';
|
||||
import {
|
||||
DocumentDataType,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
Prisma,
|
||||
ReadStatus,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '../client';
|
||||
|
||||
const PULL_REQUEST_NUMBER = 711;
|
||||
const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`;
|
||||
|
||||
export const TEST_USERS = [
|
||||
{
|
||||
name: 'Sender 1',
|
||||
email: `sender1@${EMAIL_DOMAIN}`,
|
||||
password: 'Password123',
|
||||
},
|
||||
{
|
||||
name: 'Sender 2',
|
||||
email: `sender2@${EMAIL_DOMAIN}`,
|
||||
password: 'Password123',
|
||||
},
|
||||
{
|
||||
name: 'Sender 3',
|
||||
email: `sender3@${EMAIL_DOMAIN}`,
|
||||
password: 'Password123',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const examplePdf = fs
|
||||
.readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
|
||||
.toString('base64');
|
||||
|
||||
export const seedDatabase = async () => {
|
||||
const users = await Promise.all(
|
||||
TEST_USERS.map(async (u) =>
|
||||
prisma.user.create({
|
||||
data: {
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
password: hashSync(u.password),
|
||||
emailVerified: new Date(),
|
||||
url: u.email,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const [user1, user2, user3] = users;
|
||||
|
||||
await createDraftDocument(user1, [user2, user3]);
|
||||
await createPendingDocument(user1, [user2, user3]);
|
||||
await createCompletedDocument(user1, [user2, user3]);
|
||||
};
|
||||
|
||||
const createDraftDocument = async (sender: User, recipients: User[]) => {
|
||||
const documentData = await prisma.documentData.create({
|
||||
data: {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: examplePdf,
|
||||
initialData: examplePdf,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Draft`,
|
||||
status: DocumentStatus.DRAFT,
|
||||
documentDataId: documentData.id,
|
||||
userId: sender.id,
|
||||
},
|
||||
});
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const index = recipients.indexOf(recipient);
|
||||
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email: String(recipient.email),
|
||||
name: String(recipient.name),
|
||||
token: `draft-token-${index}`,
|
||||
readStatus: ReadStatus.NOT_OPENED,
|
||||
sendStatus: SendStatus.NOT_SENT,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
signedAt: new Date(),
|
||||
Document: {
|
||||
connect: {
|
||||
id: document.id,
|
||||
},
|
||||
},
|
||||
Field: {
|
||||
create: {
|
||||
page: 1,
|
||||
type: FieldType.NAME,
|
||||
inserted: true,
|
||||
customText: String(recipient.name),
|
||||
positionX: new Prisma.Decimal(1),
|
||||
positionY: new Prisma.Decimal(1),
|
||||
width: new Prisma.Decimal(1),
|
||||
height: new Prisma.Decimal(1),
|
||||
documentId: document.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createPendingDocument = async (sender: User, recipients: User[]) => {
|
||||
const documentData = await prisma.documentData.create({
|
||||
data: {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: examplePdf,
|
||||
initialData: examplePdf,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Pending`,
|
||||
status: DocumentStatus.PENDING,
|
||||
documentDataId: documentData.id,
|
||||
userId: sender.id,
|
||||
},
|
||||
});
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const index = recipients.indexOf(recipient);
|
||||
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email: String(recipient.email),
|
||||
name: String(recipient.name),
|
||||
token: `pending-token-${index}`,
|
||||
readStatus: ReadStatus.OPENED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
signedAt: new Date(),
|
||||
Document: {
|
||||
connect: {
|
||||
id: document.id,
|
||||
},
|
||||
},
|
||||
Field: {
|
||||
create: {
|
||||
page: 1,
|
||||
type: FieldType.NAME,
|
||||
inserted: true,
|
||||
customText: String(recipient.name),
|
||||
positionX: new Prisma.Decimal(1),
|
||||
positionY: new Prisma.Decimal(1),
|
||||
width: new Prisma.Decimal(1),
|
||||
height: new Prisma.Decimal(1),
|
||||
documentId: document.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createCompletedDocument = async (sender: User, recipients: User[]) => {
|
||||
const documentData = await prisma.documentData.create({
|
||||
data: {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: examplePdf,
|
||||
initialData: examplePdf,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Completed`,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
documentDataId: documentData.id,
|
||||
completedAt: new Date(),
|
||||
userId: sender.id,
|
||||
},
|
||||
});
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const index = recipients.indexOf(recipient);
|
||||
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email: String(recipient.email),
|
||||
name: String(recipient.name),
|
||||
token: `completed-token-${index}`,
|
||||
readStatus: ReadStatus.OPENED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
signedAt: new Date(),
|
||||
Document: {
|
||||
connect: {
|
||||
id: document.id,
|
||||
},
|
||||
},
|
||||
Field: {
|
||||
create: {
|
||||
page: 1,
|
||||
type: FieldType.NAME,
|
||||
inserted: true,
|
||||
customText: String(recipient.name),
|
||||
positionX: new Prisma.Decimal(1),
|
||||
positionY: new Prisma.Decimal(1),
|
||||
width: new Prisma.Decimal(1),
|
||||
height: new Prisma.Decimal(1),
|
||||
documentId: document.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,168 +0,0 @@
|
||||
import type { User } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { hashSync } from '@documenso/lib/server-only/auth/hash';
|
||||
|
||||
import { prisma } from '..';
|
||||
import {
|
||||
DocumentDataType,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
Prisma,
|
||||
ReadStatus,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '../client';
|
||||
|
||||
//
|
||||
// https://github.com/documenso/documenso/pull/713
|
||||
//
|
||||
|
||||
const PULL_REQUEST_NUMBER = 713;
|
||||
|
||||
const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`;
|
||||
|
||||
export const TEST_USERS = [
|
||||
{
|
||||
name: 'User 1',
|
||||
email: `user1@${EMAIL_DOMAIN}`,
|
||||
password: 'Password123',
|
||||
},
|
||||
{
|
||||
name: 'User 2',
|
||||
email: `user2@${EMAIL_DOMAIN}`,
|
||||
password: 'Password123',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const examplePdf = fs
|
||||
.readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
|
||||
.toString('base64');
|
||||
|
||||
export const seedDatabase = async () => {
|
||||
const users = await Promise.all(
|
||||
TEST_USERS.map(async (u) =>
|
||||
prisma.user.create({
|
||||
data: {
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
password: hashSync(u.password),
|
||||
emailVerified: new Date(),
|
||||
url: u.email,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const [user1, user2] = users;
|
||||
|
||||
await createSentDocument(user1, [user2]);
|
||||
await createReceivedDocument(user2, [user1]);
|
||||
};
|
||||
|
||||
const createSentDocument = async (sender: User, recipients: User[]) => {
|
||||
const documentData = await prisma.documentData.create({
|
||||
data: {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: examplePdf,
|
||||
initialData: examplePdf,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
title: `[${PULL_REQUEST_NUMBER}] Document - Sent`,
|
||||
status: DocumentStatus.PENDING,
|
||||
documentDataId: documentData.id,
|
||||
userId: sender.id,
|
||||
},
|
||||
});
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const index = recipients.indexOf(recipient);
|
||||
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email: String(recipient.email),
|
||||
name: String(recipient.name),
|
||||
token: `sent-token-${index}`,
|
||||
readStatus: ReadStatus.NOT_OPENED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
signedAt: new Date(),
|
||||
Document: {
|
||||
connect: {
|
||||
id: document.id,
|
||||
},
|
||||
},
|
||||
Field: {
|
||||
create: {
|
||||
page: 1,
|
||||
type: FieldType.NAME,
|
||||
inserted: true,
|
||||
customText: String(recipient.name),
|
||||
positionX: new Prisma.Decimal(1),
|
||||
positionY: new Prisma.Decimal(1),
|
||||
width: new Prisma.Decimal(1),
|
||||
height: new Prisma.Decimal(1),
|
||||
documentId: document.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createReceivedDocument = async (sender: User, recipients: User[]) => {
|
||||
const documentData = await prisma.documentData.create({
|
||||
data: {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: examplePdf,
|
||||
initialData: examplePdf,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
title: `[${PULL_REQUEST_NUMBER}] Document - Received`,
|
||||
status: DocumentStatus.PENDING,
|
||||
documentDataId: documentData.id,
|
||||
userId: sender.id,
|
||||
},
|
||||
});
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const index = recipients.indexOf(recipient);
|
||||
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
email: String(recipient.email),
|
||||
name: String(recipient.name),
|
||||
token: `received-token-${index}`,
|
||||
readStatus: ReadStatus.NOT_OPENED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
signedAt: new Date(),
|
||||
Document: {
|
||||
connect: {
|
||||
id: document.id,
|
||||
},
|
||||
},
|
||||
Field: {
|
||||
create: {
|
||||
page: 1,
|
||||
type: FieldType.NAME,
|
||||
inserted: true,
|
||||
customText: String(recipient.name),
|
||||
positionX: new Prisma.Decimal(1),
|
||||
positionY: new Prisma.Decimal(1),
|
||||
width: new Prisma.Decimal(1),
|
||||
height: new Prisma.Decimal(1),
|
||||
documentId: document.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,8 +1,11 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { prisma } from '..';
|
||||
import { TeamMemberInviteStatus, TeamMemberRole } from '../client';
|
||||
import { seedUser } from './users';
|
||||
|
||||
const EMAIL_DOMAIN = `test.documenso.com`;
|
||||
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||
|
||||
type SeedTeamOptions = {
|
||||
createTeamMembers?: number;
|
||||
@ -13,7 +16,7 @@ export const seedTeam = async ({
|
||||
createTeamMembers = 0,
|
||||
createTeamEmail,
|
||||
}: SeedTeamOptions = {}) => {
|
||||
const teamUrl = `team-${Date.now()}`;
|
||||
const teamUrl = `team-${nanoid()}`;
|
||||
const teamEmail = createTeamEmail === true ? `${teamUrl}@${EMAIL_DOMAIN}` : createTeamEmail;
|
||||
|
||||
const teamOwner = await seedUser({
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { hashSync } from '@documenso/lib/server-only/auth/hash';
|
||||
|
||||
import { prisma } from '..';
|
||||
@ -11,12 +13,22 @@ type SeedUserOptions = {
|
||||
verified?: boolean;
|
||||
};
|
||||
|
||||
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||
|
||||
export const seedUser = async ({
|
||||
name = `user-${Date.now()}`,
|
||||
email = `user-${Date.now()}@test.documenso.com`,
|
||||
name,
|
||||
email,
|
||||
password = 'password',
|
||||
verified = true,
|
||||
}: SeedUserOptions = {}) => {
|
||||
if (!name) {
|
||||
name = nanoid();
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
email = `${nanoid()}@test.documenso.com`;
|
||||
}
|
||||
|
||||
return await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
|
||||
@ -29,6 +29,8 @@ export const adminRouter = router({
|
||||
try {
|
||||
return await findDocuments({ term, page, perPage });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to retrieve the documents. Please try again.',
|
||||
@ -44,6 +46,8 @@ export const adminRouter = router({
|
||||
try {
|
||||
return await updateUser({ id, name, email, roles });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to retrieve the specified account. Please try again.',
|
||||
@ -59,6 +63,8 @@ export const adminRouter = router({
|
||||
try {
|
||||
return await updateRecipient({ id, name, email });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to update the recipient provided.',
|
||||
@ -79,6 +85,8 @@ export const adminRouter = router({
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to update the site setting provided.',
|
||||
@ -95,6 +103,7 @@ export const adminRouter = router({
|
||||
return await sealDocument({ documentId: id, isResealing: true });
|
||||
} catch (err) {
|
||||
console.log('resealDocument error', err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to reseal the document provided.',
|
||||
|
||||
@ -16,7 +16,9 @@ export const apiTokenRouter = router({
|
||||
getTokens: authenticatedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
return await getUserTokens({ userId: ctx.user.id });
|
||||
} catch (e) {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to find your API tokens. Please try again.',
|
||||
@ -34,7 +36,9 @@ export const apiTokenRouter = router({
|
||||
id,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (e) {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to find this API token. Please try again.',
|
||||
@ -54,7 +58,9 @@ export const apiTokenRouter = router({
|
||||
tokenName,
|
||||
expiresIn: expirationDate,
|
||||
});
|
||||
} catch (e) {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to create an API token. Please try again.',
|
||||
@ -73,7 +79,9 @@ export const apiTokenRouter = router({
|
||||
teamId,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (e) {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to delete this API Token. Please try again.',
|
||||
|
||||
@ -115,6 +115,8 @@ export const documentRouter = router({
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof TRPCError) {
|
||||
throw err;
|
||||
}
|
||||
@ -222,13 +224,19 @@ export const documentRouter = router({
|
||||
|
||||
const userId = ctx.user.id;
|
||||
|
||||
return await updateTitle({
|
||||
title,
|
||||
userId,
|
||||
teamId,
|
||||
documentId,
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
try {
|
||||
return await updateTitle({
|
||||
title,
|
||||
userId,
|
||||
teamId,
|
||||
documentId,
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw err;
|
||||
}
|
||||
}),
|
||||
|
||||
setPasswordForDocument: authenticatedProcedure
|
||||
@ -347,7 +355,9 @@ export const documentRouter = router({
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
return documents;
|
||||
} catch (error) {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We are unable to search for documents. Please try again later.',
|
||||
|
||||
@ -52,20 +52,26 @@ export const fieldRouter = router({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { templateId, fields } = input;
|
||||
|
||||
await setFieldsForTemplate({
|
||||
userId: ctx.user.id,
|
||||
templateId,
|
||||
fields: fields.map((field) => ({
|
||||
id: field.nativeId,
|
||||
signerEmail: field.signerEmail,
|
||||
type: field.type,
|
||||
pageNumber: field.pageNumber,
|
||||
pageX: field.pageX,
|
||||
pageY: field.pageY,
|
||||
pageWidth: field.pageWidth,
|
||||
pageHeight: field.pageHeight,
|
||||
})),
|
||||
});
|
||||
try {
|
||||
await setFieldsForTemplate({
|
||||
userId: ctx.user.id,
|
||||
templateId,
|
||||
fields: fields.map((field) => ({
|
||||
id: field.nativeId,
|
||||
signerEmail: field.signerEmail,
|
||||
type: field.type,
|
||||
pageNumber: field.pageNumber,
|
||||
pageX: field.pageX,
|
||||
pageY: field.pageY,
|
||||
pageWidth: field.pageWidth,
|
||||
pageHeight: field.pageHeight,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw err;
|
||||
}
|
||||
}),
|
||||
|
||||
signFieldWithToken: procedure
|
||||
|
||||
@ -37,6 +37,8 @@ export const profileRouter = router({
|
||||
...input,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to find user security audit logs. Please try again.',
|
||||
@ -50,6 +52,8 @@ export const profileRouter = router({
|
||||
|
||||
return await getUserById({ id });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to retrieve the specified account. Please try again.',
|
||||
@ -108,6 +112,8 @@ export const profileRouter = router({
|
||||
|
||||
return { success: true, url: user.url };
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
|
||||
@ -135,6 +141,8 @@ export const profileRouter = router({
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
let message =
|
||||
'We were unable to update your profile. Please review the information you provided and try again.';
|
||||
|
||||
@ -171,6 +179,8 @@ export const profileRouter = router({
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
let message = 'We were unable to reset your password. Please try again.';
|
||||
|
||||
if (err instanceof Error) {
|
||||
@ -192,6 +202,8 @@ export const profileRouter = router({
|
||||
|
||||
return await sendConfirmationToken({ email });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
let message = 'We were unable to send a confirmation email. Please try again.';
|
||||
|
||||
if (err instanceof Error) {
|
||||
@ -211,6 +223,8 @@ export const profileRouter = router({
|
||||
id: ctx.user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
let message = 'We were unable to delete your account. Please try again.';
|
||||
|
||||
if (err instanceof Error) {
|
||||
|
||||
@ -29,151 +29,157 @@ export const singleplayerRouter = router({
|
||||
createSinglePlayerDocument: procedure
|
||||
.input(ZCreateSinglePlayerDocumentMutationSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const { signer, fields, documentData, documentName } = input;
|
||||
try {
|
||||
const { signer, fields, documentData, documentName } = input;
|
||||
|
||||
const document = await getFile({
|
||||
data: documentData.data,
|
||||
type: documentData.type,
|
||||
});
|
||||
|
||||
const doc = await PDFDocument.load(document);
|
||||
|
||||
const createdAt = new Date();
|
||||
|
||||
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
|
||||
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
|
||||
const typedSignature = !isBase64 ? signer.signature : null;
|
||||
|
||||
// Update the document with the fields inserted.
|
||||
for (const field of fields) {
|
||||
const isSignatureField = field.type === FieldType.SIGNATURE;
|
||||
|
||||
await insertFieldInPDF(doc, {
|
||||
...mapField(field, signer),
|
||||
Signature: isSignatureField
|
||||
? {
|
||||
created: createdAt,
|
||||
signatureImageAsBase64,
|
||||
typedSignature,
|
||||
// Dummy data.
|
||||
id: -1,
|
||||
recipientId: -1,
|
||||
fieldId: -1,
|
||||
}
|
||||
: null,
|
||||
// Dummy data.
|
||||
id: -1,
|
||||
secondaryId: '-1',
|
||||
documentId: -1,
|
||||
templateId: null,
|
||||
recipientId: -1,
|
||||
const document = await getFile({
|
||||
data: documentData.data,
|
||||
type: documentData.type,
|
||||
});
|
||||
}
|
||||
|
||||
const unsignedPdfBytes = await doc.save();
|
||||
const doc = await PDFDocument.load(document);
|
||||
|
||||
const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) });
|
||||
const createdAt = new Date();
|
||||
|
||||
const { token } = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const token = alphaid();
|
||||
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
|
||||
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
|
||||
const typedSignature = !isBase64 ? signer.signature : null;
|
||||
|
||||
// Fetch service user who will be the owner of the document.
|
||||
const serviceUser = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: SERVICE_USER_EMAIL,
|
||||
},
|
||||
// Update the document with the fields inserted.
|
||||
for (const field of fields) {
|
||||
const isSignatureField = field.type === FieldType.SIGNATURE;
|
||||
|
||||
await insertFieldInPDF(doc, {
|
||||
...mapField(field, signer),
|
||||
Signature: isSignatureField
|
||||
? {
|
||||
created: createdAt,
|
||||
signatureImageAsBase64,
|
||||
typedSignature,
|
||||
// Dummy data.
|
||||
id: -1,
|
||||
recipientId: -1,
|
||||
fieldId: -1,
|
||||
}
|
||||
: null,
|
||||
// Dummy data.
|
||||
id: -1,
|
||||
secondaryId: '-1',
|
||||
documentId: -1,
|
||||
templateId: null,
|
||||
recipientId: -1,
|
||||
});
|
||||
}
|
||||
|
||||
const { id: documentDataId } = await putFile({
|
||||
name: `${documentName}.pdf`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
|
||||
});
|
||||
const unsignedPdfBytes = await doc.save();
|
||||
|
||||
// Create document.
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title: documentName,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
documentDataId,
|
||||
userId: serviceUser.id,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) });
|
||||
|
||||
// Create recipient.
|
||||
const recipient = await tx.recipient.create({
|
||||
data: {
|
||||
documentId: document.id,
|
||||
name: signer.name,
|
||||
email: signer.email,
|
||||
token,
|
||||
signedAt: createdAt,
|
||||
readStatus: ReadStatus.OPENED,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
const { token } = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const token = alphaid();
|
||||
|
||||
// Create fields and signatures.
|
||||
await Promise.all(
|
||||
fields.map(async (field) => {
|
||||
const insertedField = await tx.field.create({
|
||||
data: {
|
||||
documentId: document.id,
|
||||
recipientId: recipient.id,
|
||||
...mapField(field, signer),
|
||||
},
|
||||
});
|
||||
// Fetch service user who will be the owner of the document.
|
||||
const serviceUser = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: SERVICE_USER_EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
||||
await tx.signature.create({
|
||||
const { id: documentDataId } = await putFile({
|
||||
name: `${documentName}.pdf`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
|
||||
});
|
||||
|
||||
// Create document.
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title: documentName,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
documentDataId,
|
||||
userId: serviceUser.id,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Create recipient.
|
||||
const recipient = await tx.recipient.create({
|
||||
data: {
|
||||
documentId: document.id,
|
||||
name: signer.name,
|
||||
email: signer.email,
|
||||
token,
|
||||
signedAt: createdAt,
|
||||
readStatus: ReadStatus.OPENED,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
|
||||
// Create fields and signatures.
|
||||
await Promise.all(
|
||||
fields.map(async (field) => {
|
||||
const insertedField = await tx.field.create({
|
||||
data: {
|
||||
fieldId: insertedField.id,
|
||||
signatureImageAsBase64,
|
||||
typedSignature,
|
||||
documentId: document.id,
|
||||
recipientId: recipient.id,
|
||||
...mapField(field, signer),
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { document, token };
|
||||
},
|
||||
{
|
||||
maxWait: 5000,
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
||||
await tx.signature.create({
|
||||
data: {
|
||||
fieldId: insertedField.id,
|
||||
signatureImageAsBase64,
|
||||
typedSignature,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const template = createElement(DocumentSelfSignedEmailTemplate, {
|
||||
documentName: documentName,
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
|
||||
});
|
||||
return { document, token };
|
||||
},
|
||||
{
|
||||
maxWait: 5000,
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderAsync(template),
|
||||
renderAsync(template, { plainText: true }),
|
||||
]);
|
||||
const template = createElement(DocumentSelfSignedEmailTemplate, {
|
||||
documentName: documentName,
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
|
||||
});
|
||||
|
||||
// Send email to signer.
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: signer.email,
|
||||
name: signer.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document signed',
|
||||
html,
|
||||
text,
|
||||
attachments: [{ content: signedPdfBuffer, filename: documentName }],
|
||||
});
|
||||
const [html, text] = await Promise.all([
|
||||
renderAsync(template),
|
||||
renderAsync(template, { plainText: true }),
|
||||
]);
|
||||
|
||||
return token;
|
||||
// Send email to signer.
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: signer.email,
|
||||
name: signer.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document signed',
|
||||
html,
|
||||
text,
|
||||
attachments: [{ content: signedPdfBuffer, filename: documentName }],
|
||||
});
|
||||
|
||||
return token;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw err;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@ -56,6 +56,8 @@ export const templateRouter = router({
|
||||
recipients: input.recipients,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to create this document. Please try again later.',
|
||||
|
||||
@ -21,6 +21,8 @@ export const webhookRouter = router({
|
||||
try {
|
||||
return await getWebhooksByUserId(ctx.user.id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to fetch your webhooks. Please try again later.',
|
||||
@ -36,6 +38,8 @@ export const webhookRouter = router({
|
||||
try {
|
||||
return await getWebhooksByTeamId(teamId, ctx.user.id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to fetch your webhooks. Please try again later.',
|
||||
@ -55,6 +59,8 @@ export const webhookRouter = router({
|
||||
teamId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to fetch your webhook. Please try again later.',
|
||||
@ -77,6 +83,8 @@ export const webhookRouter = router({
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to create this webhook. Please try again later.',
|
||||
@ -96,6 +104,8 @@ export const webhookRouter = router({
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to create this webhook. Please try again later.',
|
||||
@ -116,6 +126,8 @@ export const webhookRouter = router({
|
||||
teamId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to create this webhook. Please try again later.',
|
||||
|
||||
@ -223,6 +223,10 @@ export const AddSettingsFormPartial = ({
|
||||
<strong>Require passkey</strong> - The recipient must have an account
|
||||
and passkey configured via their settings
|
||||
</li>
|
||||
<li>
|
||||
<strong>Require 2FA</strong> - The recipient must have an account and
|
||||
2FA enabled via their settings
|
||||
</li>
|
||||
<li>
|
||||
<strong>None</strong> - No authentication required
|
||||
</li>
|
||||
|
||||
@ -5,6 +5,7 @@ import React, { useId, useMemo, useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { motion } from 'framer-motion';
|
||||
import { InfoIcon, Plus, Trash } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
@ -60,6 +61,9 @@ export const AddSignersFormPartial = ({
|
||||
}: AddSignersFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const { remaining } = useLimits();
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user;
|
||||
const [selfSignerFormId, setSelfSignerFormId] = useState<string | undefined>(undefined);
|
||||
|
||||
const initialId = useId();
|
||||
|
||||
@ -135,6 +139,20 @@ export const AddSignersFormPartial = ({
|
||||
);
|
||||
};
|
||||
|
||||
const onAddSelfSigner = () => {
|
||||
const newSelfSignerId = nanoid(12);
|
||||
|
||||
appendSigner({
|
||||
formId: newSelfSignerId,
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: undefined,
|
||||
});
|
||||
|
||||
setSelfSignerFormId(newSelfSignerId);
|
||||
};
|
||||
|
||||
const onAddSigner = () => {
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
@ -158,6 +176,10 @@ export const AddSignersFormPartial = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (signer.formId === selfSignerFormId) {
|
||||
setSelfSignerFormId(undefined);
|
||||
}
|
||||
|
||||
removeSigner(index);
|
||||
};
|
||||
|
||||
@ -209,8 +231,13 @@ export const AddSignersFormPartial = ({
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||
{...field}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
||||
signer.formId === selfSignerFormId ||
|
||||
signers[index].email === user?.email
|
||||
}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -237,8 +264,13 @@ export const AddSignersFormPartial = ({
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Name"
|
||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||
{...field}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
||||
signer.formId === selfSignerFormId ||
|
||||
signers[index].email === user?.email
|
||||
}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -258,7 +290,12 @@ export const AddSignersFormPartial = ({
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
||||
signer.formId === selfSignerFormId ||
|
||||
signers[index].email === user?.email
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue placeholder="Inherit authentication method" />
|
||||
@ -291,6 +328,10 @@ export const AddSignersFormPartial = ({
|
||||
<strong>Require passkey</strong> - The recipient must have
|
||||
an account and passkey configured via their settings
|
||||
</li>
|
||||
<li>
|
||||
<strong>Require 2FA</strong> - The recipient must have an
|
||||
account and 2FA enabled via their settings
|
||||
</li>
|
||||
<li>
|
||||
<strong>None</strong> - No authentication required
|
||||
</li>
|
||||
@ -326,7 +367,12 @@ export const AddSignersFormPartial = ({
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
||||
signer.formId === selfSignerFormId ||
|
||||
signers[index].email === user?.email
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background w-[60px]">
|
||||
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
|
||||
@ -399,12 +445,28 @@ export const AddSignersFormPartial = ({
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={isSubmitting || signers.length >= remaining.recipients}
|
||||
onClick={() => onAddSigner()}
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Signer
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
signers.length >= remaining.recipients ||
|
||||
!!selfSignerFormId ||
|
||||
signers.some((signer) => signer.email === user?.email)
|
||||
}
|
||||
onClick={() => onAddSelfSigner()}
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add myself
|
||||
</Button>
|
||||
|
||||
{!alwaysShowAdvancedSettings && isDocumentEnterprise && (
|
||||
<div className="flex flex-row items-center">
|
||||
|
||||
Reference in New Issue
Block a user