feat: use server-actions for authoring flow

This change actually makes the authoring flow work for
the most part by tying in emailing and more.

We have also done a number of quality of life updates to
simplify the codebase overall making it easier to continue
work on the refresh.
This commit is contained in:
Mythie
2023-07-26 18:52:53 +10:00
parent 60b150cc58
commit b3fa837967
54 changed files with 2891 additions and 859 deletions

View File

@@ -1,19 +1,56 @@
# [[AUTH]]
NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="secret" NEXTAUTH_SECRET="secret"
# [[APP]]
NEXT_PUBLIC_SITE_URL="http://localhost:3000" NEXT_PUBLIC_SITE_URL="http://localhost:3000"
NEXT_PUBLIC_APP_URL="http://localhost:3000" NEXT_PUBLIC_APP_URL="http://localhost:3000"
# [[DATABASE]]
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# [[SMTP]]
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
# OPTIONAL: Defines the host to use for sending emails.
NEXT_PRIVATE_SMTP_HOST="127.0.0.1"
# OPTIONAL: Defines the port to use for sending emails.
NEXT_PRIVATE_SMTP_PORT=2500
# OPTIONAL: Defines the username to use with the SMTP server.
NEXT_PRIVATE_SMTP_USERNAME="documenso"
# OPTIONAL: Defines the password to use with the SMTP server.
NEXT_PRIVATE_SMTP_PASSWORD="password"
# OPTIONAL: Defines the API key user to use with the SMTP server.
NEXT_PRIVATE_SMTP_APIKEY_USER=
# OPTIONAL: Defines the API key to use with the SMTP server.
NEXT_PRIVATE_SMTP_APIKEY=
# OPTIONAL: Defines whether to force the use of TLS.
NEXT_PRIVATE_SMTP_SECURE=
# REQUIRED: Defines the sender name to use for the from address.
NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso"
# REQUIRED: Defines the email address to use as the from address.
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
# OPTIONAL: The API key to use for the MailChannels proxy endpoint.
NEXT_PRIVATE_MAILCHANNELS_API_KEY=
# OPTIONAL: The endpoint to use for the MailChannels API if using a proxy.
NEXT_PRIVATE_MAILCHANNELS_ENDPOINT=
# OPTIONAL: The domain to use for DKIM signing.
NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN=
# OPTIONAL: The selector to use for DKIM signing.
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=
# OPTIONAL: The private key to use for DKIM signing.
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
# [[STRIPE]]
NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
NEXT_PRIVATE_STRIPE_API_KEY= # [[FEATURES]]
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false
# This is only required for the marketing site # This is only required for the marketing site
# [[REDIS]]
NEXT_PRIVATE_REDIS_URL= NEXT_PRIVATE_REDIS_URL=
NEXT_PRIVATE_REDIS_TOKEN= NEXT_PRIVATE_REDIS_TOKEN=

View File

@@ -18,7 +18,7 @@
"framer-motion": "^10.12.8", "framer-motion": "^10.12.8",
"lucide-react": "^0.214.0", "lucide-react": "^0.214.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "13.4.1", "next": "13.4.9",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"next-plausible": "^3.7.2", "next-plausible": "^3.7.2",
"perfect-freehand": "^1.2.0", "perfect-freehand": "^1.2.0",

View File

@@ -7,9 +7,23 @@ const { parsed: env } = require('dotenv').config({
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const config = { const config = {
experimental: {
serverActions: true,
},
reactStrictMode: true, reactStrictMode: true,
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'], transpilePackages: [
'@documenso/lib',
'@documenso/prisma',
'@documenso/trpc',
'@documenso/ui',
'@documenso/email',
],
env, env,
modularizeImports: {
'lucide-react': {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
},
},
}; };
module.exports = config; module.exports = config;

View File

@@ -22,7 +22,7 @@
"lucide-react": "^0.214.0", "lucide-react": "^0.214.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"nanoid": "^4.0.2", "nanoid": "^4.0.2",
"next": "13.4.1", "next": "13.4.9",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"next-plausible": "^3.7.2", "next-plausible": "^3.7.2",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 B

View File

@@ -0,0 +1,111 @@
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
import { Loader } from 'lucide-react';
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from '~/components/forms/edit-document/add-fields';
import { AddSignersFormPartial } from '~/components/forms/edit-document/add-signers';
import { AddSubjectFormPartial } from '~/components/forms/edit-document/add-subject';
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
ssr: false,
loading: () => (
<div className="dark:bg-background flex-col flex min-h-[80vh] items-center justify-center bg-white/50">
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p>
</div>
),
});
export type EditDocumentFormProps = {
className?: string;
user: User;
document: Document;
recipients: Recipient[];
fields: Field[];
};
export const EditDocumentForm = ({
className,
document,
recipients,
fields,
user: _user,
}: EditDocumentFormProps) => {
const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers');
const documentUrl = `data:application/pdf;base64,${document.document}`;
const onNextStep = () => {
if (step === 'signers') {
setStep('fields');
}
if (step === 'fields') {
setStep('subject');
}
};
const onPreviousStep = () => {
if (step === 'fields') {
setStep('signers');
}
if (step === 'subject') {
setStep('fields');
}
};
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<PDFViewer document={documentUrl} />
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
{step === 'signers' && (
<AddSignersFormPartial
recipients={recipients}
fields={fields}
document={document}
onContinue={onNextStep}
onGoBack={onPreviousStep}
/>
)}
{step === 'fields' && (
<AddFieldsFormPartial
recipients={recipients}
fields={fields}
document={document}
onContinue={onNextStep}
onGoBack={onPreviousStep}
/>
)}
{step === 'subject' && (
<AddSubjectFormPartial
recipients={recipients}
fields={fields}
document={document}
onContinue={onNextStep}
onGoBack={onPreviousStep}
/>
)}
</div>
</div>
);
};

View File

