mirror of
https://github.com/documenso/documenso.git
synced 2025-11-11 21:12:48 +10:00
Compare commits
43 Commits
experiment
...
feat/add-m
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d569292ce | |||
| b1474de2da | |||
| 6b8ff4567d | |||
| aa4b6f1723 | |||
| c8a09099a3 | |||
| 0f87dc047b | |||
| 80c758fb62 | |||
| 1a81b46859 | |||
| ef1d4ed0fa | |||
| 7705dbae0c | |||
| 8b58f10cbe | |||
| fe1f0e6a76 | |||
| a82975fd78 | |||
| a4967f19e8 | |||
| a311869c9b | |||
| 6f3cea52e8 | |||
| 732827f81d | |||
| bfff1234bb | |||
| 93a149d637 | |||
| f7ae3104ea | |||
| 64870f22b9 | |||
| 12e4bc918d | |||
| e36763a85d | |||
| 0bc9c590a7 | |||
| 4d4dfd3c5f | |||
| c9b4915fc8 | |||
| 110f9bae12 | |||
| 318a77c936 | |||
| 2bc0407d06 | |||
| 80de7d3ea9 | |||
| 6285ef2cc0 | |||
| e2987b3ef1 | |||
| d97ab04d57 | |||
| 665c943d8f | |||
| 8fe6533ef5 | |||
| fd170f095b | |||
| 1400c335a5 | |||
| 03bf16522d | |||
| 627265f016 | |||
| 08b693ff95 | |||
| 97ce3530e0 | |||
| 33f3565715 | |||
| 950a697115 |
@ -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
|
||||
|
||||
@ -17,7 +17,8 @@ For the digital signature of your documents you need a signing certificate in .p
|
||||
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
|
||||
|
||||
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
|
||||
5. Place the certificate `/apps/web/resources/certificate.p12`
|
||||
|
||||
5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
@ -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.
|
||||
@ -22,7 +22,7 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||
const config = {
|
||||
experimental: {
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
|
||||
BIN
apps/marketing/public/blog/eu-validate-1.png
Normal file
BIN
apps/marketing/public/blog/eu-validate-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
BIN
apps/marketing/public/blog/eu-validate-2.png
Normal file
BIN
apps/marketing/public/blog/eu-validate-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
@ -1,4 +1,5 @@
|
||||
import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types';
|
||||
import type { TClaimPlanRequestSchema } from './types';
|
||||
import { ZClaimPlanResponseSchema } from './types';
|
||||
|
||||
export const claimPlan = async ({
|
||||
name,
|
||||
|
||||
@ -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-black 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">
|
||||
|
||||
@ -55,6 +55,7 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
|
||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||
/>
|
||||
<Bar
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
dataKey={metricKey as string}
|
||||
maxBarSize={60}
|
||||
fill="hsl(var(--primary))"
|
||||
|
||||
@ -13,6 +13,7 @@ export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
|
||||
export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => {
|
||||
const formattedData = data.map((item) => ({
|
||||
amount: Number(item.amount),
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
date: formatMonth(item.date as string),
|
||||
}));
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import {
|
||||
|
||||
@ -2,13 +2,14 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Variants, motion } from 'framer-motion';
|
||||
import type { Variants } from 'framer-motion';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { TOSSFriendsSchema } from './schema';
|
||||
import type { TOSSFriendsSchema } from './schema';
|
||||
|
||||
const ContainerVariants: Variants = {
|
||||
initial: {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
import { allBlogPosts, allGenericPages } from 'contentlayer/generated';
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ 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';
|
||||
// import { StatusWidgetContainer } from './status-widget-container';
|
||||
|
||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
@ -65,9 +65,9 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{/* <div className="mt-6">
|
||||
<StatusWidgetContainer />
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
|
||||
|
||||
@ -96,7 +96,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
variants={HeroTitleVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="text-center text-4xl font-bold leading-tight tracking-tight lg:text-[64px]"
|
||||
className="text-center text-4xl font-bold leading-tight tracking-tight md:text-[48px] lg:text-[64px]"
|
||||
>
|
||||
Document signing,
|
||||
<span className="block" /> finally open source.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import { StatusWidget } from './status-widget';
|
||||
export function StatusWidgetContainer() {
|
||||
return (
|
||||
<Suspense fallback={<StatusWidgetFallback />}>
|
||||
<StatusWidget />
|
||||
<StatusWidget slug="documenso-status" />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { use, useMemo } from 'react';
|
||||
import { memo, use } from 'react';
|
||||
|
||||
import type { Status } from '@openstatus/react';
|
||||
import { getStatus } from '@openstatus/react';
|
||||
import { type Status, getStatus } from '@openstatus/react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
@ -45,9 +44,8 @@ const getStatusLevel = (level: Status) => {
|
||||
}[level];
|
||||
};
|
||||
|
||||
export function StatusWidget() {
|
||||
const getStatusMemoized = useMemo(async () => getStatus('documenso-status'), []);
|
||||
const { status } = use(getStatusMemoized);
|
||||
export const StatusWidget = memo(function StatusWidget({ slug }: { slug: string }) {
|
||||
const { status } = use(getStatus(slug));
|
||||
const level = getStatusLevel(status);
|
||||
|
||||
return (
|
||||
@ -72,4 +70,4 @@ export function StatusWidget() {
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -346,7 +346,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
{signatureText && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-foreground text-4xl font-semibold [font-family:var(--font-caveat)]',
|
||||
'text-foreground truncate text-4xl font-semibold [font-family:var(--font-caveat)]',
|
||||
)}
|
||||
>
|
||||
{signatureText}
|
||||
@ -360,7 +360,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
>
|
||||
<Input
|
||||
id="signatureText"
|
||||
className="text-foreground placeholder:text-muted-foreground border-none p-0 text-sm focus-visible:ring-0"
|
||||
className="text-foreground placeholder:text-muted-foreground truncate border-none p-0 text-sm focus-visible:ring-0"
|
||||
placeholder="Draw or type name here"
|
||||
disabled={isSubmitting}
|
||||
{...register('signatureText', {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { FieldError } from 'react-hook-form';
|
||||
import type { FieldError } from 'react-hook-form';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { SVGAttributes } from 'react';
|
||||
import type { SVGAttributes } from 'react';
|
||||
|
||||
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import { ThemeProviderProps } from 'next-themes/dist/types';
|
||||
import type { ThemeProviderProps } from 'next-themes/dist/types';
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
|
||||
@ -23,7 +23,7 @@ const config = {
|
||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||
experimental: {
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
|
||||
@ -4,7 +4,16 @@ import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
ScrollTextIcon,
|
||||
Share,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
@ -106,6 +115,13 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`${documentsPath}/${document.id}/logs`}>
|
||||
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||
Audit Log
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -1,19 +1,25 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft, DownloadIcon } from 'lucide-react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { 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';
|
||||
import { DownloadAuditLogButton } from './download-audit-log-button';
|
||||
import { DownloadCertificateButton } from './download-certificate-button';
|
||||
|
||||
export type DocumentLogsPageViewProps = {
|
||||
params: {
|
||||
@ -23,6 +29,8 @@ export type DocumentLogsPageViewProps = {
|
||||
};
|
||||
|
||||
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
|
||||
const locale = getLocale();
|
||||
|
||||
const { id } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
@ -67,15 +75,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 +104,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
||||
text = `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
||||
return `${text} - ${recipient.role}`;
|
||||
return `[${recipient.role}] ${text}`;
|
||||
};
|
||||
|
||||
return (
|
||||
@ -104,20 +118,24 @@ 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">
|
||||
<DownloadIcon className="mr-1.5 h-4 w-4" />
|
||||
Download certificate
|
||||
</Button>
|
||||
<DownloadCertificateButton className="mr-2" documentId={document.id} />
|
||||
|
||||
<Button className="w-full sm:w-auto">
|
||||
<DownloadIcon className="mr-1.5 h-4 w-4" />
|
||||
Download PDF
|
||||
</Button>
|
||||
<DownloadAuditLogButton documentId={document.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DownloadAuditLogButtonProps = {
|
||||
className?: string;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: downloadAuditLogs, isLoading } =
|
||||
trpc.document.downloadAuditLogs.useMutation();
|
||||
|
||||
const onDownloadAuditLogsClick = async () => {
|
||||
try {
|
||||
const { url } = await downloadAuditLogs({ documentId });
|
||||
|
||||
const iframe = Object.assign(document.createElement('iframe'), {
|
||||
src: url,
|
||||
});
|
||||
|
||||
Object.assign(iframe.style, {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '0',
|
||||
height: '0',
|
||||
});
|
||||
|
||||
const onLoaded = () => {
|
||||
if (iframe.contentDocument?.readyState === 'complete') {
|
||||
iframe.contentWindow?.print();
|
||||
|
||||
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||
document.body.removeChild(iframe);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||
iframe.addEventListener('load', onLoaded);
|
||||
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
onLoaded();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Sorry, we were unable to download the audit logs. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('w-full sm:w-auto', className)}
|
||||
loading={isLoading}
|
||||
onClick={() => void onDownloadAuditLogsClick()}
|
||||
>
|
||||
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||
Download Audit Logs
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DownloadCertificateButtonProps = {
|
||||
className?: string;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const DownloadCertificateButton = ({
|
||||
className,
|
||||
documentId,
|
||||
}: DownloadCertificateButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: downloadCertificate, isLoading } =
|
||||
trpc.document.downloadCertificate.useMutation();
|
||||
|
||||
const onDownloadCertificatesClick = async () => {
|
||||
try {
|
||||
const { url } = await downloadCertificate({ documentId });
|
||||
|
||||
const iframe = Object.assign(document.createElement('iframe'), {
|
||||
src: url,
|
||||
});
|
||||
|
||||
Object.assign(iframe.style, {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '0',
|
||||
height: '0',
|
||||
});
|
||||
|
||||
const onLoaded = () => {
|
||||
if (iframe.contentDocument?.readyState === 'complete') {
|
||||
iframe.contentWindow?.print();
|
||||
|
||||
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||
document.body.removeChild(iframe);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||
iframe.addEventListener('load', onLoaded);
|
||||
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
onLoaded();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Sorry, we were unable to download the certificate. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('w-full sm:w-auto', className)}
|
||||
loading={isLoading}
|
||||
variant="outline"
|
||||
onClick={() => void onDownloadCertificatesClick()}
|
||||
>
|
||||
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||
Download Certificate
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
|
||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
export type AuditLogDataTableProps = {
|
||||
logs: TDocumentAuditLog[];
|
||||
};
|
||||
|
||||
const dateFormat: DateTimeFormatOptions = {
|
||||
...DateTime.DATETIME_SHORT,
|
||||
hourCycle: 'h12',
|
||||
};
|
||||
|
||||
export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
|
||||
const parser = new UAParser();
|
||||
|
||||
const uppercaseFistLetter = (text: string) => {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>Browser</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody className="print:text-xs">
|
||||
{logs.map((log, i) => (
|
||||
<TableRow className="break-inside-avoid" key={i}>
|
||||
<TableCell>
|
||||
<LocaleDate format={dateFormat} date={log.createdAt} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{log.name || log.email ? (
|
||||
<div>
|
||||
{log.name && (
|
||||
<p className="break-all" title={log.name}>
|
||||
{log.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{log.email && (
|
||||
<p className="text-muted-foreground break-all" title={log.email}>
|
||||
{log.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p>N/A</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{uppercaseFistLetter(formatDocumentAuditLogAction(log).description)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>{log.ipAddress}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
139
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx
Normal file
139
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { AuditLogDataTable } from './data-table';
|
||||
|
||||
type AuditLogProps = {
|
||||
searchParams: {
|
||||
d: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
const { d } = searchParams;
|
||||
|
||||
if (typeof d !== 'string' || !d) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const rawDocumentId = decryptSecondaryData(d);
|
||||
|
||||
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const documentId = Number(rawDocumentId);
|
||||
|
||||
const document = await getEntireDocument({
|
||||
id: documentId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const { data: auditLogs } = await findDocumentAuditLogs({
|
||||
documentId: documentId,
|
||||
userId: document.userId,
|
||||
perPage: 100_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||
<div className="flex items-center">
|
||||
<h1 className="my-8 text-2xl font-bold">Version History</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
|
||||
<p>
|
||||
<span className="font-medium">Document ID</span>
|
||||
|
||||
<span className="mt-1 block break-words">{document.id}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Enclosed Document</span>
|
||||
|
||||
<span className="mt-1 block break-words">{document.title}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Status</span>
|
||||
|
||||
<span className="mt-1 block">{document.deletedAt ? 'DELETED' : document.status}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Owner</span>
|
||||
|
||||
<span className="mt-1 block break-words">
|
||||
{document.User.name} ({document.User.email})
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Created At</span>
|
||||
|
||||
<span className="mt-1 block">
|
||||
<LocaleDate date={document.createdAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Last Updated</span>
|
||||
|
||||
<span className="mt-1 block">
|
||||
<LocaleDate date={document.updatedAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Time Zone</span>
|
||||
|
||||
<span className="mt-1 block break-words">
|
||||
{document.documentMeta?.timezone ?? 'N/A'}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="font-medium">Recipients</p>
|
||||
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{document.Recipient.map((recipient) => (
|
||||
<li key={recipient.id}>
|
||||
<span className="text-muted-foreground">
|
||||
[{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}]
|
||||
</span>{' '}
|
||||
{recipient.name} ({recipient.email})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-8">
|
||||
<CardContent className="p-0">
|
||||
<AuditLogDataTable logs={auditLogs} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="my-8 flex-row-reverse">
|
||||
<div className="flex items-end justify-end gap-x-4">
|
||||
<Logo className="max-h-6 print:max-h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
299
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx
Normal file
299
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx
Normal file
@ -0,0 +1,299 @@
|
||||
import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_SIGNING_REASONS,
|
||||
} from '@documenso/lib/constants/recipient-roles';
|
||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
type SigningCertificateProps = {
|
||||
searchParams: {
|
||||
d: string;
|
||||
};
|
||||
};
|
||||
|
||||
const FRIENDLY_SIGNING_REASONS = {
|
||||
['__OWNER__']: 'I am the owner of this document',
|
||||
...RECIPIENT_ROLE_SIGNING_REASONS,
|
||||
};
|
||||
|
||||
export default async function SigningCertificate({ searchParams }: SigningCertificateProps) {
|
||||
const { d } = searchParams;
|
||||
|
||||
if (typeof d !== 'string' || !d) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const rawDocumentId = decryptSecondaryData(d);
|
||||
|
||||
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const documentId = Number(rawDocumentId);
|
||||
|
||||
const document = await getEntireDocument({
|
||||
id: documentId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const auditLogs = await getDocumentCertificateAuditLogs({
|
||||
id: documentId,
|
||||
});
|
||||
|
||||
const isOwner = (email: string) => {
|
||||
return email.toLowerCase() === document.User.email.toLowerCase();
|
||||
};
|
||||
|
||||
const getDevice = (userAgent?: string | null) => {
|
||||
if (!userAgent) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const parser = new UAParser(userAgent);
|
||||
|
||||
parser.setUA(userAgent);
|
||||
|
||||
const result = parser.getResult();
|
||||
|
||||
return `${result.os.name} - ${result.browser.name} ${result.browser.version}`;
|
||||
};
|
||||
|
||||
const getAuthenticationLevel = (recipientId: number) => {
|
||||
const recipient = document.Recipient.find((recipient) => recipient.id === recipientId);
|
||||
|
||||
if (!recipient) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const extractedAuthMethods = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth)
|
||||
.with('ACCOUNT', () => 'Account Re-Authentication')
|
||||
.with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication')
|
||||
.with('PASSKEY', () => 'Passkey Re-Authentication')
|
||||
.with('EXPLICIT_NONE', () => 'Email')
|
||||
.with(null, () => null)
|
||||
.exhaustive();
|
||||
|
||||
if (!authLevel) {
|
||||
authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth)
|
||||
.with('ACCOUNT', () => 'Account Authentication')
|
||||
.with(null, () => 'Email')
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
return authLevel;
|
||||
};
|
||||
|
||||
const getRecipientAuditLogs = (recipientId: number) => {
|
||||
return {
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT].filter(
|
||||
(log) =>
|
||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.data.recipientId === recipientId,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs[
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED
|
||||
].filter(
|
||||
(log) =>
|
||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED &&
|
||||
log.data.recipientId === recipientId,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs[
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED
|
||||
].filter(
|
||||
(log) =>
|
||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
|
||||
log.data.recipientId === recipientId,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const getRecipientSignatureField = (recipientId: number) => {
|
||||
return document.Recipient.find((recipient) => recipient.id === recipientId)?.Field.find(
|
||||
(field) => field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||
<div className="flex items-center">
|
||||
<h1 className="my-8 text-2xl font-bold">Signing Certificate</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Signer Events</TableHead>
|
||||
<TableHead>Signature</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
{/* <TableHead>Security</TableHead> */}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody className="print:text-xs">
|
||||
{document.Recipient.map((recipient, i) => {
|
||||
const logs = getRecipientAuditLogs(recipient.id);
|
||||
const signature = getRecipientSignatureField(recipient.id);
|
||||
|
||||
return (
|
||||
<TableRow key={i} className="print:break-inside-avoid">
|
||||
<TableCell truncate={false} className="w-[min-content] max-w-[220px] align-top">
|
||||
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
|
||||
<div className="break-all">{recipient.email}</div>
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">Authentication Level:</span>{' '}
|
||||
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
|
||||
</p>
|
||||
</TableCell>
|
||||
|
||||
<TableCell truncate={false} className="w-[min-content] align-top">
|
||||
{signature ? (
|
||||
<>
|
||||
<div
|
||||
className="inline-block rounded-lg p-1"
|
||||
style={{
|
||||
boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`${signature.Signature?.signatureImageAsBase64}`}
|
||||
alt="Signature"
|
||||
className="max-h-12 max-w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">Signature ID:</span>{' '}
|
||||
<span className="block font-mono uppercase">
|
||||
{signature.secondaryId}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">IP Address:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
|
||||
<span className="font-medium">Device:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground">N/A</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell truncate={false} className="w-[min-content] align-top">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Sent:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.EMAIL_SENT[0] ? (
|
||||
<LocaleDate
|
||||
date={logs.EMAIL_SENT[0].createdAt}
|
||||
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
|
||||
/>
|
||||
) : (
|
||||
'Unknown'
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Viewed:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_OPENED[0] ? (
|
||||
<LocaleDate
|
||||
date={logs.DOCUMENT_OPENED[0].createdAt}
|
||||
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
|
||||
/>
|
||||
) : (
|
||||
'Unknown'
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Signed:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0] ? (
|
||||
<LocaleDate
|
||||
date={logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt}
|
||||
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
|
||||
/>
|
||||
) : (
|
||||
'Unknown'
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Reason:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{isOwner(recipient.email)
|
||||
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
||||
: FRIENDLY_SIGNING_REASONS[recipient.role]}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="my-8 flex-row-reverse">
|
||||
<div className="flex items-end justify-end gap-x-4">
|
||||
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
||||
Signing certificate provided by:
|
||||
</p>
|
||||
|
||||
<Logo className="max-h-6 print:max-h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type ClaimAccountProps = {
|
||||
defaultName: string;
|
||||
defaultEmail: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ZClaimAccountFormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||
email: z.string().email().min(1),
|
||||
password: ZPasswordSchema,
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const { name, email, password } = data;
|
||||
return !password.includes(name) && !password.includes(email.split('@')[0]);
|
||||
},
|
||||
{
|
||||
message: 'Password should not be common or based on personal information',
|
||||
path: ['password'],
|
||||
},
|
||||
);
|
||||
|
||||
export type TClaimAccountFormSchema = z.infer<typeof ZClaimAccountFormSchema>;
|
||||
|
||||
export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) => {
|
||||
const analytics = useAnalytics();
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
||||
|
||||
const form = useForm<TClaimAccountFormSchema>({
|
||||
values: {
|
||||
name: defaultName ?? '',
|
||||
email: defaultEmail,
|
||||
password: '',
|
||||
},
|
||||
resolver: zodResolver(ZClaimAccountFormSchema),
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => {
|
||||
try {
|
||||
await signup({ name, email, password });
|
||||
|
||||
router.push(`/unverified-account`);
|
||||
|
||||
toast({
|
||||
title: 'Registration Successful',
|
||||
description:
|
||||
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
analytics.capture('App: User Claim Account', {
|
||||
email,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') {
|
||||
toast({
|
||||
title: 'An error occurred',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to sign you up. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 w-full">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="mt-4">
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Enter your name" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>Email address</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Enter your email" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>Set a password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="Pick a password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="mt-6 w-full" loading={form.formState.isSubmitting}>
|
||||
Claim account
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { CheckCircle2, Clock8 } from 'lucide-react';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { env } from 'next-runtime-env';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||
@ -16,10 +17,13 @@ import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/clie
|
||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
import { SigningAuthPageView } from '../signing-auth-page';
|
||||
import { ClaimAccount } from './claim-account';
|
||||
import { DocumentPreviewButton } from './document-preview-button';
|
||||
|
||||
export type CompletedSigningPageProps = {
|
||||
@ -31,6 +35,8 @@ export type CompletedSigningPageProps = {
|
||||
export default async function CompletedSigningPage({
|
||||
params: { token },
|
||||
}: CompletedSigningPageProps) {
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
|
||||
if (!token) {
|
||||
return notFound();
|
||||
}
|
||||
@ -79,96 +85,120 @@ export default async function CompletedSigningPage({
|
||||
|
||||
const sessionData = await getServerSession();
|
||||
const isLoggedIn = !!sessionData?.user;
|
||||
const canSignUp = !isLoggedIn && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
|
||||
|
||||
return (
|
||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
|
||||
{/* Card with recipient */}
|
||||
<SigningCard3D
|
||||
name={recipientName}
|
||||
signature={signatures.at(0)}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'-mx-4 flex flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
|
||||
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
|
||||
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
|
||||
canSignUp,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn('flex flex-col items-center', {
|
||||
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
|
||||
})}
|
||||
>
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncatedTitle}
|
||||
</Badge>
|
||||
|
||||
<div className="relative mt-6 flex w-full flex-col items-center">
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<div className="text-documenso-700 flex items-center text-center">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Everyone has signed</span>
|
||||
</div>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<div className="flex items-center text-center text-blue-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Waiting for others to sign</span>
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="flex items-center text-center text-red-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Document no longer available to sign</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Card with recipient */}
|
||||
<SigningCard3D
|
||||
name={recipientName}
|
||||
signature={signatures.at(0)}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
You have
|
||||
{recipient.role === RecipientRole.SIGNER && ' signed '}
|
||||
{recipient.role === RecipientRole.VIEWER && ' viewed '}
|
||||
{recipient.role === RecipientRole.APPROVER && ' approved '}
|
||||
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
||||
</h2>
|
||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
Document
|
||||
{recipient.role === RecipientRole.SIGNER && ' Signed '}
|
||||
{recipient.role === RecipientRole.VIEWER && ' Viewed '}
|
||||
{recipient.role === RecipientRole.APPROVER && ' Approved '}
|
||||
</h2>
|
||||
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
Everyone has signed! You will receive an Email copy of the signed document.
|
||||
</p>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
You will receive an Email copy of the signed document once everyone has signed.
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
This document has been cancelled by the owner and is no longer available for others to
|
||||
sign.
|
||||
</p>
|
||||
))}
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<div className="text-documenso-700 mt-4 flex items-center text-center">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Everyone has signed</span>
|
||||
</div>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<div className="flex items-center text-center text-blue-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Waiting for others to sign</span>
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="flex items-center text-center text-red-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Document no longer available to sign</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
Everyone has signed! You will receive an Email copy of the signed document.
|
||||
</p>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
You will receive an Email copy of the signed document once everyone has signed.
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
This document has been cancelled by the owner and is no longer available for others
|
||||
to sign.
|
||||
</p>
|
||||
))}
|
||||
|
||||
{document.status === DocumentStatus.COMPLETED ? (
|
||||
<DocumentDownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
documentData={documentData}
|
||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||
/>
|
||||
) : (
|
||||
<DocumentPreviewButton
|
||||
className="text-[11px]"
|
||||
title="Signatures will appear once the document has been completed"
|
||||
documentData={documentData}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||
|
||||
{document.status === DocumentStatus.COMPLETED ? (
|
||||
<DocumentDownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
documentData={documentData}
|
||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||
/>
|
||||
) : (
|
||||
<DocumentPreviewButton
|
||||
className="text-[11px]"
|
||||
title="Signatures will appear once the document has been completed"
|
||||
documentData={documentData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoggedIn ? (
|
||||
{canSignUp && (
|
||||
<div className={`flex max-w-xl flex-col items-center justify-center p-4 md:p-12`}>
|
||||
<h2 className="mt-8 text-center text-xl font-semibold md:mt-0">
|
||||
Need to sign documents?
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-4 max-w-[55ch] text-center leading-normal">
|
||||
Create your account and start using state-of-the-art document signing.
|
||||
</p>
|
||||
|
||||
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn && (
|
||||
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
|
||||
Go Back Home
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||
Want to send slick signing links like this one?{' '}
|
||||
<Link
|
||||
href="https://documenso.com"
|
||||
className="text-documenso-700 hover:text-documenso-600"
|
||||
>
|
||||
Check out Documenso.
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,6 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
@ -18,7 +17,6 @@ import {
|
||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import {
|
||||
CommandDialog,
|
||||
@ -71,7 +69,6 @@ export type CommandMenuProps = {
|
||||
|
||||
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
const { setTheme } = useTheme();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@ -93,17 +90,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
},
|
||||
);
|
||||
|
||||
const isOwner = useCallback(
|
||||
(document: Document) => document.userId === session?.user.id,
|
||||
[session?.user.id],
|
||||
);
|
||||
|
||||
const getSigningLink = useCallback(
|
||||
(recipients: Recipient[]) =>
|
||||
`/sign/${recipients.find((r) => r.email === session?.user.email)?.token}`,
|
||||
[session?.user.email],
|
||||
);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchDocumentsData) {
|
||||
return [];
|
||||
@ -111,10 +97,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
|
||||
return searchDocumentsData.map((document) => ({
|
||||
label: document.title,
|
||||
path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient),
|
||||
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
||||
path: document.path,
|
||||
value: document.value,
|
||||
}));
|
||||
}, [searchDocumentsData, isOwner, getSigningLink]);
|
||||
}, [searchDocumentsData]);
|
||||
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
|
||||
@ -93,7 +93,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
<Button
|
||||
data-testid="menu-switcher"
|
||||
variant="none"
|
||||
className="relative flex h-12 flex-row items-center px-2 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent"
|
||||
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
||||
@ -102,12 +102,13 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
rightSideComponent={
|
||||
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
||||
}
|
||||
textSectionClassName="hidden lg:flex"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className={cn('z-[60] ml-2 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
||||
className={cn('z-[60] ml-6 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
||||
align="end"
|
||||
forceMount
|
||||
>
|
||||
|
||||
@ -46,7 +46,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
|
||||
return (
|
||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<SheetContent className="flex w-full max-w-[400px] flex-col">
|
||||
<SheetContent className="flex w-full max-w-[350px] flex-col">
|
||||
<Link href="/" onClick={handleMenuItemClick}>
|
||||
<Image
|
||||
src={LogoImage}
|
||||
@ -87,7 +87,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
||||
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
||||
@ -11,17 +11,6 @@ services:
|
||||
ports:
|
||||
- 54320:5432
|
||||
|
||||
queue:
|
||||
image: postgres:15
|
||||
container_name: queue
|
||||
user: postgres
|
||||
command: -c jit=off
|
||||
environment:
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: queue
|
||||
ports:
|
||||
- 54321:5432
|
||||
|
||||
inbucket:
|
||||
image: inbucket/inbucket
|
||||
container_name: mailserver
|
||||
|
||||
288
package-lock.json
generated
288
package-lock.json
generated
@ -22,6 +22,7 @@
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"playwright": "^1.43.0",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
@ -4701,6 +4702,19 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/browser-chromium": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz",
|
||||
"integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.43.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz",
|
||||
@ -4716,6 +4730,50 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/playwright": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
|
||||
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.40.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/playwright-core": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
|
||||
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.4.2.tgz",
|
||||
@ -8443,18 +8501,6 @@
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aggregate-error": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
|
||||
"integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
|
||||
"dependencies": {
|
||||
"clean-stack": "^2.0.0",
|
||||
"indent-string": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.12.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
|
||||
@ -9429,14 +9475,6 @@
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
|
||||
},
|
||||
"node_modules/clean-stack": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||
"integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
|
||||
@ -10212,17 +10250,6 @@
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||
"dependencies": {
|
||||
"luxon": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
@ -10589,17 +10616,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delay": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz",
|
||||
"integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@ -13714,6 +13730,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@ -17291,20 +17308,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-map": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
|
||||
"integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
|
||||
"dependencies": {
|
||||
"aggregate-error": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
@ -17614,124 +17617,6 @@
|
||||
"is-reference": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.11.5",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.11.5.tgz",
|
||||
"integrity": "sha512-jqgNHSKL5cbDjFlHyYsCXmQDrfIX/3RsNwYqpd4N0Kt8niLuNoRNH+aazv6cOd43gPh9Y4DjQCtb+X0MH0Hvnw==",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.6.4",
|
||||
"pg-pool": "^3.6.2",
|
||||
"pg-protocol": "^1.6.1",
|
||||
"pg-types": "^2.1.0",
|
||||
"pgpass": "1.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-boss": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-9.0.3.tgz",
|
||||
"integrity": "sha512-cUWUiv3sr563yNy0nCZ25Tv5U0m59Y9MhX/flm0vTR012yeVCrqpfboaZP4xFOQPdWipMJpuu4g94HR0SncTgw==",
|
||||
"dependencies": {
|
||||
"cron-parser": "^4.0.0",
|
||||
"delay": "^5.0.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"p-map": "^4.0.0",
|
||||
"pg": "^8.5.1",
|
||||
"serialize-error": "^8.1.0",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-boss/node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz",
|
||||
"integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.6.4",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz",
|
||||
"integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA=="
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz",
|
||||
"integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz",
|
||||
"integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg=="
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass/node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
|
||||
@ -17788,12 +17673,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
|
||||
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
|
||||
"dev": true,
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz",
|
||||
"integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.40.0"
|
||||
"playwright-core": "1.43.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@ -17806,10 +17690,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
|
||||
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
|
||||
"dev": true,
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz",
|
||||
"integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
@ -17821,7 +17704,6 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -17990,41 +17872,6 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.93.2",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.93.2.tgz",
|
||||
@ -25134,7 +24981,7 @@
|
||||
"next-auth": "4.24.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg-boss": "^9.0.3",
|
||||
"playwright": "^1.43.0",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
"stripe": "^12.7.0",
|
||||
@ -25142,6 +24989,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/browser-chromium": "^1.43.0",
|
||||
"@types/luxon": "^3.3.1"
|
||||
}
|
||||
},
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"playwright": "^1.43.0",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
@ -59,4 +60,4 @@
|
||||
"next": "14.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -2,16 +2,34 @@ import { generateOpenApi } from '@ts-rest/open-api';
|
||||
|
||||
import { ApiContractV1 } from './contract';
|
||||
|
||||
export const OpenAPIV1 = generateOpenApi(
|
||||
ApiContractV1,
|
||||
{
|
||||
info: {
|
||||
title: 'Documenso API',
|
||||
version: '1.0.0',
|
||||
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
|
||||
export const OpenAPIV1 = Object.assign(
|
||||
generateOpenApi(
|
||||
ApiContractV1,
|
||||
{
|
||||
info: {
|
||||
title: 'Documenso API',
|
||||
version: '1.0.0',
|
||||
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
setOperationId: true,
|
||||
},
|
||||
),
|
||||
{
|
||||
setOperationId: true,
|
||||
components: {
|
||||
securitySchemes: {
|
||||
authorization: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'Authorization',
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [
|
||||
{
|
||||
authorization: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
@ -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<
|
||||
|
||||
@ -254,7 +254,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL(`/sign/${token}/complete`);
|
||||
await expect(page.getByText('You have signed')).toBeVisible();
|
||||
await expect(page.getByText('Document Signed')).toBeVisible();
|
||||
|
||||
// Check if document has been signed
|
||||
const { status: completedStatus } = await getDocumentByToken(token);
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { SendMailOptions } from 'nodemailer';
|
||||
import { createTransport } from 'nodemailer';
|
||||
|
||||
import { ResendTransport } from '@documenso/nodemailer-resend';
|
||||
@ -55,4 +54,3 @@ const getTransport = () => {
|
||||
};
|
||||
|
||||
export const mailer = getTransport();
|
||||
export type MailOptions = SendMailOptions;
|
||||
|
||||
@ -32,3 +32,10 @@ export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
|
||||
[RecipientRole.VIEWER]: 'VIEW_REQUEST',
|
||||
[RecipientRole.APPROVER]: 'APPROVE_REQUEST',
|
||||
} as const;
|
||||
|
||||
export const RECIPIENT_ROLE_SIGNING_REASONS = {
|
||||
[RecipientRole.SIGNER]: 'I am a signer of this document',
|
||||
[RecipientRole.APPROVER]: 'I am an approver of this document',
|
||||
[RecipientRole.CC]: 'I am required to recieve a copy of this document',
|
||||
[RecipientRole.VIEWER]: 'I am a viewer of this document',
|
||||
} satisfies Record<keyof typeof RecipientRole, string>;
|
||||
|
||||
@ -27,19 +27,19 @@
|
||||
"@next-auth/prisma-adapter": "1.0.7",
|
||||
"@noble/ciphers": "0.4.0",
|
||||
"@noble/hashes": "1.3.2",
|
||||
"@node-rs/bcrypt": "^1.10.0",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@scure/base": "^1.1.3",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@upstash/redis": "^1.20.6",
|
||||
"@vvo/tzdb": "^6.117.0",
|
||||
"@node-rs/bcrypt": "^1.10.0",
|
||||
"luxon": "^3.4.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "14.0.3",
|
||||
"next-auth": "4.24.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg-boss": "^9.0.3",
|
||||
"playwright": "^1.43.0",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
"stripe": "^12.7.0",
|
||||
@ -47,6 +47,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.3.1"
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@playwright/browser-chromium": "^1.43.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,14 @@ export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
User: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
Recipient: {
|
||||
include: {
|
||||
Field: {
|
||||
|
||||
@ -2,11 +2,12 @@
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { diffDocumentMetaChanges } from '@documenso/lib/utils/document-audit-logs';
|
||||
import {
|
||||
createDocumentAuditLogData,
|
||||
diffDocumentMetaChanges,
|
||||
} from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { queueJob } from '../queue/job';
|
||||
|
||||
export type CreateDocumentMetaOptions = {
|
||||
documentId: number;
|
||||
subject?: string;
|
||||
@ -64,45 +65,46 @@ export const upsertDocumentMeta = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const upsertedDocumentMeta = await prisma.documentMeta.upsert({
|
||||
where: {
|
||||
documentId,
|
||||
},
|
||||
create: {
|
||||
subject,
|
||||
message,
|
||||
password,
|
||||
dateFormat,
|
||||
timezone,
|
||||
documentId,
|
||||
redirectUrl,
|
||||
},
|
||||
update: {
|
||||
subject,
|
||||
message,
|
||||
password,
|
||||
dateFormat,
|
||||
timezone,
|
||||
redirectUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
|
||||
|
||||
if (changes.length > 0) {
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const upsertedDocumentMeta = await tx.documentMeta.upsert({
|
||||
where: {
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
|
||||
},
|
||||
},
|
||||
create: {
|
||||
subject,
|
||||
message,
|
||||
password,
|
||||
dateFormat,
|
||||
timezone,
|
||||
documentId,
|
||||
redirectUrl,
|
||||
},
|
||||
update: {
|
||||
subject,
|
||||
message,
|
||||
password,
|
||||
dateFormat,
|
||||
timezone,
|
||||
redirectUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return upsertedDocumentMeta;
|
||||
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
|
||||
|
||||
if (changes.length > 0) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return upsertedDocumentMeta;
|
||||
});
|
||||
};
|
||||
|
||||
@ -2,14 +2,15 @@
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import { queueJob } from '../queue/job';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sealDocument } from './seal-document';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
|
||||
export type CompleteDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
@ -92,34 +93,35 @@ export const completeDocumentWithToken = async ({
|
||||
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
|
||||
// }
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
signedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
// actionAuth: derivedRecipientActionAuth || undefined,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
signedAt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
// actionAuth: derivedRecipientActionAuth || undefined,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const pendingRecipients = await prisma.recipient.count({
|
||||
@ -132,13 +134,7 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
|
||||
if (pendingRecipients > 0) {
|
||||
await queueJob({
|
||||
job: 'send-pending-email',
|
||||
args: {
|
||||
documentId: document.id,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
await sendPendingEmail({ documentId, recipientId: recipient.id });
|
||||
}
|
||||
|
||||
const documents = await prisma.document.updateMany({
|
||||
|
||||
@ -3,10 +3,10 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { queueJob } from '../queue/job';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CreateDocumentOptions = {
|
||||
@ -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({
|
||||
@ -44,34 +46,36 @@ export const createDocument = async ({
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
|
||||
}
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
title,
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title,
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
formValues,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: document,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
title,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return document;
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: document,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return document;
|
||||
});
|
||||
};
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -11,7 +12,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { queueJob } from '../queue/job';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type DeleteDocumentOptions = {
|
||||
id: number;
|
||||
@ -60,22 +61,23 @@ export const deleteDocument = async ({
|
||||
|
||||
// if the document is a draft, hard-delete
|
||||
if (status === DocumentStatus.DRAFT) {
|
||||
// Currently redundant since deleting a document will delete the audit logs.
|
||||
// However may be useful if we disassociate audit lgos and documents if required.
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
documentId: id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'HARD',
|
||||
},
|
||||
},
|
||||
});
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Currently redundant since deleting a document will delete the audit logs.
|
||||
// However may be useful if we disassociate audit lgos and documents if required.
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'HARD',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return await prisma.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
|
||||
return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
|
||||
});
|
||||
}
|
||||
|
||||
// if the document is pending, send cancellation emails to all recipients
|
||||
@ -91,46 +93,44 @@ export const deleteDocument = async ({
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'send-mail',
|
||||
args: {
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document Cancelled',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document Cancelled',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// If the document is not a draft, only soft-delete.
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
documentId: id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'SOFT',
|
||||
},
|
||||
},
|
||||
});
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'SOFT',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return await prisma.document.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date().toISOString(),
|
||||
},
|
||||
return await tx.document.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../types/document-audit-logs';
|
||||
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type GetDocumentCertificateAuditLogsOptions = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export const getDocumentCertificateAuditLogs = async ({
|
||||
id,
|
||||
}: GetDocumentCertificateAuditLogsOptions) => {
|
||||
const rawAuditLogs = await prisma.documentAuditLog.findMany({
|
||||
where: {
|
||||
documentId: id,
|
||||
type: {
|
||||
in: [
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const auditLogs = rawAuditLogs.map((log) => parseDocumentAuditLogData(log));
|
||||
|
||||
const groupedAuditLogs = {
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter(
|
||||
(log) =>
|
||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT &&
|
||||
log.data.emailType !== DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED,
|
||||
),
|
||||
} as const;
|
||||
|
||||
return groupedAuditLogs;
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
@ -9,13 +10,13 @@ import {
|
||||
} from '@documenso/lib/constants/recipient-roles';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { queueJob } from '../queue/job';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export type ResendDocumentOptions = {
|
||||
@ -109,42 +110,43 @@ export const resendDocument = async ({
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
|
||||
await queueJob({
|
||||
job: 'send-mail',
|
||||
args: {
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
},
|
||||
});
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: true,
|
||||
},
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,20 +5,22 @@ import path from 'node:path';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
import { signPdf } from '@documenso/signing';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { putFile } from '../../universal/upload/put-file';
|
||||
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
||||
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
|
||||
import { queueJob } from '../queue/job';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sendCompletedEmail } from './send-completed-email';
|
||||
|
||||
export type SealDocumentOptions = {
|
||||
documentId: number;
|
||||
@ -90,6 +92,10 @@ export const sealDocument = async ({
|
||||
// !: Need to write the fields onto the document as a hard copy
|
||||
const pdfData = await getFile(documentData);
|
||||
|
||||
const certificate = await getCertificatePdf({ documentId }).then(async (doc) =>
|
||||
PDFDocument.load(doc),
|
||||
);
|
||||
|
||||
const doc = await PDFDocument.load(pdfData);
|
||||
|
||||
// Normalize and flatten layers that could cause issues with the signature
|
||||
@ -97,6 +103,12 @@ export const sealDocument = async ({
|
||||
doc.getForm().flatten();
|
||||
flattenAnnotations(doc);
|
||||
|
||||
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
|
||||
|
||||
certificatePages.forEach((page) => {
|
||||
doc.addPage(page);
|
||||
});
|
||||
|
||||
for (const field of fields) {
|
||||
await insertFieldInPDF(doc, field);
|
||||
}
|
||||
@ -125,38 +137,46 @@ export const sealDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.documentData.update({
|
||||
where: {
|
||||
id: documentData.id,
|
||||
},
|
||||
data: {
|
||||
data: newData,
|
||||
},
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
|
||||
documentId: document.id,
|
||||
requestMetadata,
|
||||
user: null,
|
||||
data: {
|
||||
transactionId: nanoid(),
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.documentData.update({
|
||||
where: {
|
||||
id: documentData.id,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
data: newData,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
|
||||
documentId: document.id,
|
||||
requestMetadata,
|
||||
user: null,
|
||||
data: {
|
||||
transactionId: nanoid(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
if (sendEmail && !isResealing) {
|
||||
await queueJob({
|
||||
job: 'send-completed-email',
|
||||
args: { documentId, requestMetadata },
|
||||
});
|
||||
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,
|
||||
});
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
|
||||
export type SearchDocumentsWithKeywordOptions = {
|
||||
query: string;
|
||||
@ -79,12 +78,19 @@ export const searchDocumentsWithKeyword = async ({
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const maskedDocuments = documents.map((document) =>
|
||||
maskRecipientTokensForDocument({
|
||||
document,
|
||||
user,
|
||||
}),
|
||||
);
|
||||
const isOwner = (document: Document, user: User) => document.userId === user.id;
|
||||
const getSigningLink = (recipients: Recipient[], user: User) =>
|
||||
`/sign/${recipients.find((r) => r.email === user.email)?.token}`;
|
||||
|
||||
const maskedDocuments = documents.map((document) => {
|
||||
const { Recipient, ...documentWithoutRecipient } = document;
|
||||
|
||||
return {
|
||||
...documentWithoutRecipient,
|
||||
path: isOwner(document, user) ? `/documents/${document.id}` : getSigningLink(Recipient, user),
|
||||
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
||||
};
|
||||
});
|
||||
|
||||
return maskedDocuments;
|
||||
};
|
||||
|
||||
@ -9,7 +9,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { queueJob } from '../queue/job';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export interface SendDocumentOptions {
|
||||
documentId: number;
|
||||
@ -86,9 +86,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
],
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user: null,
|
||||
@ -101,7 +100,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
recipientRole: 'OWNER',
|
||||
isResending: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@ -137,9 +136,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
],
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user: null,
|
||||
@ -152,7 +150,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
recipientRole: recipient.role,
|
||||
isResending: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@ -6,6 +6,7 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||
@ -16,7 +17,9 @@ import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
} from '../../constants/recipient-roles';
|
||||
import { queueJob } from '../queue/job';
|
||||
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) {
|
||||
@ -113,75 +149,79 @@ export const sendDocument = async ({
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
|
||||
// TODO: Move this to a seperate queue of it's own
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
await tx.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: false,
|
||||
},
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
if (document.status === DocumentStatus.DRAFT) {
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||
documentId: document.id,
|
||||
requestMetadata,
|
||||
user,
|
||||
data: {},
|
||||
const updatedDocument = await prisma.$transaction(async (tx) => {
|
||||
if (document.status === DocumentStatus.DRAFT) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||
documentId: document.id,
|
||||
requestMetadata,
|
||||
user,
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedDocument = await prisma.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -11,7 +12,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { queueJob } from '../queue/job';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type SuperDeleteDocumentOptions = {
|
||||
id: number;
|
||||
@ -48,39 +49,37 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'send-mail',
|
||||
args: {
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document Cancelled',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document Cancelled',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
documentId: id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'HARD',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// always hard delete if deleted from admin
|
||||
return await prisma.document.delete({ where: { id } });
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'HARD',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return await tx.document.delete({ where: { id } });
|
||||
});
|
||||
};
|
||||
|
||||
@ -2,10 +2,9 @@
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { queueJob } from '../queue/job';
|
||||
|
||||
export type UpdateTitleOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
@ -52,32 +51,33 @@ export const updateTitle = async ({
|
||||
return document;
|
||||
}
|
||||
|
||||
// Instead of doing everything in a transaction we can use our knowledge
|
||||
// of the current document title to ensure we aren't performing a conflicting
|
||||
// update.
|
||||
const updatedDocument = await prisma.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
title: document.title,
|
||||
},
|
||||
data: {
|
||||
title,
|
||||
},
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
from: document.title,
|
||||
to: updatedDocument.title,
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Instead of doing everything in a transaction we can use our knowledge
|
||||
// of the current document title to ensure we aren't performing a conflicting
|
||||
// update.
|
||||
const updatedDocument = await tx.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
title: document.title,
|
||||
},
|
||||
},
|
||||
});
|
||||
data: {
|
||||
title,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedDocument;
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
from: document.title,
|
||||
to: updatedDocument.title,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return updatedDocument;
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { ReadStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import type { TDocumentAccessAuthTypes } from '../../types/document-auth';
|
||||
import { queueJob } from '../queue/job';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getDocumentAndRecipientByToken } from './get-document-by-token';
|
||||
|
||||
@ -33,33 +33,34 @@ export const viewedDocument = async ({
|
||||
|
||||
const { documentId } = recipient;
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
readStatus: ReadStatus.OPENED,
|
||||
},
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
documentId,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientId: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
accessAuth: recipientAccessAuth || undefined,
|
||||
readStatus: ReadStatus.OPENED,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
documentId,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientId: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
accessAuth: recipientAccessAuth || undefined,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const document = await getDocumentAndRecipientByToken({ token, requireAccessAuth: false });
|
||||
|
||||
@ -2,7 +2,7 @@ import { prisma } from '@documenso/prisma';
|
||||
import type { FieldType, Team } from '@documenso/prisma/client';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { queueJob } from '../queue/job';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type CreateFieldOptions = {
|
||||
documentId: number;
|
||||
@ -103,9 +103,8 @@ export const createField = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: 'FIELD_CREATED',
|
||||
documentId,
|
||||
user: {
|
||||
@ -120,7 +119,7 @@ export const createField = async ({
|
||||
fieldType: field.type,
|
||||
},
|
||||
requestMetadata,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return field;
|
||||
|
||||
@ -2,7 +2,7 @@ import { prisma } from '@documenso/prisma';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { queueJob } from '../queue/job';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type DeleteFieldOptions = {
|
||||
fieldId: number;
|
||||
@ -67,9 +67,8 @@ export const deleteField = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: 'FIELD_DELETED',
|
||||
documentId,
|
||||
user: {
|
||||
@ -84,7 +83,7 @@ export const deleteField = async ({
|
||||
fieldType: field.type,
|
||||
},
|
||||
requestMetadata,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return field;
|
||||
|
||||
@ -2,11 +2,10 @@
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { queueJob } from '../queue/job';
|
||||
|
||||
export type RemovedSignedFieldWithTokenOptions = {
|
||||
token: string;
|
||||
fieldId: number;
|
||||
@ -66,22 +65,21 @@ export const removeSignedFieldWithToken = async ({
|
||||
fieldId: field.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
name: recipient?.name,
|
||||
email: recipient?.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
field: field.type,
|
||||
fieldId: field.secondaryId,
|
||||
},
|
||||
},
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
name: recipient?.name,
|
||||
email: recipient?.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
field: field.type,
|
||||
fieldId: field.secondaryId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -8,8 +8,6 @@ import { prisma } from '@documenso/prisma';
|
||||
import type { Field, FieldType } from '@documenso/prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { queueJob } from '../queue/job';
|
||||
|
||||
export interface SetFieldsForDocumentOptions {
|
||||
userId: number;
|
||||
documentId: number;
|
||||
@ -157,9 +155,8 @@ export const setFieldsForDocument = async ({
|
||||
|
||||
// Handle field updated audit log.
|
||||
if (field._persisted && changes.length > 0) {
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
|
||||
documentId: documentId,
|
||||
user,
|
||||
@ -168,15 +165,14 @@ export const setFieldsForDocument = async ({
|
||||
changes,
|
||||
...baseAuditLog,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Handle field created audit log.
|
||||
if (!field._persisted) {
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
|
||||
documentId: documentId,
|
||||
user,
|
||||
@ -184,7 +180,7 @@ export const setFieldsForDocument = async ({
|
||||
data: {
|
||||
...baseAuditLog,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -12,9 +12,9 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
|
||||
import { queueJob } from '../queue/job';
|
||||
|
||||
export type SignFieldWithTokenOptions = {
|
||||
token: string;
|
||||
@ -168,9 +168,8 @@ export const signFieldWithToken = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
@ -200,7 +199,7 @@ export const signFieldWithToken = async ({
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return updatedField;
|
||||
|
||||
@ -3,8 +3,7 @@ import type { FieldType, Team } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { diffFieldChanges } from '../../utils/document-audit-logs';
|
||||
import { queueJob } from '../queue/job';
|
||||
import { createDocumentAuditLogData, diffFieldChanges } from '../../utils/document-audit-logs';
|
||||
|
||||
export type UpdateFieldOptions = {
|
||||
fieldId: number;
|
||||
@ -78,9 +77,8 @@ export const updateField = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
|
||||
documentId,
|
||||
user: {
|
||||
@ -96,7 +94,7 @@ export const updateField = async ({
|
||||
changes: diffFieldChanges(oldField, updatedField),
|
||||
},
|
||||
requestMetadata,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return updatedField;
|
||||
|
||||
45
packages/lib/server-only/htmltopdf/get-certificate-pdf.ts
Normal file
45
packages/lib/server-only/htmltopdf/get-certificate-pdf.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Browser } from 'playwright';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { encryptSecondaryData } from '../crypto/encrypt';
|
||||
|
||||
export type GetCertificatePdfOptions = {
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions) => {
|
||||
const encryptedId = encryptSecondaryData({
|
||||
data: documentId.toString(),
|
||||
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
|
||||
});
|
||||
|
||||
let browser: Browser;
|
||||
|
||||
if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) {
|
||||
browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
|
||||
} else {
|
||||
browser = await chromium.launch();
|
||||
}
|
||||
|
||||
if (!browser) {
|
||||
throw new Error(
|
||||
'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed',
|
||||
);
|
||||
}
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
|
||||
const result = await page.pdf({
|
||||
format: 'A4',
|
||||
});
|
||||
|
||||
void browser.close();
|
||||
|
||||
return result;
|
||||
};
|
||||
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));
|
||||
};
|
||||
@ -1,52 +0,0 @@
|
||||
import type { WorkHandler } from 'pg-boss';
|
||||
import PgBoss from 'pg-boss';
|
||||
|
||||
import { jobHandlers } from './job';
|
||||
|
||||
type QueueState = {
|
||||
isReady: boolean;
|
||||
queue: PgBoss | null;
|
||||
};
|
||||
|
||||
let initPromise: Promise<PgBoss> | null = null;
|
||||
const state: QueueState = {
|
||||
isReady: false,
|
||||
queue: null,
|
||||
};
|
||||
|
||||
export async function initQueue() {
|
||||
if (state.isReady) {
|
||||
return state.queue as PgBoss;
|
||||
}
|
||||
|
||||
if (initPromise) {
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
const queue = new PgBoss({
|
||||
connectionString: 'postgres://postgres:password@127.0.0.1:54321/queue',
|
||||
|
||||
schema: 'documenso_queue',
|
||||
});
|
||||
|
||||
try {
|
||||
await queue.start();
|
||||
} catch (error) {
|
||||
console.error('Failed to start queue', error);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(jobHandlers).map(async ([job, jobHandler]) => {
|
||||
await queue.work(job, jobHandler as WorkHandler<unknown>);
|
||||
}),
|
||||
);
|
||||
|
||||
state.isReady = true;
|
||||
state.queue = queue;
|
||||
|
||||
return queue;
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
import type { WorkHandler } from 'pg-boss';
|
||||
|
||||
import type { MailOptions } from '@documenso/email/mailer';
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { initQueue } from '.';
|
||||
import type { CreateDocumentAuditLogDataOptions } from '../../utils/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import {
|
||||
type SendDocumentOptions as SendCompletedDocumentOptions,
|
||||
sendCompletedEmail,
|
||||
} from '../document/send-completed-email';
|
||||
import { type SendPendingEmailOptions, sendPendingEmail } from '../document/send-pending-email';
|
||||
|
||||
type JobOptions = {
|
||||
'send-mail': MailOptions;
|
||||
'send-completed-email': SendCompletedDocumentOptions;
|
||||
'send-pending-email': SendPendingEmailOptions;
|
||||
'create-document-audit-log': CreateDocumentAuditLogDataOptions;
|
||||
};
|
||||
|
||||
export const jobHandlers: {
|
||||
[K in keyof JobOptions]: WorkHandler<JobOptions[K]>;
|
||||
} = {
|
||||
'send-completed-email': async ({ id, name, data: { documentId, requestMetadata } }) => {
|
||||
console.log('Running Queue: ', name, ' ', id);
|
||||
|
||||
await sendCompletedEmail({
|
||||
documentId,
|
||||
requestMetadata,
|
||||
});
|
||||
},
|
||||
'send-pending-email': async ({ id, name, data: { documentId, recipientId } }) => {
|
||||
console.log('Running Queue: ', name, ' ', id);
|
||||
|
||||
await sendPendingEmail({
|
||||
documentId,
|
||||
recipientId,
|
||||
});
|
||||
},
|
||||
'send-mail': async ({ id, name, data: { attachments, to, from, subject, html, text } }) => {
|
||||
console.log('Running Queue: ', name, ' ', id);
|
||||
|
||||
await mailer.sendMail({
|
||||
to,
|
||||
from,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
attachments,
|
||||
});
|
||||
},
|
||||
|
||||
// Audit Logs Queue
|
||||
'create-document-audit-log': async ({
|
||||
name,
|
||||
data: { documentId, type, requestMetadata, user, data },
|
||||
id,
|
||||
}) => {
|
||||
console.log('Running Queue: ', name, ' ', id);
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type,
|
||||
documentId,
|
||||
requestMetadata,
|
||||
user,
|
||||
data,
|
||||
}),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const queueJob = async ({
|
||||
job,
|
||||
args,
|
||||
}: {
|
||||
job: keyof JobOptions;
|
||||
args?: JobOptions[keyof JobOptions];
|
||||
}) => {
|
||||
const queue = await initQueue();
|
||||
|
||||
await queue.send(job, args ?? {});
|
||||
};
|
||||
@ -3,7 +3,7 @@ import type { Team } from '@documenso/prisma/client';
|
||||
import { SendStatus } from '@documenso/prisma/client';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { queueJob } from '../queue/job';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type DeleteRecipientOptions = {
|
||||
documentId: number;
|
||||
@ -73,30 +73,33 @@ export const deleteRecipient = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const deletedRecipient = await prisma.recipient.delete({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
});
|
||||
const deletedRecipient = await prisma.$transaction(async (tx) => {
|
||||
const deleted = await tx.recipient.delete({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
type: 'RECIPIENT_DELETED',
|
||||
documentId,
|
||||
user: {
|
||||
id: team?.id ?? user.id,
|
||||
email: team?.name ?? user.email,
|
||||
name: team ? '' : user.name,
|
||||
},
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
},
|
||||
requestMetadata,
|
||||
},
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: 'RECIPIENT_DELETED',
|
||||
documentId,
|
||||
user: {
|
||||
id: team?.id ?? user.id,
|
||||
email: team?.name ?? user.email,
|
||||
name: team ? '' : user.name,
|
||||
},
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
},
|
||||
requestMetadata,
|
||||
}),
|
||||
});
|
||||
|
||||
return deleted;
|
||||
});
|
||||
|
||||
return deletedRecipient;
|
||||
|
||||
@ -17,7 +17,6 @@ import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { queueJob } from '../queue/job';
|
||||
|
||||
export interface SetRecipientsForDocumentOptions {
|
||||
userId: number;
|
||||
@ -204,9 +203,8 @@ export const setRecipientsForDocument = async ({
|
||||
|
||||
// Handle recipient updated audit log.
|
||||
if (recipient._persisted && changes.length > 0) {
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
documentId: documentId,
|
||||
user,
|
||||
@ -215,15 +213,14 @@ export const setRecipientsForDocument = async ({
|
||||
changes,
|
||||
...baseAuditLog,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Handle recipient created audit log.
|
||||
if (!recipient._persisted) {
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
|
||||
documentId: documentId,
|
||||
user,
|
||||
@ -232,7 +229,7 @@ export const setRecipientsForDocument = async ({
|
||||
...baseAuditLog,
|
||||
actionAuth: recipient.actionAuth || undefined,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -3,8 +3,7 @@ import type { RecipientRole, Team } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { diffRecipientChanges } from '../../utils/document-audit-logs';
|
||||
import { queueJob } from '../queue/job';
|
||||
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
|
||||
|
||||
export type UpdateRecipientOptions = {
|
||||
documentId: number;
|
||||
@ -76,43 +75,44 @@ export const updateRecipient = async ({
|
||||
throw new Error('Recipient not found');
|
||||
}
|
||||
|
||||
const updatedRecipient = await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
email: email?.toLowerCase() ?? recipient.email,
|
||||
name: name ?? recipient.name,
|
||||
role: role ?? recipient.role,
|
||||
},
|
||||
});
|
||||
|
||||
const changes = diffRecipientChanges(recipient, updatedRecipient);
|
||||
|
||||
if (changes.length > 0) {
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
documentId: documentId,
|
||||
user: {
|
||||
id: team?.id ?? user.id,
|
||||
name: team?.name ?? user.name,
|
||||
email: team ? '' : user.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
changes,
|
||||
recipientId,
|
||||
recipientEmail: updatedRecipient.email,
|
||||
recipientName: updatedRecipient.name,
|
||||
recipientRole: updatedRecipient.role,
|
||||
},
|
||||
const updatedRecipient = await prisma.$transaction(async (tx) => {
|
||||
const persisted = await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
email: email?.toLowerCase() ?? recipient.email,
|
||||
name: name ?? recipient.name,
|
||||
role: role ?? recipient.role,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedRecipient;
|
||||
}
|
||||
const changes = diffRecipientChanges(recipient, persisted);
|
||||
|
||||
if (changes.length > 0) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
documentId: documentId,
|
||||
user: {
|
||||
id: team?.id ?? user.id,
|
||||
name: team?.name ?? user.name,
|
||||
email: team ? '' : user.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
changes,
|
||||
recipientId,
|
||||
recipientEmail: persisted.email,
|
||||
recipientName: persisted.name,
|
||||
recipientRole: persisted.role,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return persisted;
|
||||
}
|
||||
});
|
||||
|
||||
return updatedRecipient;
|
||||
};
|
||||
|
||||
@ -2,6 +2,7 @@ import { createElement } from 'react';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
@ -12,8 +13,6 @@ import { createTokenVerification } from '@documenso/lib/utils/token-verification
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { queueJob } from '../queue/job';
|
||||
|
||||
export type CreateTeamEmailVerificationOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
@ -123,17 +122,14 @@ export const sendTeamEmailVerificationEmail = async (
|
||||
token,
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'send-mail',
|
||||
args: {
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `A request to use your email has been initiated by ${teamName} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `A request to use your email has been initiated by ${teamName} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
};
|
||||
|
||||
@ -2,6 +2,7 @@ import { createElement } from 'react';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import type { TeamInviteEmailProps } from '@documenso/email/templates/team-invite';
|
||||
import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite';
|
||||
@ -14,8 +15,6 @@ import { prisma } from '@documenso/prisma';
|
||||
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
|
||||
import type { TCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
|
||||
import { queueJob } from '../queue/job';
|
||||
|
||||
export type CreateTeamMemberInvitesOptions = {
|
||||
userId: number;
|
||||
userName: string;
|
||||
@ -149,17 +148,14 @@ export const sendTeamMemberInviteEmail = async ({
|
||||
...emailTemplateOptions,
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'send-mail',
|
||||
args: {
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `You have been invited to join ${emailTemplateOptions.teamName} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `You have been invited to join ${emailTemplateOptions.teamName} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
@ -7,8 +8,6 @@ import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { queueJob } from '../queue/job';
|
||||
|
||||
export type DeleteTeamEmailOptions = {
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
@ -74,21 +73,18 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
|
||||
teamUrl: team.url,
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
to: {
|
||||
address: team.owner.email,
|
||||
name: team.owner.name ?? '',
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `Team email has been revoked for ${team.name}`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: team.owner.email,
|
||||
name: team.owner.name ?? '',
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `Team email has been revoked for ${team.name}`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
} catch (e) {
|
||||
// Todo: Teams - Alert us.
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { TeamTransferRequestTemplate } from '@documenso/email/templates/team-transfer-request';
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
@ -7,8 +8,6 @@ import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { queueJob } from '../queue/job';
|
||||
|
||||
export type RequestTeamOwnershipTransferOptions = {
|
||||
/**
|
||||
* The ID of the user initiating the transfer.
|
||||
@ -94,18 +93,15 @@ export const requestTeamOwnershipTransfer = async ({
|
||||
token,
|
||||
});
|
||||
|
||||
await queueJob({
|
||||
job: 'create-document-audit-log',
|
||||
args: {
|
||||
to: newOwnerUser.email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
await mailer.sendMail({
|
||||
to: newOwnerUser.email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
|
||||
@ -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(),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@ import {
|
||||
import { ZRecipientAuthOptionsSchema } from '../types/document-auth';
|
||||
import type { RequestMetadata } from '../universal/extract-request-metadata';
|
||||
|
||||
export type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
|
||||
type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
|
||||
documentId: number;
|
||||
type: T;
|
||||
data: Extract<TDocumentAuditLog, { type: T }>['data'];
|
||||
|
||||
@ -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[]
|
||||
|
||||
@ -7,6 +7,9 @@ module.exports = {
|
||||
content: ['src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
screens: {
|
||||
print: { raw: 'print' },
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['var(--font-sans)', ...fontFamily.sans],
|
||||
signature: ['var(--font-signature)'],
|
||||
|
||||
@ -56,7 +56,7 @@ export const authRouter = router({
|
||||
|
||||
return user;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@ export const ZSignUpMutationSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
password: ZPasswordSchema,
|
||||
signature: z.string().min(1, { message: 'A signature is required.' }),
|
||||
signature: z.string().nullish(),
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
||||
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||
@ -22,6 +25,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
ZCreateDocumentMutationSchema,
|
||||
ZDeleteDraftDocumentMutationSchema as ZDeleteDocumentMutationSchema,
|
||||
ZDownloadAuditLogsMutationSchema,
|
||||
ZFindDocumentAuditLogsQuerySchema,
|
||||
ZGetDocumentByIdQuerySchema,
|
||||
ZGetDocumentByTokenQuerySchema,
|
||||
@ -354,6 +358,7 @@ export const documentRouter = router({
|
||||
query,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
|
||||
return documents;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@ -364,4 +369,66 @@ export const documentRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
downloadAuditLogs: authenticatedProcedure
|
||||
.input(ZDownloadAuditLogsMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { documentId, teamId } = input;
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: documentId,
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const encrypted = encryptSecondaryData({
|
||||
data: document.id.toString(),
|
||||
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
|
||||
});
|
||||
|
||||
return {
|
||||
url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encrypted}`,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
'We were unable to download the audit logs for this document. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
downloadCertificate: authenticatedProcedure
|
||||
.input(ZDownloadAuditLogsMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { documentId, teamId } = input;
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: documentId,
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const encrypted = encryptSecondaryData({
|
||||
data: document.id.toString(),
|
||||
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
|
||||
});
|
||||
|
||||
return {
|
||||
url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encrypted}`,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
'We were unable to download the audit logs for this document. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@ -163,3 +163,8 @@ export type TDeleteDraftDocumentMutationSchema = z.infer<typeof ZDeleteDraftDocu
|
||||
export const ZSearchDocumentsMutationSchema = z.object({
|
||||
query: z.string(),
|
||||
});
|
||||
|
||||
export const ZDownloadAuditLogsMutationSchema = z.object({
|
||||
documentId: z.number(),
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
@ -2,12 +2,12 @@ import { createElement } from 'react';
|
||||
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { renderAsync } from '@documenso/email/render';
|
||||
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
|
||||
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
|
||||
import { queueJob } from '@documenso/lib/server-only/queue/job';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
@ -160,22 +160,19 @@ export const singleplayerRouter = router({
|
||||
]);
|
||||
|
||||
// Send email to signer.
|
||||
await queueJob({
|
||||
job: 'send-mail',
|
||||
args: {
|
||||
to: {
|
||||
address: signer.email,
|
||||
name: signer.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document signed',
|
||||
html,
|
||||
text,
|
||||
attachments: [{ content: signedPdfBuffer, filename: documentName }],
|
||||
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;
|
||||
|
||||
3
packages/tsconfig/process-env.d.ts
vendored
3
packages/tsconfig/process-env.d.ts
vendored
@ -61,6 +61,9 @@ declare namespace NodeJS {
|
||||
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP?: string;
|
||||
|
||||
//
|
||||
NEXT_PRIVATE_BROWSERLESS_URL?: string;
|
||||
|
||||
/**
|
||||
* Vercel environment variables
|
||||
*/
|
||||
|
||||
@ -55,6 +55,8 @@ type AvatarWithTextProps = {
|
||||
primaryText: React.ReactNode;
|
||||
secondaryText?: React.ReactNode;
|
||||
rightSideComponent?: React.ReactNode;
|
||||
// Optional class to hide/show the text beside avatar
|
||||
textSectionClassName?: string;
|
||||
};
|
||||
|
||||
const AvatarWithText = ({
|
||||
@ -64,6 +66,7 @@ const AvatarWithText = ({
|
||||
primaryText,
|
||||
secondaryText,
|
||||
rightSideComponent,
|
||||
textSectionClassName,
|
||||
}: AvatarWithTextProps) => (
|
||||
<div className={cn('flex w-full max-w-xs items-center gap-2', className)}>
|
||||
<Avatar
|
||||
@ -72,7 +75,7 @@ const AvatarWithText = ({
|
||||
<AvatarFallback className="text-xs text-gray-400">{avatarFallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex flex-col text-left text-sm font-normal">
|
||||
<div className={cn('flex flex-col text-left text-sm font-normal', textSectionClassName)}>
|
||||
<span className="text-foreground truncate">{primaryText}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">{secondaryText}</span>
|
||||
</div>
|
||||
|
||||
@ -32,7 +32,11 @@ type CommandDialogProps = DialogProps & {
|
||||
const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-2xl">
|
||||
<DialogContent
|
||||
className="w-11/12 items-center overflow-hidden rounded-lg p-0 shadow-2xl lg:mt-0"
|
||||
position="center"
|
||||
overlayClassName="bg-background/60"
|
||||
>
|
||||
<Command
|
||||
{...commandProps}
|
||||
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-0 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4"
|
||||
|
||||
@ -54,28 +54,35 @@ const DialogContent = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
position?: 'start' | 'end' | 'center';
|
||||
hideClose?: boolean;
|
||||
/* Below prop is to add additional classes to the overlay */
|
||||
overlayClassName?: string;
|
||||
}
|
||||
>(({ className, children, position = 'start', hideClose = false, ...props }, ref) => (
|
||||
<DialogPortal position={position}>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-background animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 rounded-b-lg border p-6 shadow-lg sm:max-w-lg sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideClose && (
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
>(
|
||||
(
|
||||
{ className, children, overlayClassName, position = 'start', hideClose = false, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<DialogPortal position={position}>
|
||||
<DialogOverlay className={cn(overlayClassName)} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-background animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 rounded-b-lg border p-6 shadow-lg sm:max-w-lg sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideClose && (
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
),
|
||||
);
|
||||
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
|
||||
@ -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,8 @@ export const AddSignersFormPartial = ({
|
||||
}: AddSignersFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const { remaining } = useLimits();
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user;
|
||||
|
||||
const initialId = useId();
|
||||
|
||||
@ -135,6 +138,18 @@ export const AddSignersFormPartial = ({
|
||||
);
|
||||
};
|
||||
|
||||
const onAddSelfSigner = () => {
|
||||
const newSelfSignerId = nanoid(12);
|
||||
|
||||
appendSigner({
|
||||
formId: newSelfSignerId,
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const onAddSigner = () => {
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
@ -209,8 +224,12 @@ export const AddSignersFormPartial = ({
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||
{...field}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
||||
signers[index].email === user?.email
|
||||
}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -237,8 +256,12 @@ export const AddSignersFormPartial = ({
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Name"
|
||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||
{...field}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
||||
signers[index].email === user?.email
|
||||
}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -403,32 +426,46 @@ 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>
|
||||
|
||||
{!alwaysShowAdvancedSettings && isDocumentEnterprise && (
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
id="showAdvancedRecipientSettings"
|
||||
className="h-5 w-5"
|
||||
checkClassName="dark:text-white text-primary"
|
||||
checked={showAdvancedSettings}
|
||||
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 text-sm"
|
||||
htmlFor="showAdvancedRecipientSettings"
|
||||
>
|
||||
Show advanced settings
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
form.getValues('signers').some((signer) => signer.email === user?.email)
|
||||
}
|
||||
onClick={() => onAddSelfSigner()}
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add myself
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!alwaysShowAdvancedSettings && isDocumentEnterprise && (
|
||||
<div className="mt-4 flex flex-row items-center">
|
||||
<Checkbox
|
||||
id="showAdvancedRecipientSettings"
|
||||
className="h-5 w-5"
|
||||
checkClassName="dark:text-white text-primary"
|
||||
checked={showAdvancedSettings}
|
||||
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 text-sm"
|
||||
htmlFor="showAdvancedRecipientSettings"
|
||||
>
|
||||
Show advanced settings
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</AnimateGenericFadeInOut>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
@ -2,13 +2,16 @@ import * as React from 'react';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement> & {
|
||||
overflowHidden?: boolean;
|
||||
}
|
||||
>(({ className, overflowHidden, ...props }, ref) => (
|
||||
<div className={cn('w-full', overflowHidden ? 'overflow-hidden' : 'overflow-auto')}>
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
));
|
||||
|
||||
Table.displayName = 'Table';
|
||||
|
||||
@ -76,11 +79,17 @@ TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
React.TdHTMLAttributes<HTMLTableCellElement> & {
|
||||
truncate?: boolean;
|
||||
}
|
||||
>(({ className, truncate = true, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('truncate p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
className={cn(
|
||||
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
|
||||
truncate && 'truncate',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@ -5,6 +5,7 @@ import React, { useId, useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Plus, Trash } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
@ -41,6 +42,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
onSubmit,
|
||||
}: AddTemplatePlaceholderRecipientsFormProps) => {
|
||||
const initialId = useId();
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user;
|
||||
const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() =>
|
||||
recipients.length > 1 ? recipients.length + 1 : 2,
|
||||
);
|
||||
@ -50,6 +53,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TAddTemplatePlacholderRecipientsFormSchema>({
|
||||
resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema),
|
||||
@ -85,6 +89,17 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
name: 'signers',
|
||||
});
|
||||
|
||||
const onAddPlaceholderSelfRecipient = () => {
|
||||
const newSelfSignerId = nanoid(12);
|
||||
|
||||
appendSigner({
|
||||
formId: newSelfSignerId,
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
});
|
||||
};
|
||||
|
||||
const onAddPlaceholderRecipient = () => {
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
@ -203,11 +218,27 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
error={'signers__root' in errors && errors['signers__root']}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="button" disabled={isSubmitting} onClick={() => onAddPlaceholderRecipient()}>
|
||||
<div className="mt-4 flex flex-row items-center space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => onAddPlaceholderRecipient()}
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Placeholder Recipient
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
|
||||
disabled={
|
||||
isSubmitting || getValues('signers').some((signer) => signer.email === user?.email)
|
||||
}
|
||||
onClick={() => onAddPlaceholderSelfRecipient()}
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Myself
|
||||
</Button>
|
||||
</div>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
|
||||
@ -97,6 +97,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Custom CSS for printing reports
|
||||
* - Sets page margins to 0.5 inches
|
||||
* - Hides the header and footer
|
||||
* - Hides the print button
|
||||
* - Sets page size to A4
|
||||
* - Sets the font size to 12pt
|
||||
*/
|
||||
.print-provider {
|
||||
@page {
|
||||
margin: 1in;
|
||||
size: A4;
|
||||
}
|
||||
}
|
||||
|
||||
.gradient-border-mask::before {
|
||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user