@@ -16,7 +16,7 @@ export type LoadablePDFCard = PDFViewerProps & {
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), { const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
ssr: false, ssr: false,
loading: () => ( loading: () => (
<div className="flex min-h-[80vh] flex-col items-center justify-center bg-white/50"> <div className="flex-col flex min-h-[80vh] items-center justify-center bg-white/50">
<Loader className="h-12 w-12 animate-spin text-slate-500" /> <Loader className="h-12 w-12 animate-spin text-slate-500" />
<p className="mt-4 text-slate-500">Loading document...</p> <p className="mt-4 text-slate-500">Loading document...</p>

View File

@@ -4,7 +4,7 @@ import { ChevronLeft, Loader } from 'lucide-react';
export default function Loading() { export default function Loading() {
return ( return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8"> <div className="flex-col mx-auto -mt-4 flex w-full max-w-screen-xl px-4 md:px-8">
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80"> <Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" /> <ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents Documents
@@ -13,15 +13,15 @@ export default function Loading() {
Loading Document... Loading Document...
</h1> </h1>
<div className="mt-8 grid min-h-[80vh] w-full grid-cols-12 gap-x-8"> <div className="mt-8 grid min-h-[80vh] w-full grid-cols-12 gap-x-8">
<div className="dark:bg-background border-documenso col-span-7 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl"> <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 min-h-[80vh] flex-col items-center justify-center"> <div className="flex-col flex min-h-[80vh] items-center justify-center">
<Loader className="text-documenso h-12 w-12 animate-spin" /> <Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p> <p className="text-muted-foreground mt-4">Loading document...</p>
</div> </div>
</div> </div>
<div className="bg-background border-documenso col-span-5 rounded-xl border-2 before:rounded-xl" /> <div className="bg-background border-border col-span-12 rounded-xl border-2 before:rounded-xl lg:col-span-6 xl:col-span-5" />
</div> </div>
</div> </div>
); );

View File

@@ -1,14 +1,15 @@
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft, Users2 } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { EditDocumentForm } from '~/components/forms/edit-document'; import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
import { DocumentStatus } from '~/components/formatter/document-status';
export type DocumentPageProps = { export type DocumentPageProps = {
params: { params: {
@@ -61,6 +62,18 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
{document.title} {document.title}
</h1> </h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<span>{recipients.length} Recipient(s)</span>
</div>
)}
</div>
<EditDocumentForm <EditDocumentForm
className="mt-8" className="mt-8"
document={document} document={document}

View File

@@ -0,0 +1,18 @@
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
export default function DocumentSentPage() {
return (
<div className="flex-col mx-auto -mt-4 flex w-full max-w-screen-xl px-4 md:px-8">
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
</Link>
<h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl">
Loading Document...
</h1>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { Inter } from 'next/font/google';
import { TrpcProvider } from '@documenso/trpc/react'; import { TrpcProvider } from '@documenso/trpc/react';
import { Toaster } from '@documenso/ui/primitives/toaster'; import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import { ThemeProvider } from '~/providers/next-theme'; import { ThemeProvider } from '~/providers/next-theme';
import { PlausibleProvider } from '~/providers/plausible'; import { PlausibleProvider } from '~/providers/plausible';
@@ -47,7 +48,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<body> <body>
<PlausibleProvider> <PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TrpcProvider>{children}</TrpcProvider> <TrpcProvider>{children}</TrpcProvider>
</TooltipProvider>
</ThemeProvider> </ThemeProvider>
</PlausibleProvider> </PlausibleProvider>
<Toaster /> <Toaster />

View File

@@ -2,7 +2,6 @@
import { Variants, motion } from 'framer-motion'; import { Variants, motion } from 'framer-motion';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useTheme } from 'next-themes';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -92,8 +91,6 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
}, },
}); });
const { theme } = useTheme();
return ( return (
<motion.div <motion.div
className={cn('flex', className)} className={cn('flex', className)}
@@ -105,20 +102,19 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
<Card <Card
role="button" role="button"
className={cn( className={cn(
'focus-visible:ring-ring ring-offset-background flex flex-1 cursor-pointer flex-col items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2', 'focus-visible:ring-ring ring-offset-background flex-col flex flex-1 cursor-pointer items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
className, className,
)} )}
gradient={true} gradient={true}
degrees={120} degrees={120}
{...getRootProps()} {...getRootProps()}
{...props} {...props}
> >
<CardContent className="text-muted-foreground/40 flex flex-col items-center justify-center p-6"> <CardContent className="text-muted-foreground/40 flex-col flex items-center justify-center p-6">
{/* <FilePlus strokeWidth="1px" className="h-16 w-16"/> */} {/* <FilePlus strokeWidth="1px" className="h-16 w-16"/> */}
<div className="flex"> <div className="flex">
<motion.div <motion.div
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm" className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 flex-col z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
variants={DocumentDropzoneCardLeftVariants} variants={DocumentDropzoneCardLeftVariants}
> >
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" /> <div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
@@ -127,7 +123,7 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
</motion.div> </motion.div>
<motion.div <motion.div
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm" className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 flex-col z-20 flex aspect-[3/4] w-24 items-center justify-center gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
variants={DocumentDropzoneCardCenterVariants} variants={DocumentDropzoneCardCenterVariants}
> >
<Plus <Plus
@@ -137,7 +133,7 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
</motion.div> </motion.div>
<motion.div <motion.div
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm" className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 flex-col z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
variants={DocumentDropzoneCardRightVariants} variants={DocumentDropzoneCardRightVariants}
> >
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" /> <div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />

View File

@@ -1,6 +1,4 @@
import React from 'react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { LucideIcon } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -15,7 +13,7 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
return ( return (
<div <div
className={cn( className={cn(
'border-border bg-background overflow-hidden rounded-lg border shadow shadow-transparent duration-200 hover:shadow-slate-100', 'border-border bg-background hover:shadow-border/80 overflow-hidden rounded-lg border shadow shadow-transparent duration-200',
className, className,
)} )}
> >

View File

@@ -1,6 +1,7 @@
import { HTMLAttributes } from 'react'; import { HTMLAttributes } from 'react';
import { CheckCircle2, Clock, File, LucideIcon } from 'lucide-react'; import { CheckCircle2, Clock, File } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -31,14 +32,24 @@ const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = {
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & { export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
status: InternalDocumentStatus; status: InternalDocumentStatus;
inheritColor?: boolean;
}; };
export const DocumentStatus = ({ className, status, ...props }: DocumentStatusProps) => { export const DocumentStatus = ({
className,
status,
inheritColor,
...props
}: DocumentStatusProps) => {
const { label, icon: Icon, color } = FRIENDLY_STATUS_MAP[status]; const { label, icon: Icon, color } = FRIENDLY_STATUS_MAP[status];
return ( return (
<span className={cn('flex items-center', className)} {...props}> <span className={cn('flex items-center', className)} {...props}>
<Icon className={cn('mr-2 inline-block h-4 w-4', color)} /> <Icon
className={cn('mr-2 inline-block h-4 w-4', {
[color]: !inheritColor,
})}
/>
{label} {label}
</span> </span>
); );

View File

@@ -1,251 +0,0 @@
'use client';
import { useId, useState } from 'react';
import dynamic from 'next/dynamic';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from './edit-document/add-fields';
import { AddSignersFormPartial } from './edit-document/add-signers';
import { AddSubjectFormPartial } from './edit-document/add-subject';
import { TEditDocumentFormSchema, ZEditDocumentFormSchema } from './edit-document/types';
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
ssr: false,
loading: () => (
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p>
</div>
),
});
const MAX_STEP = 2;
export type EditDocumentFormProps = {
className?: string;
user: User;
document: Document;
recipients: Recipient[];
fields: Field[];
};
export const EditDocumentForm = ({
className,
document,
recipients,
fields,
user: _user,
}: EditDocumentFormProps) => {
const initialId = useId();
const [step, setStep] = useState(0);
const [nextStepLoading, setNextStepLoading] = useState(false);
const documentUrl = `data:application/pdf;base64,${document.document}`;
const defaultSigners =
recipients.length > 0
? recipients.map((recipient) => ({
nativeId: recipient.id,
formId: `${recipient.id}-${recipient.documentId}`,
name: recipient.name,
email: recipient.email,
}))
: [
{
formId: initialId,
name: '',
email: '',
},
];
const defaultFields = fields.map((field) => ({
nativeId: field.id,
formId: `${field.id}-${field.documentId}`,
pageNumber: field.page,
type: field.type,
pageX: Number(field.positionX),
pageY: Number(field.positionY),
pageWidth: Number(field.width),
pageHeight: Number(field.height),
signerEmail: recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
}));
const { mutateAsync: setRecipientsForDocument } =
trpc.document.setRecipientsForDocument.useMutation();
const { mutateAsync: setFieldsForDocument } = trpc.document.setFieldsForDocument.useMutation();
const {
control,
handleSubmit,
watch,
trigger,
formState: { errors, isSubmitting },
} = useForm<TEditDocumentFormSchema>({
mode: 'onBlur',
defaultValues: {
signers: defaultSigners,
fields: defaultFields,
email: {
subject: '',
message: '',
},
},
resolver: zodResolver(ZEditDocumentFormSchema),
});
const signersFormValue = watch('signers');
const fieldsFormValue = watch('fields');
console.log({ state: watch(), errors });
const canGoBack = step > 0;
const canGoNext = step < MAX_STEP;
const onGoBackClick = () => setStep((prev) => Math.max(0, prev - 1));
const onGoNextClick = async () => {
setNextStepLoading(true);
const passes = await trigger();
if (step === 0) {
await setRecipientsForDocument({
documentId: document.id,
recipients: signersFormValue.map((signer) => ({
id: signer.nativeId ?? undefined,
name: signer.name,
email: signer.email,
})),
}).catch((err: unknown) => console.error(err));
}
if (step === 1) {
await setFieldsForDocument({
documentId: document.id,
fields: fieldsFormValue.map((field) => ({
id: field.nativeId ?? undefined,
type: field.type,
signerEmail: field.signerEmail,
pageNumber: field.pageNumber,
pageX: field.pageX,
pageY: field.pageY,
pageWidth: field.pageWidth,
pageHeight: field.pageHeight,
})),
}).catch((err: unknown) => console.error(err));
}
if (passes) {
setStep((prev) => Math.min(MAX_STEP, prev + 1));
}
console.log({ passes });
setNextStepLoading(false);
};
return (
<div className={cn('grid w-full grid-cols-12 gap-x-8', className)}>
<Card className="col-span-7 rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewer document={documentUrl} />
</CardContent>
</Card>
<div className="col-span-5">
<form
className="dark:bg-background border-border sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border bg-[hsl(var(--widget))] px-4 py-6"
onSubmit={handleSubmit(console.log)}
>
{step === 0 && (
<AddSignersFormPartial
className="-mx-2 flex-1 overflow-y-hidden px-2"
control={control}
watch={watch}
errors={errors}
isSubmitting={isSubmitting}
/>
)}
{step === 1 && (
<AddFieldsFormPartial
className="-mx-2 flex-1 overflow-y-hidden px-2"
control={control}
watch={watch}
errors={errors}
isSubmitting={isSubmitting}
/>
)}
{step === 2 && (
<AddSubjectFormPartial
className="-mx-2 flex-1 overflow-y-hidden px-2"
control={control}
watch={watch}
errors={errors}
isSubmitting={isSubmitting}
/>
)}
<div className="mt-4 flex-shrink-0">
<p className="text-muted-foreground text-sm">
Add Signers ({step + 1}/{MAX_STEP + 1})
</p>
<div className="bg-muted relative mt-4 h-[2px] rounded-md">
<div
className="bg-documenso absolute inset-y-0 left-0"
style={{
width: `${(100 / (MAX_STEP + 1)) * (step + 1)}%`,
}}
/>
</div>
<div className="mt-4 flex gap-x-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
size="lg"
variant="secondary"
disabled={!canGoBack}
onClick={onGoBackClick}
>
Go Back
</Button>
{step < MAX_STEP && (
<Button
type="button"
className="bg-documenso flex-1"
size="lg"
disabled={!canGoNext}
onClick={onGoNextClick}
>
{nextStepLoading && <Loader className="mr-2 h-5 w-5 animate-spin" />}
Continue
</Button>
)}
{step === MAX_STEP && (
<Button type="submit" className="bg-documenso flex-1" size="lg">
Complete
</Button>
)}
</div>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,31 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { TAddFieldsFormSchema } from './add-fields.types';
export type AddFieldsActionInput = TAddFieldsFormSchema & {
documentId: number;
};
export const addFields = async ({ documentId, fields }: AddFieldsActionInput) => {
'use server';
const { id: userId } = await getRequiredServerComponentSession();
await setFieldsForDocument({
userId,
documentId,
fields: fields.map((field) => ({
id: field.nativeId,
signerEmail: field.signerEmail,
type: field.type,
pageNumber: field.pageNumber,
pageX: field.pageX,
pageY: field.pageY,
pageWidth: field.pageWidth,
pageHeight: field.pageHeight,
})),
});
};

View File

@@ -3,12 +3,13 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Caveat } from 'next/font/google'; import { Caveat } from 'next/font/google';
import { useRouter } from 'next/navigation';
import { Check, ChevronsUpDown } from 'lucide-react'; import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { Control, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { FieldType } from '@documenso/prisma/client'; import { Document, Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -20,11 +21,23 @@ import {
CommandItem, CommandItem,
} from '@documenso/ui/primitives/command'; } from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types'; import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
import { getBoundingClientRect } from '~/helpers/getBoundingClientRect';
import { addFields } from './add-fields.action';
import { TAddFieldsFormSchema } from './add-fields.types';
import {
EditDocumentFormContainer,
EditDocumentFormContainerActions,
EditDocumentFormContainerContent,
EditDocumentFormContainerFooter,
EditDocumentFormContainerStep,
} from './container';
import { FieldItem } from './field-item'; import { FieldItem } from './field-item';
import { FRIENDLY_FIELD_TYPE, TEditDocumentFormSchema } from './types'; import { FRIENDLY_FIELD_TYPE } from './types';
const fontCaveat = Caveat({ const fontCaveat = Caveat({
weight: ['500'], weight: ['500'],
@@ -40,31 +53,58 @@ const MIN_HEIGHT_PX = 60;
const MIN_WIDTH_PX = 200; const MIN_WIDTH_PX = 200;
export type AddFieldsFormProps = { export type AddFieldsFormProps = {
className?: string; recipients: Recipient[];
control: Control<TEditDocumentFormSchema>; fields: Field[];
watch: UseFormWatch<TEditDocumentFormSchema>; document: Document;
errors: FieldErrors<TEditDocumentFormSchema>; onContinue?: () => void;
isSubmitting: boolean; onGoBack?: () => void;
}; };
export const AddFieldsFormPartial = ({ export const AddFieldsFormPartial = ({
className, recipients,
control: control, fields,
watch, document,
errors: _errors, onContinue,
isSubmitting: _isSubmitting, onGoBack,
}: AddFieldsFormProps) => { }: AddFieldsFormProps) => {
const signers = watch('signers'); const { toast } = useToast();
const fields = watch('fields'); const router = useRouter();
const { append, remove, update } = useFieldArray({ const {
control,
handleSubmit,
formState: { isSubmitting },
} = useForm<TAddFieldsFormSchema>({
defaultValues: {
fields: fields.map((field) => ({
nativeId: field.id,
formId: `${field.id}-${field.documentId}`,
pageNumber: field.page,
type: field.type,
pageX: Number(field.positionX),
pageY: Number(field.positionY),
pageWidth: Number(field.width),
pageHeight: Number(field.height),
signerEmail:
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
})),
},
});
const {
append,
remove,
update,
fields: localFields,
} = useFieldArray({
control, control,
name: 'fields', name: 'fields',
}); });
const [selectedSigner, setSelectedSigner] = useState(() => signers[0]);
const [selectedField, setSelectedField] = useState<FieldType | null>(null); const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ const [coords, setCoords] = useState({
@@ -77,14 +117,60 @@ export const AddFieldsFormPartial = ({
width: 0, width: 0,
}); });
const isWithinPageBounds = useCallback((event: MouseEvent) => { /**
* Given a mouse event, find the nearest pdf page element.
*/
const getPage = (event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) { if (!(event.target instanceof HTMLElement)) {
return false; return null;
} }
const target = event.target; const target = event.target;
const $page = const $page =
target.closest(PDF_VIEWER_PAGE_SELECTOR) ?? target.querySelector(PDF_VIEWER_PAGE_SELECTOR); target.closest<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR) ??
target.querySelector<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
return null;
}
return $page;
};
/**
* Provided a page and a field, calculate the position of the field
* as a percentage of the page width and height.
*/
const getFieldPosition = (page: HTMLElement, field: HTMLElement) => {
const {
top: pageTop,
left: pageLeft,
height: pageHeight,
width: pageWidth,
} = getBoundingClientRect(page);
const {
top: fieldTop,
left: fieldLeft,
height: fieldHeight,
width: fieldWidth,
} = getBoundingClientRect(field);
return {
x: ((fieldLeft - pageLeft) / pageWidth) * 100,
y: ((fieldTop - pageTop) / pageHeight) * 100,
width: (fieldWidth / pageWidth) * 100,
height: (fieldHeight / pageHeight) * 100,
};
};
/**
* Given a mouse event, determine if the mouse is within the bounds of the
* nearest pdf page element.
*/
const isWithinPageBounds = useCallback((event: MouseEvent) => {
const $page = getPage(event);
if (!$page) { if (!$page) {
return false; return false;
@@ -121,28 +207,17 @@ export const AddFieldsFormPartial = ({
const onMouseClick = useCallback( const onMouseClick = useCallback(
(event: MouseEvent) => { (event: MouseEvent) => {
if (!selectedField) { if (!selectedField || !selectedSigner) {
return; return;
} }
if (!(event.target instanceof HTMLElement)) { const $page = getPage(event);
return;
}
const target = event.target;
const $page =
target.closest<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR) ??
target.querySelector<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR);
if (!$page || !isWithinPageBounds(event)) { if (!$page || !isWithinPageBounds(event)) {
return; return;
} }
const { height, width } = $page.getBoundingClientRect(); const { top, left, height, width } = getBoundingClientRect($page);
const top = $page.offsetTop;
const left = $page.offsetLeft;
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10); const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
@@ -172,14 +247,14 @@ export const AddFieldsFormPartial = ({
setVisible(false); setVisible(false);
setSelectedField(null); setSelectedField(null);
}, },
[append, isWithinPageBounds, selectedField, selectedSigner.email], [append, isWithinPageBounds, selectedField, selectedSigner],
); );
const onFieldResize = useCallback( const onFieldResize = useCallback(
(node: HTMLElement, index: number) => { (node: HTMLElement, index: number) => {
const field = fields[index]; const field = localFields[index];
const $page = document.querySelector<HTMLElement>( const $page = window.document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
); );
@@ -187,71 +262,69 @@ export const AddFieldsFormPartial = ({
return; return;
} }
const { height: pageHeight, width: pageWidth } = $page.getBoundingClientRect(); const {
x: pageX,
const pageTop = $page.offsetTop; y: pageY,
const pageLeft = $page.offsetLeft; width: pageWidth,
height: pageHeight,
let { top: nodeTop, left: nodeLeft } = node.getBoundingClientRect(); } = getFieldPosition($page, node);
const { height, width } = node.getBoundingClientRect();
nodeTop += window.scrollY;
nodeLeft += window.scrollX;
// Calculate width and height as a percentage of the page width and height
const newWidth = (width / pageWidth) * 100;
const newHeight = (height / pageHeight) * 100;
// Calculate the new position as a percentage of the page width and height
const newX = ((nodeLeft - pageLeft) / pageWidth) * 100;
const newY = ((nodeTop - pageTop) / pageHeight) * 100;
update(index, { update(index, {
...field, ...field,
pageX: newX, pageX,
pageY: newY, pageY,
pageWidth: newWidth, pageWidth,
pageHeight: newHeight, pageHeight,
}); });
}, },
[fields, update], [localFields, update],
); );
const onFieldMove = useCallback( const onFieldMove = useCallback(
(node: HTMLElement, index: number) => { (node: HTMLElement, index: number) => {
const field = fields[index]; const field = localFields[index];
const $page = document.querySelector( const $page = window.document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
); );
if (!$page || !($page instanceof HTMLElement)) { if (!$page) {
return; return;
} }
const { height: pageHeight, width: pageWidth } = $page.getBoundingClientRect(); const { x: pageX, y: pageY } = getFieldPosition($page, node);
const pageTop = $page.offsetTop;
const pageLeft = $page.offsetLeft;
let { top: nodeTop, left: nodeLeft } = node.getBoundingClientRect();
nodeTop += window.scrollY;
nodeLeft += window.scrollX;
// Calculate the new position as a percentage of the page width and height
const newX = ((nodeLeft - pageLeft) / pageWidth) * 100;
const newY = ((nodeTop - pageTop) / pageHeight) * 100;
update(index, { update(index, {
...field, ...field,
pageX: newX, pageX,
pageY: newY, pageY,
}); });
}, },
[fields, update], [localFields, update],
); );
const onFormSubmit = handleSubmit(async (data: TAddFieldsFormSchema) => {
try {
// Custom invocation server action
await addFields({
documentId: document.id,
fields: data.fields,
});
router.refresh();
onContinue?.();
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
});
useEffect(() => { useEffect(() => {
if (selectedField) { if (selectedField) {
window.addEventListener('mousemove', onMouseMove); window.addEventListener('mousemove', onMouseMove);
@@ -265,7 +338,7 @@ export const AddFieldsFormPartial = ({
}, [onMouseClick, onMouseMove, selectedField]); }, [onMouseClick, onMouseMove, selectedField]);
useEffect(() => { useEffect(() => {
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR); const $page = window.document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) { if (!$page) {
return; return;
@@ -279,8 +352,17 @@ export const AddFieldsFormPartial = ({
}; };
}, []); }, []);
useEffect(() => {
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
}, [recipients]);
return ( return (
<div className={cn('flex flex-col', className)}> <EditDocumentFormContainer>
<EditDocumentFormContainerContent
title="Add Fields"
description="Add all relevant fields for each recipient."
>
<div className="flex-col flex">
{selectedField && visible && ( {selectedField && visible && (
<Card <Card
className="border-primary pointer-events-none fixed z-50 cursor-pointer bg-white" className="border-primary pointer-events-none fixed z-50 cursor-pointer bg-white"
@@ -297,11 +379,11 @@ export const AddFieldsFormPartial = ({
</Card> </Card>
)} )}
{fields.map((field, index) => ( {localFields.map((field, index) => (
<FieldItem <FieldItem
key={index} key={index}
field={field} field={field}
disabled={selectedSigner.email !== field.signerEmail} disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
minHeight={fieldBounds.current.height} minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width} minWidth={fieldBounds.current.width}
passive={visible && !!selectedField} passive={visible && !!selectedField}
@@ -311,14 +393,6 @@ export const AddFieldsFormPartial = ({
/> />
))} ))}
<h3 className="text-2xl font-semibold">Add Fields</h3>
<p className="text-muted-foreground mt-2 text-sm">
Add all relevant fields for each recipient.
</p>
<hr className="mb-8 mt-4" />
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@@ -327,13 +401,15 @@ export const AddFieldsFormPartial = ({
role="combobox" role="combobox"
className="bg-background text-muted-foreground justify-between font-normal" className="bg-background text-muted-foreground justify-between font-normal"
> >
{selectedSigner.name && ( {selectedSigner?.email && (
<span> <span className="flex-1 truncate text-left">
{selectedSigner.name} ({selectedSigner.email}) {selectedSigner?.email} ({selectedSigner?.email})
</span> </span>
)} )}
{!selectedSigner.name && <span>{selectedSigner.email}</span>} {!selectedSigner?.email && (
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" /> <ChevronsUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
@@ -345,22 +421,45 @@ export const AddFieldsFormPartial = ({
<CommandEmpty /> <CommandEmpty />
<CommandGroup> <CommandGroup>
{signers.map((signer, index) => ( {recipients.map((recipient, index) => (
<CommandItem key={index} onSelect={() => setSelectedSigner(signer)}> <CommandItem
key={index}
className={cn({
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
})}
onSelect={() => setSelectedSigner(recipient)}
>
{recipient.sendStatus !== SendStatus.SENT ? (
<Check <Check
aria-hidden={signer !== selectedSigner} aria-hidden={recipient !== selectedSigner}
className={cn('mr-2 h-4 w-4', { className={cn('mr-2 h-4 w-4 flex-shrink-0', {
'opacity-0': signer !== selectedSigner, 'opacity-0': recipient !== selectedSigner,
'opacity-100': signer === selectedSigner, 'opacity-100': recipient === selectedSigner,
})} })}
/> />
{signer.name && ( ) : (
<span> <Tooltip>
{signer.name} ({signer.email}) <TooltipTrigger>
<Info className="mr-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
This document has already been sent to this recipient. You can no longer
edit this recipient.
</TooltipContent>
</Tooltip>
)}
{recipient.name && (
<span className="truncate" title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span> </span>
)} )}
{!signer.name && <span>{signer.email}</span>} {!recipient.name && (
<span className="truncate" title={recipient.email}>
{recipient.email}
</span>
)}
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
@@ -373,18 +472,19 @@ export const AddFieldsFormPartial = ({
<button <button
type="button" type="button"
className="group h-full w-full" className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.SIGNATURE)} onClick={() => setSelectedField(FieldType.SIGNATURE)}
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined} data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
> >
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer"> <Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4"> <CardContent className="flex-col flex items-center justify-center px-6 py-4">
<p <p
className={cn( className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-3xl font-medium', 'text-muted-foreground group-data-[selected]:text-foreground text-3xl font-medium',
fontCaveat.className, fontCaveat.className,
)} )}
> >
{selectedSigner.name || 'Signature'} {selectedSigner?.name || 'Signature'}
</p> </p>
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p> <p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
@@ -395,11 +495,12 @@ export const AddFieldsFormPartial = ({
<button <button
type="button" type="button"
className="group h-full w-full" className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.EMAIL)} onClick={() => setSelectedField(FieldType.EMAIL)}
data-selected={selectedField === FieldType.EMAIL ? true : undefined} data-selected={selectedField === FieldType.EMAIL ? true : undefined}
> >
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer"> <Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4"> <CardContent className="flex-col flex items-center justify-center px-6 py-4">
<p <p
className={cn( className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium', 'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
@@ -416,11 +517,12 @@ export const AddFieldsFormPartial = ({
<button <button
type="button" type="button"
className="group h-full w-full" className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.NAME)} onClick={() => setSelectedField(FieldType.NAME)}
data-selected={selectedField === FieldType.NAME ? true : undefined} data-selected={selectedField === FieldType.NAME ? true : undefined}
> >
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer"> <Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4"> <CardContent className="flex-col flex items-center justify-center px-6 py-4">
<p <p
className={cn( className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium', 'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
@@ -437,11 +539,12 @@ export const AddFieldsFormPartial = ({
<button <button
type="button" type="button"
className="group h-full w-full" className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.DATE)} onClick={() => setSelectedField(FieldType.DATE)}
data-selected={selectedField === FieldType.DATE ? true : undefined} data-selected={selectedField === FieldType.DATE ? true : undefined}
> >
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer"> <Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4"> <CardContent className="flex-col flex items-center justify-center px-6 py-4">
<p <p
className={cn( className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium', 'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
@@ -457,5 +560,18 @@ export const AddFieldsFormPartial = ({
</div> </div>
</div> </div>
</div> </div>
</EditDocumentFormContainerContent>
<EditDocumentFormContainerFooter>
<EditDocumentFormContainerStep title="Add Fields" step={2} maxStep={3} />
<EditDocumentFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
onGoNextClick={() => onFormSubmit()}
onGoBackClick={onGoBack}
/>
</EditDocumentFormContainerFooter>
</EditDocumentFormContainer>
); );
}; };

View File

@@ -0,0 +1,21 @@
import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
export const ZAddFieldsFormSchema = z.object({
fields: z.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
}),
),
});
export type TAddFieldsFormSchema = z.infer<typeof ZAddFieldsFormSchema>;

View File

@@ -0,0 +1,26 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { TAddSignersFormSchema } from './add-signers.types';
export type AddSignersActionInput = TAddSignersFormSchema & {
documentId: number;
};
export const addSigners = async ({ documentId, signers }: AddSignersActionInput) => {
'use server';
const { id: userId } = await getRequiredServerComponentSession();
await setRecipientsForDocument({
userId,
documentId,
recipients: signers.map((signer) => ({
id: signer.nativeId,
email: signer.email,
name: signer.name,
})),
});
};

View File

@@ -1,35 +1,76 @@
'use client'; 'use client';
import React from 'react'; import React, { useId } from 'react';
import { useRouter } from 'next/navigation';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react'; import { Plus, Trash } from 'lucide-react';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { Control, Controller, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form'; import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils'; import { Document, Field, Recipient, SendStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { FormErrorMessage } from '~/components/form/form-error-message'; import { FormErrorMessage } from '~/components/form/form-error-message';
import { TEditDocumentFormSchema } from './types'; import { addSigners } from './add-signers.action';
import { TAddSignersFormSchema } from './add-signers.types';
import {
EditDocumentFormContainer,
EditDocumentFormContainerActions,
EditDocumentFormContainerContent,
EditDocumentFormContainerFooter,
EditDocumentFormContainerStep,
} from './container';
export type AddSignersFormProps = { export type AddSignersFormProps = {
className?: string; recipients: Recipient[];
control: Control<TEditDocumentFormSchema>; fields: Field[];
watch: UseFormWatch<TEditDocumentFormSchema>; document: Document;
errors: FieldErrors<TEditDocumentFormSchema>; onContinue?: () => void;
isSubmitting: boolean; onGoBack?: () => void;
}; };
export const AddSignersFormPartial = ({ export const AddSignersFormPartial = ({
className, recipients,
control, fields: _fields,
errors, document: document,
isSubmitting, onContinue,
onGoBack,
}: AddSignersFormProps) => { }: AddSignersFormProps) => {
const { toast } = useToast();
const router = useRouter();
const initialId = useId();
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddSignersFormSchema>({
defaultValues: {
signers:
recipients.length > 0
? recipients.map((recipient) => ({
nativeId: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
}))
: [
{
formId: initialId,
name: '',
email: '',
},
],
},
});
const { const {
append: appendSigner, append: appendSigner,
fields: signers, fields: signers,
@@ -39,10 +80,15 @@ export const AddSignersFormPartial = ({
name: 'signers', name: 'signers',
}); });
const { remove: removeField, fields: fields } = useFieldArray({ const hasBeenSentToRecipientId = (id?: number) => {
name: 'fields', if (!id) {
control, return false;
}); }
return recipients.some(
(recipient) => recipient.id === id && recipient.sendStatus === SendStatus.SENT,
);
};
const onAddSigner = () => { const onAddSigner = () => {
appendSigner({ appendSigner({
@@ -55,17 +101,17 @@ export const AddSignersFormPartial = ({
const onRemoveSigner = (index: number) => { const onRemoveSigner = (index: number) => {
const signer = signers[index]; const signer = signers[index];
removeSigner(index); if (hasBeenSentToRecipientId(signer.nativeId)) {
toast({
const fieldsToRemove: number[] = []; title: 'Cannot remove signer',
description: 'This signer has already received the document.',
fields.forEach((field, fieldIndex) => { variant: 'destructive',
if (field.signerEmail === signer.email) {
fieldsToRemove.push(fieldIndex);
}
}); });
removeField(fieldsToRemove); return;
}
removeSigner(index);
}; };
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
@@ -74,21 +120,42 @@ export const AddSignersFormPartial = ({
} }
}; };
const onFormSubmit = handleSubmit(async (data: TAddSignersFormSchema) => {
try {
// Custom invocation server action
await addSigners({
documentId: document.id,
signers: data.signers,
});
router.refresh();
onContinue?.();
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
});
return ( return (
<div className={cn('flex flex-col', className)}> <EditDocumentFormContainer onSubmit={onFormSubmit}>
<h3 className="text-foreground text-2xl font-semibold">Add Signers</h3> <EditDocumentFormContainerContent
title="Add Signers"
<p className="text-muted-foreground mt-2 text-sm"> description="Add the people who will sign the document."
Add the people who will sign the document. >
</p> <div className="flex-col flex w-full gap-y-4">
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">
<div className="flex w-full flex-col gap-y-4">
<AnimatePresence> <AnimatePresence>
{signers.map((signer, index) => ( {signers.map((signer, index) => (
<motion.div key={signer.formId} className="flex flex-wrap items-end gap-x-4"> <motion.div
key={signer.formId}
data-native-id={signer.nativeId}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1"> <div className="flex-1">
<Label htmlFor={`signer-${signer.formId}-email`}> <Label htmlFor={`signer-${signer.formId}-email`}>
Email Email
@@ -103,7 +170,7 @@ export const AddSignersFormPartial = ({
id={`signer-${signer.formId}-email`} id={`signer-${signer.formId}-email`}
type="email" type="email"
className="bg-background mt-2" className="bg-background mt-2"
disabled={isSubmitting} disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
{...field} {...field}
/> />
@@ -122,7 +189,7 @@ export const AddSignersFormPartial = ({
id={`signer-${signer.formId}-name`} id={`signer-${signer.formId}-name`}
type="text" type="text"
className="bg-background mt-2" className="bg-background mt-2"
disabled={isSubmitting} disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
{...field} {...field}
/> />
@@ -134,7 +201,11 @@ export const AddSignersFormPartial = ({
<button <button
type="button" type="button"
className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50" className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitting || signers.length === 1} disabled={
isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId) ||
signers.length === 1
}
onClick={() => onRemoveSigner(index)} onClick={() => onRemoveSigner(index)}
> >
<Trash className="h-5 w-5" /> <Trash className="h-5 w-5" />
@@ -158,7 +229,18 @@ export const AddSignersFormPartial = ({
Add Signer Add Signer
</Button> </Button>
</div> </div>
</div> </EditDocumentFormContainerContent>
</div>
<EditDocumentFormContainerFooter>
<EditDocumentFormContainerStep title="Add Signers" step={1} maxStep={3} />
<EditDocumentFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
onGoNextClick={() => onFormSubmit()}
onGoBackClick={onGoBack}
/>
</EditDocumentFormContainerFooter>
</EditDocumentFormContainer>
); );
}; };

View File

@@ -0,0 +1,19 @@
import { z } from 'zod';
export const ZAddSignersFormSchema = z.object({
signers: z
.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z.string().min(1).email(),
name: z.string(),
}),
)
.refine((signers) => {
const emails = signers.map((signer) => signer.email);
return new Set(emails).size === emails.length;
}, 'Signers must have unique emails'),
});
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;

View File

@@ -0,0 +1,21 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { TAddSubjectFormSchema } from './add-subject.types';
export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
documentId: number;
};
export const completeDocument = async ({ documentId }: CompleteDocumentActionInput) => {
'use server';
const { id: userId } = await getRequiredServerComponentSession();
await sendDocument({
userId,
documentId,
});
};

View File

@@ -1,61 +1,106 @@
'use client'; 'use client';
import { Control, Controller, FieldErrors, UseFormWatch } from 'react-hook-form'; import { useRouter } from 'next/navigation';
import { cn } from '@documenso/ui/lib/utils'; import { useForm } from 'react-hook-form';
import { Document, DocumentStatus, Field, Recipient } from '@documenso/prisma/client';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea'; import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { FormErrorMessage } from '~/components/form/form-error-message'; import { FormErrorMessage } from '~/components/form/form-error-message';
import { TEditDocumentFormSchema } from './types'; import { completeDocument } from './add-subject.action';
import { TAddSubjectFormSchema } from './add-subject.types';
import {
EditDocumentFormContainer,
EditDocumentFormContainerActions,
EditDocumentFormContainerContent,
EditDocumentFormContainerFooter,
EditDocumentFormContainerStep,
} from './container';
export type AddSubjectFormProps = { export type AddSubjectFormProps = {
className?: string; recipients: Recipient[];
control: Control<TEditDocumentFormSchema>; fields: Field[];
watch: UseFormWatch<TEditDocumentFormSchema>; document: Document;
errors: FieldErrors<TEditDocumentFormSchema>; onContinue?: () => void;
isSubmitting: boolean; onGoBack?: () => void;
}; };
export const AddSubjectFormPartial = ({ export const AddSubjectFormPartial = ({
className, recipients: _recipients,
control, fields: _fields,
errors, document,
isSubmitting, onContinue,
onGoBack,
}: AddSubjectFormProps) => { }: AddSubjectFormProps) => {
const { toast } = useToast();
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddSubjectFormSchema>({
defaultValues: {
email: {
subject: '',
message: '',
},
},
});
const onFormSubmit = handleSubmit(async (data: TAddSubjectFormSchema) => {
const { subject, message } = data.email;
try {
await completeDocument({
documentId: document.id,
email: {
subject,
message,
},
});
router.refresh();
onContinue?.();
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while sending the document.',
variant: 'destructive',
});
}
});
return ( return (
<div className={cn('flex flex-col', className)}> <EditDocumentFormContainer>
<h3 className="text-foreground text-2xl font-semibold">Add Subject</h3> <EditDocumentFormContainerContent
title="Add Subject"
<p className="text-muted-foreground mt-2 text-sm"> description="Add the subject and message you wish to send to signers."
Add the subject and message you wish to send to signers. >
</p> <div className="flex-col flex">
<div className="flex-col flex gap-y-4">
<hr className="border-border mb-8 mt-4" />
<div className="flex flex-col gap-y-4">
<div> <div>
<Label htmlFor="subject"> <Label htmlFor="subject">
Subject <span className="text-muted-foreground">(Optional)</span> Subject <span className="text-muted-foreground">(Optional)</span>
</Label> </Label>
<Controller
control={control}
name="email.subject"
render={({ field }) => (
<Input <Input
id="subject" id="subject"
// placeholder="Subject" // placeholder="Subject"
className="bg-background mt-2" className="bg-background mt-2"
disabled={isSubmitting} disabled={isSubmitting}
{...field} {...register('email.subject')}
/>
)}
/> />
<FormErrorMessage className="mt-2" errors={errors} /> <FormErrorMessage className="mt-2" error={errors.email?.subject} />
</div> </div>
<div> <div>
@@ -63,20 +108,19 @@ export const AddSubjectFormPartial = ({
Message <span className="text-muted-foreground">(Optional)</span> Message <span className="text-muted-foreground">(Optional)</span>
</Label> </Label>
<Controller
control={control}
name="email.message"
render={({ field }) => (
<Textarea <Textarea
id="message" id="message"
className="bg-background mt-2 h-32 resize-none" className="bg-background mt-2 h-32 resize-none"
disabled={isSubmitting} disabled={isSubmitting}
{...field} {...register('email.message')}
/>
)}
/> />
<FormErrorMessage className="mt-2" errors={errors} /> <FormErrorMessage
className="mt-2"
error={
typeof errors.email?.message !== 'string' ? errors.email?.message : undefined
}
/>
</div> </div>
<div> <div>
@@ -84,7 +128,7 @@ export const AddSubjectFormPartial = ({
You can use the following variables in your message: You can use the following variables in your message:
</p> </p>
<ul className="mt-2 flex list-inside list-disc flex-col gap-y-2 text-sm"> <ul className="flex-col mt-2 flex list-inside list-disc gap-y-2 text-sm">
<li className="text-muted-foreground"> <li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm"> <code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{signer.name}'} {'{signer.name}'}
@@ -107,5 +151,19 @@ export const AddSubjectFormPartial = ({
</div> </div>
</div> </div>
</div> </div>
</EditDocumentFormContainerContent>
<EditDocumentFormContainerFooter>
<EditDocumentFormContainerStep title="Add Subject" step={3} maxStep={3} />
<EditDocumentFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
goNextLabel={document.status === DocumentStatus.DRAFT ? 'Send' : 'Update'}
onGoNextClick={() => onFormSubmit()}
onGoBackClick={onGoBack}
/>
</EditDocumentFormContainerFooter>
</EditDocumentFormContainer>
); );
}; };

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZAddSubjectFormSchema = z.object({
email: z.object({
subject: z.string(),
message: z.string(),
}),
});
export type TAddSubjectFormSchema = z.infer<typeof ZAddSubjectFormSchema>;

View File

@@ -0,0 +1,152 @@
import React, { HTMLAttributes } from 'react';
import { Loader } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type EditDocumentFormContainerProps = HTMLAttributes<HTMLFormElement> & {
children?: React.ReactNode;
};
export const EditDocumentFormContainer = ({
children,
id = 'edit-document-form',
className,
...props
}: EditDocumentFormContainerProps) => {
return (
<form
id={id}
className={cn(
'dark:bg-background border-border bg-widget flex-col sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen rounded-xl border px-4 py-6',
className,
)}
{...props}
>
<div className={cn('flex-col -mx-2 flex flex-1 overflow-hidden px-2')}>{children}</div>
</form>
);
};
export type EditDocumentFormContainerContentProps = HTMLAttributes<HTMLDivElement> & {
title: string;
description: string;
children?: React.ReactNode;
};
export const EditDocumentFormContainerContent = ({
children,
title,
description,
className,
...props
}: EditDocumentFormContainerContentProps) => {
return (
<div className={cn('flex-col flex flex-1', className)} {...props}>
<h3 className="text-foreground text-2xl font-semibold">{title}</h3>
<p className="text-muted-foreground mt-2 text-sm">{description}</p>
<hr className="border-border mb-8 mt-4" />
<div className="flex-col -mx-2 flex flex-1 overflow-y-auto px-2">{children}</div>
</div>
);
};
export type EditDocumentFormContainerFooterProps = HTMLAttributes<HTMLDivElement> & {
children?: React.ReactNode;
};
export const EditDocumentFormContainerFooter = ({
children,
className,
...props
}: EditDocumentFormContainerFooterProps) => {
return (
<div className={cn('mt-4 flex-shrink-0', className)} {...props}>
{children}
</div>
);
};
export type EditDocumentFormContainerStepProps = {
title: string;
step: number;
maxStep: number;
};
export const EditDocumentFormContainerStep = ({
title,
step,
maxStep,
}: EditDocumentFormContainerStepProps) => {
return (
<div>
<p className="text-muted-foreground text-sm">
{title}{' '}
<span>
({step}/{maxStep})
</span>
</p>
<div className="bg-muted relative mt-4 h-[2px] rounded-md">
<div
className="bg-documenso absolute inset-y-0 left-0"
style={{
width: `${(100 / maxStep) * step}%`,
}}
/>
</div>
</div>
);
};
export type EditDocumentFormContainerActionsProps = {
canGoBack?: boolean;
canGoNext?: boolean;
goNextLabel?: string;
goBackLabel?: string;
onGoBackClick?: () => void;
onGoNextClick?: () => void;
loading?: boolean;
disabled?: boolean;
};
export const EditDocumentFormContainerActions = ({
canGoBack = true,
canGoNext = true,
goNextLabel = 'Continue',
goBackLabel = 'Go Back',
onGoBackClick,
onGoNextClick,
loading,
disabled,
}: EditDocumentFormContainerActionsProps) => {
return (
<div className="mt-4 flex gap-x-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
size="lg"
variant="secondary"
disabled={disabled || loading || !canGoBack}
onClick={onGoBackClick}
>
{goBackLabel}
</Button>
<Button
type="button"
className="bg-documenso flex-1"
size="lg"
disabled={disabled || loading || !canGoNext}
onClick={onGoNextClick}
>
{loading && <Loader className="mr-2 h-5 w-5 animate-spin" />}
{goNextLabel}
</Button>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { X } from 'lucide-react'; import { Trash } from 'lucide-react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd'; import { Rnd } from 'react-rnd';
@@ -53,8 +53,8 @@ export const FieldItem = ({
const { height, width } = $page.getBoundingClientRect(); const { height, width } = $page.getBoundingClientRect();
const top = $page.offsetTop; const top = $page.getBoundingClientRect().top + window.scrollY;
const left = $page.offsetLeft; const left = $page.getBoundingClientRect().left + window.scrollX;
// X and Y are percentages of the page's height and width // X and Y are percentages of the page's height and width
const pageX = (field.pageX / 100) * width + left; const pageX = (field.pageX / 100) * width + left;
@@ -63,6 +63,24 @@ export const FieldItem = ({
const pageHeight = (field.pageHeight / 100) * height; const pageHeight = (field.pageHeight / 100) * height;
const pageWidth = (field.pageWidth / 100) * width; const pageWidth = (field.pageWidth / 100) * width;
console.log(
structuredClone({
field,
top,
left,
height,
width,
pageX: coords.pageX,
pageY: coords.pageY,
pageHeight: coords.pageHeight,
pageWidth: coords.pageWidth,
newPageX: pageX,
newPageY: pageY,
newPageHeight: pageHeight,
newPageWidth: pageWidth,
}),
);
setCoords({ setCoords({
pageX: pageX, pageX: pageX,
pageY: pageY, pageY: pageY,
@@ -92,10 +110,10 @@ export const FieldItem = ({
key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth} key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth}
className={cn('absolute z-20', { className={cn('absolute z-20', {
'pointer-events-none': passive, 'pointer-events-none': passive,
'pointer-events-none opacity-75': disabled, 'pointer-events-none z-10 opacity-75': disabled,
})} })}
minHeight={minHeight} // minHeight={minHeight}
minWidth={minWidth} // minWidth={minWidth}
default={{ default={{
x: coords.pageX, x: coords.pageX,
y: coords.pageY, y: coords.pageY,
@@ -116,16 +134,17 @@ export const FieldItem = ({
> >
{!disabled && ( {!disabled && (
<button <button
className="bg-destructive absolute -right-2 -top-2 z-[9999] flex h-5 w-5 items-center justify-center rounded-full" className="text-muted-foreground/50 hover:text-muted-foreground/80 absolute -right-2 -top-2 z-[9999] flex h-8 w-8 items-center justify-center rounded-full border bg-white shadow-[0_0_0_2px_theme(colors.gray.100/70%)]"
onClick={() => onRemove?.()} onClick={() => onRemove?.()}
> >
<X className="text-destructive-foreground h-4 w-4" /> <Trash className="h-4 w-4" />
</button> </button>
)} )}
<Card <Card
className={cn('hover:border-primary/50 h-full w-full bg-white', { className={cn('h-full w-full bg-white', {
'border-primary hover:border-primary': active, 'border-primary': !disabled,
'border-primary/80': active,
})} })}
> >
<CardContent <CardContent

View File

@@ -0,0 +1,64 @@
import { useState } from 'react';
import { Loader } from 'lucide-react';
import { Button, ButtonProps } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
export type SendDocumentActionDialogProps = ButtonProps & {
loading?: boolean;
};
export const SendDocumentActionDialog = ({
loading,
className,
...props
}: SendDocumentActionDialogProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" className={className}>
{loading && <Loader className="text-documenso mr-2 h-5 w-5 animate-spin" />}
Send
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-center text-lg font-semibold">Send Document</DialogTitle>
<DialogDescription className="text-center text-base">
You are about to send this document to the recipients. Are you sure you want to
continue?
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-4 flex items-center gap-x-4">
<Button
className="dark:bg-muted dark:hover:bg-muted/80 dark:focus-visible:ring-muted/80 flex-1 border-none bg-black/5 hover:bg-black/10 focus-visible:ring-black/10"
type="button"
variant="secondary"
onClick={() => setOpen(false)}
>
Cancel
</Button>
{/* We would use DialogAction here but it interrupts the submit action */}
<Button className={className} {...props}>
{loading && <Loader className="mr-2 h-5 w-5 animate-spin" />}
Send
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -81,7 +81,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
return ( return (
<form <form
className={cn('flex w-full flex-col gap-y-4', className)} className={cn('flex-col flex w-full gap-y-4', className)}
onSubmit={handleSubmit(onFormSubmit)} onSubmit={handleSubmit(onFormSubmit)}
> >
<div> <div>

View File

@@ -0,0 +1,10 @@
export const getBoundingClientRect = (element: HTMLElement) => {
const rect = element.getBoundingClientRect();
const { width, height } = rect;
const top = rect.top + window.scrollY;
const left = rect.left + window.scrollX;
return { top, left, width, height };
};

View File

@@ -124,16 +124,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
document.document = await insertImageInPDF( document.document = await insertImageInPDF(
document.document, document.document,
signatureDataUrl, signatureDataUrl,
field.positionX, field.positionX.toNumber(),
field.positionY, field.positionY.toNumber(),
field.page, field.page,
); );
} else { } else {
document.document = await insertTextInPDF( document.document = await insertTextInPDF(
document.document, document.document,
signatureText ?? '', signatureText ?? '',
field.positionX, field.positionX.toNumber(),
field.positionY, field.positionY.toNumber(),
field.page, field.page,
); );
} }

1346
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
export { render, renderAsync } from '@react-email/components'; export {};

50
packages/email/mailer.ts Normal file
View File

@@ -0,0 +1,50 @@
import { createTransport } from 'nodemailer';
import { MailChannelsTransport } from './transports/mailchannels';
const getTransport = () => {
const transport = process.env.NEXT_PRIVATE_SMTP_TRANSPORT ?? 'smtp-auth';
if (transport === 'mailchannels') {
return createTransport(
MailChannelsTransport.makeTransport({
apiKey: process.env.NEXT_PRIVATE_MAILCHANNELS_API_KEY,
endpoint: process.env.NEXT_PRIVATE_MAILCHANNELS_ENDPOINT,
}),
);
}
if (transport === 'smtp-api') {
if (!process.env.NEXT_PRIVATE_SMTP_HOST || !process.env.NEXT_PRIVATE_SMTP_APIKEY) {
throw new Error(
'SMTP API transport requires NEXT_PRIVATE_SMTP_HOST and NEXT_PRIVATE_SMTP_APIKEY',
);
}
return createTransport({
host: process.env.NEXT_PRIVATE_SMTP_HOST,
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
auth: {
user: process.env.NEXT_PRIVATE_SMTP_APIKEY_USER ?? 'apikey',
pass: process.env.NEXT_PRIVATE_SMTP_APIKEY ?? '',
},
});
}
if (!process.env.NEXT_PRIVATE_SMTP_HOST) {
throw new Error('SMTP transport requires NEXT_PRIVATE_SMTP_HOST');
}
return createTransport({
host: process.env.NEXT_PRIVATE_SMTP_HOST,
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
auth: {
user: process.env.NEXT_PRIVATE_SMTP_USERNAME ?? '',
pass: process.env.NEXT_PRIVATE_SMTP_PASSWORD ?? '',
},
});
};
export const mailer = getTransport();

View File

@@ -5,18 +5,26 @@
"types": "./index.ts", "types": "./index.ts",
"license": "MIT", "license": "MIT",
"files": [ "files": [
"templates/" "templates/",
"transports/",
"mailer.ts",
"render.ts",
"index.ts"
], ],
"scripts": { "scripts": {
"dev": "email dev --port 3002 --dir templates" "dev": "email dev --port 3002 --dir templates",
"worker:test": "tsup worker/index.ts --format esm"
}, },
"dependencies": { "dependencies": {
"@documenso/tsconfig": "*", "@documenso/tsconfig": "*",
"@documenso/tailwind-config": "*", "@documenso/tailwind-config": "*",
"@documenso/ui": "*", "@documenso/ui": "*",
"@react-email/components": "^0.0.7" "@react-email/components": "^0.0.7",
"nodemailer": "^6.9.3"
}, },
"devDependencies": { "devDependencies": {
"react-email": "^1.9.4" "@types/nodemailer": "^6.4.8",
"react-email": "^1.9.4",
"tsup": "^7.1.0"
} }
} }

1
packages/email/render.ts Normal file
View File

@@ -0,0 +1 @@
export { render, renderAsync } from '@react-email/components';

View File

@@ -1,6 +1,5 @@
import { import {
Body, Body,
Button,
Container, Container,
Head, Head,
Html, Html,

View File

@@ -0,0 +1,154 @@
import { SentMessageInfo, Transport } from 'nodemailer';
import type { Address } from 'nodemailer/lib/mailer';
import type MailMessage from 'nodemailer/lib/mailer/mail-message';
const VERSION = '1.0.0';
type NodeMailerAddress = string | Address | Array<string | Address> | undefined;
interface MailChannelsAddress {
email: string;
name?: string;
}
interface MailChannelsTransportOptions {
apiKey: string;
endpoint: string;
}
/**
* Transport for sending email through MailChannels via Cloudflare Workers.
*
* Optionally allows specifying a custom endpoint and API key so you can setup a worker
* to proxy requests to MailChannels with added security.
*
* @see https://blog.cloudflare.com/sending-email-from-workers-with-mailchannels/
*/
export class MailChannelsTransport implements Transport<SentMessageInfo> {
public name = 'CloudflareMailTransport';
public version = VERSION;
private _options: MailChannelsTransportOptions;
public static makeTransport(options: Partial<MailChannelsTransportOptions>) {
return new MailChannelsTransport(options);
}
constructor(options: Partial<MailChannelsTransportOptions>) {
const { apiKey = '', endpoint = 'https://api.mailchannels.net/tx/v1/send' } = options;
this._options = {
apiKey,
endpoint,
};
}
public send(mail: MailMessage, callback: (_err: Error | null, _info: SentMessageInfo) => void) {
if (!mail.data.to || !mail.data.from) {
return callback(new Error('Missing required fields "to" or "from"'), null);
}
const mailTo = this.toMailChannelsAddresses(mail.data.to);
const mailCc = this.toMailChannelsAddresses(mail.data.cc);
const mailBcc = this.toMailChannelsAddresses(mail.data.bcc);
const from: MailChannelsAddress =
typeof mail.data.from === 'string'
? { email: mail.data.from }
: {
email: mail.data.from?.address,
name: mail.data.from?.name,
};
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this._options.apiKey) {
requestHeaders['X-Auth-Token'] = this._options.apiKey;
}
fetch(this._options.endpoint, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify({
from: from,
subject: mail.data.subject,
personalizations: [
{
to: mailTo,
cc: mailCc.length > 0 ? mailCc : undefined,
bcc: mailBcc.length > 0 ? mailBcc : undefined,
dkim_domain: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN || undefined,
dkim_selector: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR || undefined,
dkim_private_key: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY || undefined,
},
],
content: [
{
type: 'text/plain',
value: mail.data.text?.toString('utf-8') ?? '',
},
{
type: 'text/html',
value: mail.data.html?.toString('utf-8') ?? '',
},
],
}),
})
.then((res) => {
if (res.status >= 200 && res.status <= 299) {
return callback(null, {
messageId: '',
envelope: {
from: mail.data.from,
to: mail.data.to,
},
accepted: mail.data.to,
rejected: [],
pending: [],
});
}
res.json().then((data) => {
return callback(new Error(`MailChannels error: ${data.message}`), null);
});
})
.catch((err) => {
return callback(err, null);
});
}
/**
* Converts a nodemailer address(s) to an array of MailChannel compatible address.
*/
private toMailChannelsAddresses(address: NodeMailerAddress): Array<MailChannelsAddress> {
if (!address) {
return [];
}
if (typeof address === 'string') {
return [{ email: address }];
}
if (Array.isArray(address)) {
return address.map((address) => {
if (typeof address === 'string') {
return { email: address };
}
return {
email: address.address,
name: address.name,
};
});
}
return [
{
email: address.address,
name: address.name,
},
];
}
}

View File

@@ -7,7 +7,7 @@
"@typescript-eslint/eslint-plugin": "^5.59.2", "@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2", "@typescript-eslint/parser": "^5.59.2",
"eslint": "^8.40.0", "eslint": "^8.40.0",
"eslint-config-next": "^13.4.1", "eslint-config-next": "^13.4.9",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2", "eslint-plugin-react": "^7.32.2",

View File

@@ -13,6 +13,7 @@
"scripts": { "scripts": {
}, },
"dependencies": { "dependencies": {
"@documenso/email": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@pdf-lib/fontkit": "^1.1.1", "@pdf-lib/fontkit": "^1.1.1",
"@next-auth/prisma-adapter": "^1.0.6", "@next-auth/prisma-adapter": "^1.0.6",
@@ -20,8 +21,9 @@
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"nanoid": "^4.0.2", "nanoid": "^4.0.2",
"next": "13.4.1", "next": "13.4.9",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"react": "18.2.0",
"stripe": "^12.7.0" "stripe": "^12.7.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,94 @@
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 { prisma } from '@documenso/prisma';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
export interface SendDocumentOptions {
documentId: number;
userId: number;
}
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const document = await prisma.document.findUnique({
where: {
id: documentId,
userId,
},
include: {
Recipient: true,
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.Recipient.length === 0) {
throw new Error('Document has no recipients');
}
if (document.status === DocumentStatus.COMPLETED) {
throw new Error('Can not send completed document');
}
await Promise.all([
document.Recipient.map(async (recipient) => {
const { email, name } = recipient;
if (recipient.sendStatus === SendStatus.SENT) {
return;
}
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
assetBaseUrl: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
inviterName: user.name || undefined,
inviterEmail: user.email,
signDocumentLink: 'https://example.com',
});
mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Please sign this document',
html: render(template),
text: render(template, { plainText: true }),
});
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
}),
]);
const updatedDocument = await prisma.document.update({
where: {
id: documentId,
},
data: {
status: DocumentStatus.PENDING,
},
});
return updatedDocument;
};

View File

@@ -48,24 +48,22 @@ export const setFieldsForDocument = async ({
), ),
); );
const linkedFields = fields.map((field) => { const linkedFields = fields
.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id); const existing = existingFields.find((existingField) => existingField.id === field.id);
return { return {
...field, ...field,
...existing, ...existing,
}; };
})
.filter((field) => {
return (
field.Recipient?.sendStatus !== SendStatus.SENT &&
field.Recipient?.signingStatus !== SigningStatus.SIGNED
);
}); });
for (const field of linkedFields) {
if (
field.Recipient?.sendStatus === SendStatus.SENT ||
field.Recipient?.signingStatus === SigningStatus.SIGNED
) {
throw new Error('Cannot modify fields after sending');
}
}
const persistedFields = await prisma.$transaction( const persistedFields = await prisma.$transaction(
linkedFields.map((field) => linkedFields.map((field) =>
field.id field.id

View File

@@ -43,7 +43,8 @@ export const setRecipientsForDocument = async ({
), ),
); );
const linkedRecipients = recipients.map((recipient) => { const linkedRecipients = recipients
.map((recipient) => {
const existing = existingRecipients.find( const existing = existingRecipients.find(
(existingRecipient) => (existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email, existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
@@ -53,17 +54,13 @@ export const setRecipientsForDocument = async ({
...recipient, ...recipient,
...existing, ...existing,
}; };
})
.filter((recipient) => {
return (
recipient.sendStatus !== SendStatus.SENT && recipient.signingStatus !== SigningStatus.SIGNED
);
}); });
for (const recipient of linkedRecipients) {
if (
recipient.sendStatus === SendStatus.SENT ||
recipient.signingStatus === SigningStatus.SIGNED
) {
throw new Error('Cannot modify recipients after sending');
}
}
const persistedRecipients = await prisma.$transaction( const persistedRecipients = await prisma.$transaction(
linkedRecipients.map((recipient) => linkedRecipients.map((recipient) =>
recipient.id recipient.id

View File

@@ -44,6 +44,10 @@ module.exports = {
DEFAULT: 'hsl(var(--card))', DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))', foreground: 'hsl(var(--card-foreground))',
}, },
widget: {
DEFAULT: 'hsl(var(--widget))',
// foreground: 'hsl(var(--widget-foreground))',
},
documenso: { documenso: {
DEFAULT: '#A2E771', DEFAULT: '#A2E771',
50: '#FFFFFF', 50: '#FFFFFF',

View File

@@ -1,10 +1,12 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { authenticatedProcedure, router } from '../trpc'; import { authenticatedProcedure, router } from '../trpc';
import { import {
ZSendDocumentMutationSchema,
ZSetFieldsForDocumentMutationSchema, ZSetFieldsForDocumentMutationSchema,
ZSetRecipientsForDocumentMutationSchema, ZSetRecipientsForDocumentMutationSchema,
} from './schema'; } from './schema';
@@ -52,4 +54,24 @@ export const documentRouter = router({
}); });
} }
}), }),
sendDocument: authenticatedProcedure
.input(ZSendDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId } = input;
return await sendDocument({
userId: ctx.user.id,
documentId,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to send this document. Please try again later.',
});
}
}),
}); });

View File

@@ -36,3 +36,9 @@ export const ZSetFieldsForDocumentMutationSchema = z.object({
export type TSetFieldsForDocumentMutationSchema = z.infer< export type TSetFieldsForDocumentMutationSchema = z.infer<
typeof ZSetFieldsForDocumentMutationSchema typeof ZSetFieldsForDocumentMutationSchema
>; >;
export const ZSendDocumentMutationSchema = z.object({
documentId: z.number(),
});
export type TSendDocumentMutationSchema = z.infer<typeof ZSendDocumentMutationSchema>;

View File

@@ -16,7 +16,8 @@
"preserveWatchOutput": true, "preserveWatchOutput": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"target": "ES2018" "target": "ES2018",
"types": ["@documenso/tsconfig/process-env.d.ts"]
}, },
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

36
packages/tsconfig/process-env.d.ts vendored Normal file
View File

@@ -0,0 +1,36 @@
declare namespace NodeJS {
export interface ProcessEnv {
NEXT_PUBLIC_SITE_URL?: string;
NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: string;
NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'smtp-auth' | 'smtp-api';
NEXT_PRIVATE_MAILCHANNELS_API_KEY?: string;
NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN?: string;
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR?: string;
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY?: string;
NEXT_PRIVATE_MAILCHANNELS_ENDPOINT?: string;
NEXT_PRIVATE_SMTP_HOST?: string;
NEXT_PRIVATE_SMTP_PORT?: string;
NEXT_PRIVATE_SMTP_USERNAME?: string;
NEXT_PRIVATE_SMTP_PASSWORD?: string;
NEXT_PRIVATE_SMTP_APIKEY_USER?: string;
NEXT_PRIVATE_SMTP_APIKEY?: string;
NEXT_PRIVATE_SMTP_SECURE?: string;
NEXT_PRIVATE_SMTP_FROM_NAME?: string;
NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string;
}
}

View File

@@ -16,6 +16,7 @@ const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>, React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => ( >(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content <TooltipPrimitive.Content
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
@@ -25,6 +26,7 @@ const TooltipContent = React.forwardRef<
)} )}
{...props} {...props}
/> />
</TooltipPrimitive.Portal>
)); ));
TooltipContent.displayName = TooltipPrimitive.Content.displayName; TooltipContent.displayName = TooltipPrimitive.Content.displayName;

View File

@@ -18,6 +18,21 @@
"NEXT_PUBLIC_SITE_URL", "NEXT_PUBLIC_SITE_URL",
"NEXT_PRIVATE_DATABASE_URL", "NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_NEXT_AUTH_SECRET", "NEXT_PRIVATE_NEXT_AUTH_SECRET",
"NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED" "NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED",
"NEXT_PRIVATE_SMTP_TRANSPORT",
"NEXT_PRIVATE_MAILCHANNELS_API_KEY",
"NEXT_PRIVATE_MAILCHANNELS_ENDPOINT",
"NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN",
"NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR",
"NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY",
"NEXT_PRIVATE_SMTP_HOST",
"NEXT_PRIVATE_SMTP_PORT",
"NEXT_PRIVATE_SMTP_USERNAME",
"NEXT_PRIVATE_SMTP_PASSWORD",
"NEXT_PRIVATE_SMTP_APIKEY_USER",
"NEXT_PRIVATE_SMTP_APIKEY",
"NEXT_PRIVATE_SMTP_SECURE",
"NEXT_PRIVATE_SMTP_FROM_NAME",
"NEXT_PRIVATE_SMTP_FROM_ADDRESS"
] ]
} }