From d11a68fc4ca877de6a823df48dd95dbe8c0a29c0 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sun, 2 Jun 2024 15:49:09 +1000 Subject: [PATCH] feat: add direct templates links (#1165) ## Description Direct templates links is a feature that provides template owners the ability to allow users to create documents based of their templates. ## General outline This works by allowing the template owner to configure a "direct recipient" in the template. When a user opens the direct link to the template, it will create a flow where they sign the fields configured by the template owner for the direct recipient. After these fields are signed the following will occur: - A document will be created where the owner is the template owner - The direct recipient fields will be signed - The document will be sent to any other recipients configured in the template - If there are none the document will be immediately completed ## Notes There's a custom prisma migration to migrate all documents to have 'DOCUMENT' as the source, then sets the column to required. --------- Co-authored-by: Lucas Smith --- .../src/pages/api/stripe/webhook/index.ts | 2 + .../templates/[id]/edit-template.tsx | 1 + .../template-direct-link-dialog-wrapper.tsx | 40 ++ .../templates/[id]/template-page-view.tsx | 36 +- .../templates/data-table-action-dropdown.tsx | 19 +- .../templates/data-table-templates.tsx | 82 ++- .../templates/template-direct-link-badge.tsx | 45 ++ .../templates/template-direct-link-dialog.tsx | 448 +++++++++++++++ .../templates/use-template-dialog.tsx | 2 +- .../d/[token]/configure-direct-template.tsx | 158 ++++++ .../(recipient)/d/[token]/direct-template.tsx | 156 ++++++ .../app/(recipient)/d/[token]/not-found.tsx | 33 ++ .../src/app/(recipient)/d/[token]/page.tsx | 92 +++ .../d/[token]/sign-direct-template.tsx | 278 +++++++++ .../d/[token]/signing-auth-page.tsx | 54 ++ apps/web/src/app/(recipient)/layout.tsx | 38 ++ .../(signing)/sign/[token]/complete/page.tsx | 4 +- .../app/(signing)/sign/[token]/date-field.tsx | 30 +- .../sign/[token]/document-auth-provider.tsx | 18 +- .../(signing)/sign/[token]/email-field.tsx | 34 +- .../src/app/(signing)/sign/[token]/form.tsx | 4 +- .../app/(signing)/sign/[token]/name-field.tsx | 30 +- .../src/app/(signing)/sign/[token]/page.tsx | 8 +- .../(signing)/sign/[token]/sign-dialog.tsx | 20 +- .../sign/[token]/signature-field.tsx | 35 +- .../app/(signing)/sign/[token]/text-field.tsx | 30 +- .../components/formatter/template-type.tsx | 4 +- packages/app-tests/e2e/fixtures/documents.ts | 2 +- .../e2e/templates/direct-templates.spec.ts | 339 +++++++++++ packages/ee/server-only/limits/constants.ts | 3 + packages/ee/server-only/limits/schema.ts | 4 + packages/ee/server-only/limits/server.ts | 44 +- .../webhook/on-early-adopters-checkout.ts | 2 + .../document-created-from-direct-template.tsx | 93 ++++ packages/lib/constants/template.ts | 26 + packages/lib/errors/app-error.ts | 1 + .../server-only/document/create-document.ts | 6 +- .../document/duplicate-document-by-id.ts | 3 +- .../document/get-document-by-token.ts | 4 +- .../document/is-recipient-authorized.ts | 8 +- .../server-only/document/send-document.tsx | 43 +- .../document/validate-field-auth.ts | 52 ++ .../field/sign-field-with-token.ts | 49 +- .../recipient/set-recipients-for-template.ts | 47 +- .../create-document-from-direct-template.ts | 526 ++++++++++++++++++ .../create-document-from-template-legacy.ts | 4 +- .../template/create-document-from-template.ts | 21 +- .../template/create-template-direct-link.ts | 107 ++++ .../template/delete-template-direct-link.ts | 68 +++ .../server-only/template/find-templates.ts | 9 + .../get-template-by-direct-link-token.ts | 33 ++ .../get-template-with-details-by-id.ts | 1 + .../template/toggle-template-direct-link.ts | 61 ++ packages/lib/types/document-audit-logs.ts | 18 +- packages/lib/utils/templates.ts | 44 ++ .../migration.sql | 45 ++ packages/prisma/schema.prisma | 29 +- packages/prisma/seed/documents.ts | 5 + packages/prisma/seed/initial-seed.ts | 3 +- packages/prisma/seed/templates.ts | 84 +++ packages/prisma/seed/users.ts | 4 +- packages/prisma/types/template.ts | 2 + .../trpc/server/singleplayer-router/router.ts | 2 + .../trpc/server/template-router/router.ts | 102 +++- .../trpc/server/template-router/schema.ts | 23 + packages/trpc/server/trpc.ts | 13 +- .../recipient/recipient-role-select.tsx | 156 +++--- .../ui/primitives/data-table-pagination.tsx | 2 +- packages/ui/primitives/dialog.tsx | 5 +- packages/ui/primitives/pdf-viewer.tsx | 2 - .../add-template-placeholder-recipients.tsx | 123 ++-- 71 files changed, 3636 insertions(+), 283 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/templates/[id]/template-direct-link-dialog-wrapper.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/template-direct-link-badge.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx create mode 100644 apps/web/src/app/(recipient)/d/[token]/configure-direct-template.tsx create mode 100644 apps/web/src/app/(recipient)/d/[token]/direct-template.tsx create mode 100644 apps/web/src/app/(recipient)/d/[token]/not-found.tsx create mode 100644 apps/web/src/app/(recipient)/d/[token]/page.tsx create mode 100644 apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx create mode 100644 apps/web/src/app/(recipient)/d/[token]/signing-auth-page.tsx create mode 100644 apps/web/src/app/(recipient)/layout.tsx create mode 100644 packages/app-tests/e2e/templates/direct-templates.spec.ts create mode 100644 packages/email/templates/document-created-from-direct-template.tsx create mode 100644 packages/lib/server-only/document/validate-field-auth.ts create mode 100644 packages/lib/server-only/template/create-document-from-direct-template.ts create mode 100644 packages/lib/server-only/template/create-template-direct-link.ts create mode 100644 packages/lib/server-only/template/delete-template-direct-link.ts create mode 100644 packages/lib/server-only/template/get-template-by-direct-link-token.ts create mode 100644 packages/lib/server-only/template/toggle-template-direct-link.ts create mode 100644 packages/lib/utils/templates.ts create mode 100644 packages/prisma/migrations/20240530055436_add_template_direct_links/migration.sql diff --git a/apps/marketing/src/pages/api/stripe/webhook/index.ts b/apps/marketing/src/pages/api/stripe/webhook/index.ts index a19cffda9..fa8cb60e4 100644 --- a/apps/marketing/src/pages/api/stripe/webhook/index.ts +++ b/apps/marketing/src/pages/api/stripe/webhook/index.ts @@ -13,6 +13,7 @@ import { updateFile } from '@documenso/lib/universal/upload/update-file'; import { prisma } from '@documenso/prisma'; import { DocumentDataType, + DocumentSource, DocumentStatus, FieldType, ReadStatus, @@ -104,6 +105,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const document = await prisma.document.create({ data: { + source: DocumentSource.DOCUMENT, title: 'Documenso Supporter Pledge.pdf', status: DocumentStatus.COMPLETED, userId: user.id, diff --git a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx index 21be26129..b95bb9a73 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx @@ -256,6 +256,7 @@ export const EditTemplateForm = ({ documentFlow={documentFlow.signers} recipients={recipients} fields={fields} + templateDirectLink={template.directLink} onSubmit={onAddTemplatePlaceholderFormSubmit} isEnterprise={isEnterprise} isDocumentPdfLoaded={isDocumentPdfLoaded} diff --git a/apps/web/src/app/(dashboard)/templates/[id]/template-direct-link-dialog-wrapper.tsx b/apps/web/src/app/(dashboard)/templates/[id]/template-direct-link-dialog-wrapper.tsx new file mode 100644 index 000000000..39d057248 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/[id]/template-direct-link-dialog-wrapper.tsx @@ -0,0 +1,40 @@ +'use client'; + +import React, { useState } from 'react'; + +import { LinkIcon } from 'lucide-react'; + +import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; + +import { TemplateDirectLinkDialog } from '../template-direct-link-dialog'; + +export type TemplatePageViewProps = { + template: Template & { directLink?: TemplateDirectLink | null; Recipient: Recipient[] }; +}; + +export const TemplateDirectLinkDialogWrapper = ({ template }: TemplatePageViewProps) => { + const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false); + + return ( +
+ + + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx index 69a38f0a9..36071ffee 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx @@ -13,7 +13,9 @@ import type { Team } from '@documenso/prisma/client'; import { TemplateType } from '~/components/formatter/template-type'; +import { TemplateDirectLinkBadge } from '../template-direct-link-badge'; import { EditTemplateForm } from './edit-template'; +import { TemplateDirectLinkDialogWrapper } from './template-direct-link-dialog-wrapper'; export type TemplatePageViewProps = { params: { @@ -50,17 +52,33 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps) return (
- - - Templates - +
+
+ + + Templates + -

- {template.title} -

+

+ {template.title} +

-
- +
+ + + {template.directLink?.token && ( + + )} +
+
+ +
+ +
+ setTemplateDirectLinkDialogOpen(true)}> + + Direct link + + setDeleteDialogOpen(true)} @@ -82,6 +89,12 @@ export const DataTableActionDropdown = ({ onOpenChange={setDuplicateDialogOpen} /> + + ; + templates: FindTemplateRow[]; perPage: number; page: number; totalPages: number; @@ -48,6 +42,7 @@ export const TemplatesDataTable = ({ teamId, }: TemplatesDataTableProps) => { const [isPending, startTransition] = useTransition(); + const updateSearchParams = useUpdateSearchParams(); const { remaining } = useLimits(); @@ -88,9 +83,70 @@ export const TemplatesDataTable = ({ cell: ({ row }) => , }, { - header: 'Type', + header: () => ( +
+ Type + + + + + + +
    +
  • +

    + + Public +

    + +

    + Public templates are connected to your public profile. Any modifications + to public templates will also appear in your public profile. +

    +
  • +
  • +
    + + direct link +
    + +

    + Direct link templates contain one dynamic recipient placeholder. Anyone + with access to this link can sign the document, and it will then appear on + your documents page. +

    +
  • +
  • +

    + + {teamId ? 'Team Only' : 'Private'} +

    + +

    + {teamId + ? 'Team only templates are not linked anywhere and are visible only to your team.' + : 'Private templates can only be modified and viewed by you.'} +

    +
  • +
+
+
+
+ ), accessorKey: 'type', - cell: ({ row }) => , + cell: ({ row }) => ( +
+ + + {row.original.directLink?.token && ( + + )} +
+ ), }, { header: 'Actions', diff --git a/apps/web/src/app/(dashboard)/templates/template-direct-link-badge.tsx b/apps/web/src/app/(dashboard)/templates/template-direct-link-badge.tsx new file mode 100644 index 000000000..6c02b23c9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/template-direct-link-badge.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { Link2Icon } from 'lucide-react'; + +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; +import { cn } from '@documenso/ui/lib/utils'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +type TemplateDirectLinkBadgeProps = { + token: string; + enabled: boolean; + className?: string; +}; + +export const TemplateDirectLinkBadge = ({ + token, + enabled, + className, +}: TemplateDirectLinkBadgeProps) => { + const [, copy] = useCopyToClipboard(); + const { toast } = useToast(); + + const onCopyClick = async (token: string) => + copy(formatDirectTemplatePath(token)).then(() => { + toast({ + title: 'Copied to clipboard', + description: 'The direct link has been copied to your clipboard', + }); + }); + + return ( + + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx b/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx new file mode 100644 index 000000000..6874fef90 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx @@ -0,0 +1,448 @@ +import { useEffect, useMemo, useState } from 'react'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react'; +import { P, match } from 'ts-pattern'; + +import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { + DIRECT_TEMPLATE_DOCUMENTATION, + DIRECT_TEMPLATE_RECIPIENT_EMAIL, +} from '@documenso/lib/constants/template'; +import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; +import { + type Recipient, + RecipientRole, + type Template, + type TemplateDirectLink, +} from '@documenso/prisma/client'; +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@documenso/ui/primitives/table'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +type TemplateDirectLinkDialogProps = { + template: Template & { + directLink?: Pick | null; + Recipient: Recipient[]; + }; + open: boolean; + onOpenChange: (_open: boolean) => void; +}; + +type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE'; + +export const TemplateDirectLinkDialog = ({ + template, + open, + onOpenChange, +}: TemplateDirectLinkDialogProps) => { + const { toast } = useToast(); + const { quota, remaining } = useLimits(); + + const [, copy] = useCopyToClipboard(); + const router = useRouter(); + + const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false); + const [token, setToken] = useState(template.directLink?.token ?? null); + const [selectedRecipientId, setSelectedRecipientId] = useState(null); + const [currentStep, setCurrentStep] = useState( + token ? 'MANAGE' : 'ONBOARD', + ); + + const validDirectTemplateRecipients = useMemo( + () => template.Recipient.filter((recipient) => recipient.role !== RecipientRole.CC), + [template.Recipient], + ); + + const { + mutateAsync: createTemplateDirectLink, + isLoading: isCreatingTemplateDirectLink, + reset: resetCreateTemplateDirectLink, + } = trpcReact.template.createTemplateDirectLink.useMutation({ + onSuccess: (data) => { + setToken(data.token); + setIsEnabled(data.enabled); + setCurrentStep('MANAGE'); + + router.refresh(); + }, + onError: () => { + setSelectedRecipientId(null); + + toast({ + title: 'Something went wrong', + description: 'Unable to create direct template access. Please try again later.', + variant: 'destructive', + }); + }, + }); + + const { mutateAsync: toggleTemplateDirectLink, isLoading: isTogglingTemplateAccess } = + trpcReact.template.toggleTemplateDirectLink.useMutation({ + onSuccess: (data) => { + toast({ + title: 'Success', + description: `Direct link signing has been ${data.enabled ? 'enabled' : 'disabled'}`, + }); + }, + onError: (_ctx, data) => { + toast({ + title: 'Something went wrong', + description: `An error occurred while ${ + data.enabled ? 'enabling' : 'disabling' + } direct link signing.`, + variant: 'destructive', + }); + }, + }); + + const { mutateAsync: deleteTemplateDirectLink, isLoading: isDeletingTemplateDirectLink } = + trpcReact.template.deleteTemplateDirectLink.useMutation({ + onSuccess: () => { + onOpenChange(false); + setToken(null); + + toast({ + title: 'Success', + description: 'Direct template link deleted', + duration: 5000, + }); + + router.refresh(); + setToken(null); + }, + onError: () => { + toast({ + title: 'Something went wrong', + description: + 'We encountered an error while removing the direct template link. Please try again later.', + variant: 'destructive', + }); + }, + }); + + const onCopyClick = async (token: string) => + copy(formatDirectTemplatePath(token)).then(() => { + toast({ + title: 'Copied to clipboard', + description: 'The direct link has been copied to your clipboard', + }); + }); + + const onRecipientTableRowClick = async (recipientId: number) => { + if (isLoading) { + return; + } + + setSelectedRecipientId(recipientId); + + await createTemplateDirectLink({ + templateId: template.id, + directRecipientId: recipientId, + }); + }; + + const isLoading = + isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink; + + useEffect(() => { + resetCreateTemplateDirectLink(); + setCurrentStep(token ? 'MANAGE' : 'ONBOARD'); + setSelectedRecipientId(null); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + return ( + !isLoading && onOpenChange(value)}> +
+ + {match({ token, currentStep }) + .with({ token: P.nullish, currentStep: 'ONBOARD' }, () => ( + + + Create Direct Signing Link + + Here's how it works: + + +
    + {DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => ( +
  • +
    +
    + {index + 1} +
    +
    + +

    {step.title}

    +

    {step.description}

    +
  • + ))} +
+ + {remaining.directTemplates === 0 && ( + + + Direct template link usage exceeded ({quota.directTemplates}/ + {quota.directTemplates}) + + + You have reached the maximum limit of {quota.directTemplates} direct + templates.{' '} + + Upgrade your account to continue! + + + + )} + + {remaining.directTemplates !== 0 && ( + + + + )} +
+ )) + .with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => ( + + {isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && ( +
+ +
+ )} + + + Choose Direct Link Recipient + + + Choose an existing recipient from below to continue + + + +
+ + + + Recipient + Role + + + + + {validDirectTemplateRecipients.length === 0 && ( + + +

No valid recipients found

+
+
+ )} + + {validDirectTemplateRecipients.map((row) => ( + onRecipientTableRowClick(row.id)} + > + +
+

{row.name}

+

{row.email}

+
+
+ + + {RECIPIENT_ROLES_DESCRIPTION[row.role].roleName} + + + + {selectedRecipientId === row.id ? ( + + ) : ( + + )} + +
+ ))} +
+
+
+ + {/* Prevent creating placeholder direct template recipient if the email already exists. */} + {!template.Recipient.some( + (recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL, + ) && ( + +
+ {validDirectTemplateRecipients.length !== 0 && ( +

Or

+ )} + + +
+
+ )} +
+ )) + .with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => ( + + + Direct Link Signing + + + Manage the direct link signing for this template + + + +
+
+ + + setIsEnabled(value)} + /> +
+ +
+ + +
+ + +
+ +
+
+
+
+ + + + + + +
+ )) + .with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => ( + + + Are you sure? + + + Please note that proceeding will remove direct linking recipient and turn it + into a placeholder. + + + + + + + + + + )) + .otherwise(() => null)} +
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx index d144eba3b..20cba75da 100644 --- a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx @@ -172,7 +172,7 @@ export function UseTemplateDialog({ return ( !form.formState.isSubmitting && setOpen(value)}> - diff --git a/apps/web/src/app/(recipient)/d/[token]/configure-direct-template.tsx b/apps/web/src/app/(recipient)/d/[token]/configure-direct-template.tsx new file mode 100644 index 000000000..41ce61815 --- /dev/null +++ b/apps/web/src/app/(recipient)/d/[token]/configure-direct-template.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useSession } from 'next-auth/react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import type { Field, Recipient } from '@documenso/prisma/client'; +import type { TemplateWithDetails } from '@documenso/prisma/types/template'; +import { + DocumentFlowFormContainerActions, + DocumentFlowFormContainerContent, + DocumentFlowFormContainerFooter, + DocumentFlowFormContainerHeader, + DocumentFlowFormContainerStep, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import { ShowFieldItem } from '@documenso/ui/primitives/document-flow/show-field-item'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useStep } from '@documenso/ui/primitives/stepper'; + +import { useRequiredDocumentAuthContext } from '~/app/(signing)/sign/[token]/document-auth-provider'; + +const ZConfigureDirectTemplateFormSchema = z.object({ + email: z.string().email('Email is invalid'), +}); + +export type TConfigureDirectTemplateFormSchema = z.infer; + +export type ConfigureDirectTemplateFormProps = { + flowStep: DocumentFlowStep; + isDocumentPdfLoaded: boolean; + template: TemplateWithDetails; + directTemplateRecipient: Recipient & { Field: Field[] }; + initialEmail?: string; + onSubmit: (_data: TConfigureDirectTemplateFormSchema) => void; +}; + +export const ConfigureDirectTemplateFormPartial = ({ + flowStep, + isDocumentPdfLoaded, + template, + directTemplateRecipient, + initialEmail, + onSubmit, +}: ConfigureDirectTemplateFormProps) => { + const { Recipient } = template; + const { derivedRecipientAccessAuth } = useRequiredDocumentAuthContext(); + const { data: session } = useSession(); + + const recipientsWithBlankDirectRecipientEmail = Recipient.map((recipient) => { + if (recipient.id === directTemplateRecipient.id) { + return { + ...recipient, + email: '', + }; + } + + return recipient; + }); + + const form = useForm({ + resolver: zodResolver( + ZConfigureDirectTemplateFormSchema.superRefine((items, ctx) => { + if (template.Recipient.map((recipient) => recipient.email).includes(items.email)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Email cannot already exist in the template', + path: ['email'], + }); + } + }), + ), + defaultValues: { + email: initialEmail || '', + }, + }); + + const { stepIndex, currentStep, totalSteps, previousStep } = useStep(); + + return ( + <> + + + + {isDocumentPdfLoaded && + directTemplateRecipient.Field.map((field, index) => ( + + ))} + +
+
+ ( + + Email + + + + + + {!fieldState.error && ( +

+ Enter your email address to receive the completed document. +

+ )} + + +
+ )} + /> +
+
+
+ + + + + + + + ); +}; diff --git a/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx b/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx new file mode 100644 index 000000000..8bb3756f4 --- /dev/null +++ b/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import type { Field } from '@documenso/prisma/client'; +import { type Recipient } from '@documenso/prisma/client'; +import type { TemplateWithDetails } from '@documenso/prisma/types/template'; +import { trpc } from '@documenso/trpc/react'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { Stepper } from '@documenso/ui/primitives/stepper'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentAuthContext } from '~/app/(signing)/sign/[token]/document-auth-provider'; +import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; + +import type { TConfigureDirectTemplateFormSchema } from './configure-direct-template'; +import { ConfigureDirectTemplateFormPartial } from './configure-direct-template'; +import type { DirectTemplateLocalField } from './sign-direct-template'; +import { SignDirectTemplateForm } from './sign-direct-template'; + +export type TemplatesDirectPageViewProps = { + template: TemplateWithDetails; + directTemplateToken: string; + directTemplateRecipient: Recipient & { Field: Field[] }; +}; + +type DirectTemplateStep = 'configure' | 'sign'; +const DirectTemplateSteps: DirectTemplateStep[] = ['configure', 'sign']; + +export const DirectTemplatePageView = ({ + template, + directTemplateRecipient, + directTemplateToken, +}: TemplatesDirectPageViewProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const { email, setEmail } = useRequiredSigningContext(); + const { recipient, setRecipient } = useRequiredDocumentAuthContext(); + + const [step, setStep] = useState('configure'); + const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); + + const directTemplateFlow: Record = { + configure: { + title: 'General', + description: 'Preview and configure template.', + stepIndex: 1, + }, + sign: { + title: 'Sign document', + description: 'Sign the document to complete the process.', + stepIndex: 2, + }, + }; + + const { mutateAsync: createDocumentFromDirectTemplate } = + trpc.template.createDocumentFromDirectTemplate.useMutation(); + + /** + * Set the email into a temporary recipient so it can be used for reauth and signing email fields. + */ + const onConfigureDirectTemplateSubmit = ({ email }: TConfigureDirectTemplateFormSchema) => { + setEmail(email); + + setRecipient({ + ...recipient, + email, + }); + + setStep('sign'); + }; + + const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => { + try { + const token = await createDocumentFromDirectTemplate({ + directTemplateToken, + directRecipientEmail: recipient.email, + templateUpdatedAt: template.updatedAt, + signedFieldValues: fields.map((field) => { + if (!field.signedValue) { + throw new Error('Invalid configuration'); + } + + return field.signedValue; + }), + }); + + const redirectUrl = template.templateMeta?.redirectUrl; + + redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${token}/complete`); + } catch (err) { + toast({ + title: 'Something went wrong', + description: 'We were unable to submit this document at this time. Please try again later.', + variant: 'destructive', + }); + + throw err; + } + }; + + const currentDocumentFlow = directTemplateFlow[step]; + + return ( +
+ + + setIsDocumentPdfLoaded(true)} + /> + + + +
+ e.preventDefault()} + > + setStep(DirectTemplateSteps[step - 1])} + > + + + + + +
+
+ ); +}; diff --git a/apps/web/src/app/(recipient)/d/[token]/not-found.tsx b/apps/web/src/app/(recipient)/d/[token]/not-found.tsx new file mode 100644 index 000000000..2fe561ba1 --- /dev/null +++ b/apps/web/src/app/(recipient)/d/[token]/not-found.tsx @@ -0,0 +1,33 @@ +'use client'; + +import Link from 'next/link'; + +import { ChevronLeft } from 'lucide-react'; + +import { Button } from '@documenso/ui/primitives/button'; + +export default function NotFound() { + return ( +
+
+

404 Template not found

+ +

Oops! Something went wrong.

+ +

+ The template you are looking for may have been disabled, deleted or may have never + existed. +

+ +
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/(recipient)/d/[token]/page.tsx b/apps/web/src/app/(recipient)/d/[token]/page.tsx new file mode 100644 index 000000000..d7b2eb682 --- /dev/null +++ b/apps/web/src/app/(recipient)/d/[token]/page.tsx @@ -0,0 +1,92 @@ +import { notFound, redirect } from 'next/navigation'; + +import { UsersIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token'; +import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; +import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; + +import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider'; +import { SigningProvider } from '~/app/(signing)/sign/[token]/provider'; +import { truncateTitle } from '~/helpers/truncate-title'; + +import { DirectTemplatePageView } from './direct-template'; +import { DirectTemplateAuthPageView } from './signing-auth-page'; + +export type TemplatesDirectPageProps = { + params: { + token: string; + }; +}; + +export default async function TemplatesDirectPage({ params }: TemplatesDirectPageProps) { + const { token } = params; + + if (!token) { + redirect('/'); + } + + const { user } = await getServerComponentSession(); + + const template = await getTemplateByDirectLinkToken({ + token, + }).catch(() => null); + + if (!template || !template.directLink?.enabled) { + notFound(); + } + + const directTemplateRecipient = template.Recipient.find( + (recipient) => recipient.id === template.directLink?.directTemplateRecipientId, + ); + + if (!directTemplateRecipient) { + notFound(); + } + + const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ + documentAuth: template.authOptions, + }); + + // Ensure typesafety when we add more options. + const isAccessAuthValid = match(derivedRecipientAccessAuth) + .with(DocumentAccessAuth.ACCOUNT, () => user !== null) + .with(null, () => true) + .exhaustive(); + + if (!isAccessAuthValid) { + return ; + } + + return ( + + +
+

+ {truncateTitle(template.title)} +

+ +
+ +

+ {template.Recipient.length}{' '} + {template.Recipient.length > 1 ? 'recipients' : 'recipient'} +

+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx b/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx new file mode 100644 index 000000000..1531b6969 --- /dev/null +++ b/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx @@ -0,0 +1,278 @@ +import { useMemo, useState } from 'react'; + +import { DateTime } from 'luxon'; +import { match } from 'ts-pattern'; + +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; +import type { Field, Recipient, Signature } from '@documenso/prisma/client'; +import { FieldType } from '@documenso/prisma/client'; +import type { TemplateWithDetails } from '@documenso/prisma/types/template'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + DocumentFlowFormContainerContent, + DocumentFlowFormContainerFooter, + DocumentFlowFormContainerHeader, + DocumentFlowFormContainerStep, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { ElementVisible } from '@documenso/ui/primitives/element-visible'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { useStep } from '@documenso/ui/primitives/stepper'; + +import { DateField } from '~/app/(signing)/sign/[token]/date-field'; +import { EmailField } from '~/app/(signing)/sign/[token]/email-field'; +import { NameField } from '~/app/(signing)/sign/[token]/name-field'; +import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; +import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog'; +import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field'; +import { TextField } from '~/app/(signing)/sign/[token]/text-field'; + +export type SignDirectTemplateFormProps = { + flowStep: DocumentFlowStep; + directRecipient: Recipient; + directRecipientFields: Field[]; + template: TemplateWithDetails; + onSubmit: (_data: DirectTemplateLocalField[]) => Promise; +}; + +export type DirectTemplateLocalField = Field & { + signedValue?: TSignFieldWithTokenMutationSchema; + Signature?: Signature; +}; + +export const SignDirectTemplateForm = ({ + flowStep, + directRecipient, + directRecipientFields, + template, + onSubmit, +}: SignDirectTemplateFormProps) => { + const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext(); + + const [localFields, setLocalFields] = useState(directRecipientFields); + const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { currentStep, totalSteps, previousStep } = useStep(); + + const onSignField = (value: TSignFieldWithTokenMutationSchema) => { + setLocalFields( + localFields.map((field) => { + if (field.id !== value.fieldId) { + return field; + } + + const tempField: DirectTemplateLocalField = { + ...field, + customText: value.value, + inserted: true, + signedValue: value, + }; + + if (field.type === FieldType.SIGNATURE) { + tempField.Signature = { + id: 1, + created: new Date(), + recipientId: 1, + fieldId: 1, + signatureImageAsBase64: value.value, + typedSignature: null, + }; + } + + if (field.type === FieldType.DATE) { + tempField.customText = DateTime.now() + .setZone(template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE) + .toFormat(template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT); + } + return tempField; + }), + ); + }; + + const onUnsignField = (value: TRemovedSignedFieldWithTokenMutationSchema) => { + setLocalFields( + localFields.map((field) => { + if (field.id !== value.fieldId) { + return field; + } + + return { + ...field, + customText: '', + inserted: false, + signedValue: undefined, + Signature: undefined, + }; + }), + ); + }; + + const uninsertedFields = useMemo(() => { + return sortFieldsByPosition(localFields.filter((field) => !field.inserted)); + }, [localFields]); + + const fieldsValidated = () => { + setValidateUninsertedFields(true); + validateFieldsInserted(localFields); + }; + + const handleSubmit = async () => { + setValidateUninsertedFields(true); + + const isFieldsValid = validateFieldsInserted(localFields); + + if (!isFieldsValid) { + return; + } + + setIsSubmitting(true); + + try { + await onSubmit(localFields); + } catch { + setIsSubmitting(false); + } + + // Do not reset to false since we do a redirect. + }; + + return ( + <> + + + + + {validateUninsertedFields && uninsertedFields[0] && ( + + Click to insert field + + )} + + {localFields.map((field) => + match(field.type) + .with(FieldType.SIGNATURE, () => ( + + )) + .with(FieldType.NAME, () => ( + + )) + .with(FieldType.DATE, () => ( + + )) + .with(FieldType.EMAIL, () => ( + + )) + .with(FieldType.TEXT, () => ( + + )) + .otherwise(() => null), + )} + + +
+
+
+ + + setFullName(e.target.value.trimStart())} + /> +
+ +
+ + + + + { + setSignature(value); + }} + /> + + +
+
+
+
+ + + + +
+ + + +
+
+ + ); +}; diff --git a/apps/web/src/app/(recipient)/d/[token]/signing-auth-page.tsx b/apps/web/src/app/(recipient)/d/[token]/signing-auth-page.tsx new file mode 100644 index 000000000..a3f077ad9 --- /dev/null +++ b/apps/web/src/app/(recipient)/d/[token]/signing-auth-page.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useState } from 'react'; + +import { signOut } from 'next-auth/react'; + +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export const DirectTemplateAuthPageView = () => { + const { toast } = useToast(); + + const [isSigningOut, setIsSigningOut] = useState(false); + + const handleChangeAccount = async () => { + try { + setIsSigningOut(true); + + await signOut({ + callbackUrl: '/signin', + }); + } catch { + toast({ + title: 'Something went wrong', + description: 'We were unable to log you out at this time.', + duration: 10000, + variant: 'destructive', + }); + } + + setIsSigningOut(false); + }; + + return ( +
+
+

Authentication required

+ +

+ You need to be logged in to view this page. +

+ + +
+
+ ); +}; diff --git a/apps/web/src/app/(recipient)/layout.tsx b/apps/web/src/app/(recipient)/layout.tsx new file mode 100644 index 000000000..fb4bd0622 --- /dev/null +++ b/apps/web/src/app/(recipient)/layout.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; +import { getTeams } from '@documenso/lib/server-only/team/get-teams'; + +import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header'; +import { NextAuthProvider } from '~/providers/next-auth'; + +type RecipientLayoutProps = { + children: React.ReactNode; +}; + +/** + * A layout to handle scenarios where the user is a recipient of a given resource + * where we do not care whether they are authenticated or not. + * + * Such as direct template access, or signing. + */ +export default async function RecipientLayout({ children }: RecipientLayoutProps) { + const { user, session } = await getServerComponentSession(); + + let teams: GetTeamsResponse = []; + + if (user && session) { + teams = await getTeams({ userId: user.id }); + } + + return ( + +
+ {user && } + +
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index 1b7adfe70..f505b0692 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -67,7 +67,7 @@ export default async function CompletedSigningPage({ const isDocumentAccessValid = await isRecipientAuthorized({ type: 'ACCESS', - document, + documentAuthOptions: document.authOptions, recipient, userId: user?.id, }); @@ -131,7 +131,7 @@ export default async function CompletedSigningPage({
)) .with({ deletedAt: null }, () => ( -
+
Waiting for others to sign
diff --git a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx index dc1799bc1..5bee91a9b 100644 --- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx @@ -17,6 +17,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { SigningFieldContainer } from './signing-field-container'; @@ -26,6 +30,8 @@ export type DateFieldProps = { recipient: Recipient; dateFormat?: string | null; timezone?: string | null; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; export const DateField = ({ @@ -33,6 +39,8 @@ export const DateField = ({ recipient, dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT, timezone = DEFAULT_DOCUMENT_TIME_ZONE, + onSignField, + onUnsignField, }: DateFieldProps) => { const router = useRouter(); @@ -58,12 +66,19 @@ export const DateField = ({ const onSign = async (authOptions?: TRecipientActionAuth) => { try { - await signFieldWithToken({ + const payload: TSignFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, authOptions, - }); + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { @@ -85,10 +100,17 @@ export const DateField = ({ const onRemove = async () => { try { - await removeSignedFieldWithToken({ + const payload: TRemovedSignedFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, - }); + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { diff --git a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx index 86f673db0..c5c32f414 100644 --- a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx @@ -34,9 +34,9 @@ type PasskeyData = { export type DocumentAuthContextValue = { executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise; - document: Document; + documentAuthOptions: Document['authOptions']; documentAuthOption: TDocumentAuthOptions; - setDocument: (_value: Document) => void; + setDocumentAuthOptions: (_value: Document['authOptions']) => void; recipient: Recipient; recipientAuthOption: TRecipientAuthOptions; setRecipient: (_value: Recipient) => void; @@ -69,19 +69,19 @@ export const useRequiredDocumentAuthContext = () => { }; export interface DocumentAuthProviderProps { - document: Document; + documentAuthOptions: Document['authOptions']; recipient: Recipient; user?: User | null; children: React.ReactNode; } export const DocumentAuthProvider = ({ - document: initialDocument, + documentAuthOptions: initialDocumentAuthOptions, recipient: initialRecipient, user, children, }: DocumentAuthProviderProps) => { - const [document, setDocument] = useState(initialDocument); + const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions); const [recipient, setRecipient] = useState(initialRecipient); const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false); @@ -95,10 +95,10 @@ export const DocumentAuthProvider = ({ } = useMemo( () => extractDocumentAuthMethods({ - documentAuth: document.authOptions, + documentAuth: documentAuthOptions, recipientAuth: recipient.authOptions, }), - [document, recipient], + [documentAuthOptions, recipient], ); const passkeyQuery = trpc.auth.findPasskeys.useQuery( @@ -189,8 +189,8 @@ export const DocumentAuthProvider = ({ Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const EmailField = ({ field, recipient }: EmailFieldProps) => { +export const EmailField = ({ field, recipient, onSignField, onUnsignField }: EmailFieldProps) => { const router = useRouter(); const { toast } = useToast(); @@ -43,13 +49,22 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => { const onSign = async (authOptions?: TRecipientActionAuth) => { try { - await signFieldWithToken({ + const value = providedEmail ?? ''; + + const payload: TSignFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, - value: providedEmail ?? '', + value, isBase64: false, authOptions, - }); + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { @@ -71,10 +86,17 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => { const onRemove = async () => { try { - await removeSignedFieldWithToken({ + const payload: TRemovedSignedFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, - }); + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 70897a716..adcbfa16c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -145,7 +145,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const NameField = ({ field, recipient }: NameFieldProps) => { +export const NameField = ({ field, recipient, onSignField, onUnsignField }: NameFieldProps) => { const router = useRouter(); const { toast } = useToast(); @@ -83,13 +89,20 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { return; } - await signFieldWithToken({ + const payload: TSignFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, value, isBase64: false, authOptions, - }); + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { @@ -111,10 +124,17 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { const onRemove = async () => { try { - await removeSignedFieldWithToken({ + const payload: TRemovedSignedFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, - }); + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index b066193e6..3ae09f662 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -65,7 +65,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp const isDocumentAccessValid = await isRecipientAuthorized({ type: 'ACCESS', - document, + documentAuthOptions: document.authOptions, recipient, userId: user?.id, }); @@ -126,7 +126,11 @@ export default async function SigningPage({ params: { token } }: SigningPageProp fullName={user?.email === recipient.email ? user.name : recipient.name} signature={user?.email === recipient.email ? user.signature : undefined} > - + void | Promise; onSignatureComplete: () => void | Promise; @@ -25,14 +25,14 @@ export type SignDialogProps = { export const SignDialog = ({ isSubmitting, - document, + documentTitle, fields, fieldsValidated, onSignatureComplete, role, }: SignDialogProps) => { const [showDialog, setShowDialog] = useState(false); - const truncatedTitle = truncateTitle(document.title); + const truncatedTitle = truncateTitle(documentTitle); const isComplete = fields.every((field) => field.inserted); const handleOpenChange = (open: boolean) => { @@ -40,18 +40,6 @@ export const SignDialog = ({ return; } - // Reauth is currently not required for signing the document. - // if (isAuthRedirectRequired) { - // await executeActionAuthProcedure({ - // actionTarget: 'DOCUMENT', - // onReauthFormSubmit: () => { - // // Do nothing since the user should be redirected. - // }, - // }); - - // return; - // } - setShowDialog(open); }; diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx index 94051f75b..e6c39ab08 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { type Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Label } from '@documenso/ui/primitives/label'; @@ -29,9 +33,16 @@ type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text'; export type SignatureFieldProps = { field: FieldWithSignature; recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { +export const SignatureField = ({ + field, + recipient, + onSignField, + onUnsignField, +}: SignatureFieldProps) => { const router = useRouter(); const { toast } = useToast(); @@ -105,13 +116,20 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { return; } - await signFieldWithToken({ + const payload: TSignFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, value, isBase64: true, authOptions, - }); + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { @@ -133,10 +151,17 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { const onRemove = async () => { try { - await removeSignedFieldWithToken({ + const payload: TRemovedSignedFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, - }); + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { diff --git a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx index 8b78229be..ec063315d 100644 --- a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx @@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Input } from '@documenso/ui/primitives/input'; @@ -24,9 +28,11 @@ import { SigningFieldContainer } from './signing-field-container'; export type TextFieldProps = { field: FieldWithSignature; recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const TextField = ({ field, recipient }: TextFieldProps) => { +export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => { const router = useRouter(); const { toast } = useToast(); @@ -81,13 +87,20 @@ export const TextField = ({ field, recipient }: TextFieldProps) => { return; } - await signFieldWithToken({ + const payload: TSignFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, value: localText, isBase64: true, authOptions, - }); + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); setLocalCustomText(''); @@ -111,10 +124,17 @@ export const TextField = ({ field, recipient }: TextFieldProps) => { const onRemove = async () => { try { - await removeSignedFieldWithToken({ + const payload: TRemovedSignedFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, - }); + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { diff --git a/apps/web/src/components/formatter/template-type.tsx b/apps/web/src/components/formatter/template-type.tsx index 3bcb3b05e..f17728391 100644 --- a/apps/web/src/components/formatter/template-type.tsx +++ b/apps/web/src/components/formatter/template-type.tsx @@ -1,6 +1,6 @@ import type { HTMLAttributes } from 'react'; -import { Globe, Lock } from 'lucide-react'; +import { Globe2, Lock } from 'lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client'; @@ -22,7 +22,7 @@ const TEMPLATE_TYPES: Record = { }, PUBLIC: { label: 'Public', - icon: Globe, + icon: Globe2, color: 'text-green-500 dark:text-green-300', }, }; diff --git a/packages/app-tests/e2e/fixtures/documents.ts b/packages/app-tests/e2e/fixtures/documents.ts index f7e0bd391..160dc1030 100644 --- a/packages/app-tests/e2e/fixtures/documents.ts +++ b/packages/app-tests/e2e/fixtures/documents.ts @@ -13,5 +13,5 @@ export const checkDocumentTabCount = async (page: Page, tabName: string, count: return; } - await expect(page.getByRole('main')).toContainText(`Showing ${count}`); + await expect(page.getByTestId('data-table-count')).toContainText(`Showing ${count}`); }; diff --git a/packages/app-tests/e2e/templates/direct-templates.spec.ts b/packages/app-tests/e2e/templates/direct-templates.spec.ts new file mode 100644 index 000000000..518dc28c0 --- /dev/null +++ b/packages/app-tests/e2e/templates/direct-templates.spec.ts @@ -0,0 +1,339 @@ +import { expect, test } from '@playwright/test'; +import { customAlphabet } from 'nanoid'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { + DIRECT_TEMPLATE_RECIPIENT_EMAIL, + DIRECT_TEMPLATE_RECIPIENT_NAME, +} from '@documenso/lib/constants/template'; +import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth'; +import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; +import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; +import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; +import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates'; +import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; +import { checkDocumentTabCount } from '../fixtures/documents'; + +const nanoid = customAlphabet('1234567890abcdef', 10); + +test.describe.configure({ mode: 'parallel' }); + +test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + + // Should only be visible to the owner in personal templates. + const personalTemplate = await seedTemplate({ + title: 'Personal template', + userId: owner.id, + }); + + // Should be visible to team members. + const teamTemplate = await seedTemplate({ + title: 'Team template 1', + userId: owner.id, + teamId: team.id, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: '/templates', + }); + + const urls = [ + `${WEBAPP_BASE_URL}/t/${team.url}/templates/${teamTemplate.id}`, + `${WEBAPP_BASE_URL}/templates/${personalTemplate.id}`, + ]; + + // Run test for personal and team templates. + for (const url of urls) { + // Owner should see list of templates with no direct link badge. + await page.goto(url); + await expect(page.getByRole('button', { name: 'direct link' })).toHaveCount(1); + + // Create direct link. + await page.getByRole('button', { name: 'Create Direct Link' }).click(); + await page.getByRole('button', { name: 'Enable direct link signing' }).click(); + await page.getByRole('button', { name: 'Create one automatically' }).click(); + await expect(page.getByRole('heading', { name: 'Direct Link Signing' })).toBeVisible(); + await page.getByTestId('btn-dialog-close').click(); + + // Expect badge to appear. + await expect(page.getByRole('button', { name: 'direct link' })).toHaveCount(2); + } + + await unseedTeam(team.url); +}); + +test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + + // Should only be visible to the owner in personal templates. + const personalDirectTemplate = await seedDirectTemplate({ + title: 'Personal direct template link', + userId: owner.id, + }); + + // Should be visible to team members. + const teamDirectTemplate = await seedDirectTemplate({ + title: 'Team direct template link 1', + userId: owner.id, + teamId: team.id, + }); + + await apiSignin({ + page, + email: owner.email, + }); + + // Run test for personal and team templates. + for (const template of [personalDirectTemplate, teamDirectTemplate]) { + // Check that the direct template link is accessible. + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + + // Navigate to template settings and disable access. + await page.goto(`${WEBAPP_BASE_URL}${formatTemplatesPath(template.team?.url)}`); + await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click(); + await page.getByRole('menuitem', { name: 'Direct link' }).click(); + await page.getByRole('switch').click(); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Direct link signing has been').first()).toBeVisible(); + await page.getByLabel('Direct Link Signing', { exact: true }).press('Escape'); + + // Check that the direct template link is no longer accessible. + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + await expect(page.getByText('Template not found')).toBeVisible(); + } + + await unseedTeam(team.url); +}); + +test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + + // Should only be visible to the owner in personal templates. + const personalDirectTemplate = await seedDirectTemplate({ + title: 'Personal direct template link', + userId: owner.id, + }); + + // Should be visible to team members. + const teamDirectTemplate = await seedDirectTemplate({ + title: 'Team direct template link 1', + userId: owner.id, + teamId: team.id, + }); + + await apiSignin({ + page, + email: owner.email, + }); + + // Run test for personal and team templates. + for (const template of [personalDirectTemplate, teamDirectTemplate]) { + // Check that the direct template link is accessible. + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + + // Navigate to template settings and delete the access. + await page.goto(`${WEBAPP_BASE_URL}${formatTemplatesPath(template.team?.url)}`); + await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click(); + await page.getByRole('menuitem', { name: 'Direct link' }).click(); + await page.getByRole('button', { name: 'Remove' }).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + await expect(page.getByText('Direct template link deleted').first()).toBeVisible(); + + // Check that the direct template link is no longer accessible. + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + await expect(page.getByText('Template not found')).toBeVisible(); + } + + await unseedTeam(team.url); +}); + +test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => { + const user = await seedUser(); + + const directTemplateWithAuth = await seedDirectTemplate({ + title: 'Personal direct template link', + userId: user.id, + createTemplateOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: 'ACCOUNT', + globalActionAuth: null, + }), + }, + }); + + const directTemplatePath = formatDirectTemplatePath( + directTemplateWithAuth.directLink?.token || '', + ); + + await page.goto(directTemplatePath); + + await expect(page.getByText('Authentication required')).toBeVisible(); + + await apiSignin({ + page, + email: user.email, + }); + + await page.goto(directTemplatePath); + + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + await expect(page.getByLabel('Email')).toBeDisabled(); + + await unseedUser(user.id); +}); + +test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + + // Should only be visible to the owner in personal templates. + const personalDirectTemplate = await seedDirectTemplate({ + title: 'Personal direct template link', + userId: owner.id, + }); + + // Should be visible to team members. + const teamDirectTemplate = await seedDirectTemplate({ + title: 'Team direct template link 1', + userId: owner.id, + teamId: team.id, + }); + + // Run test for personal and team templates. + for (const template of [personalDirectTemplate, teamDirectTemplate]) { + // Check that the direct template link is accessible. + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + + await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail()); + + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(/\/sign/); + await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible(); + } + + await apiSignin({ + page, + email: owner.email, + }); + + // Check that the owner has the documents. + for (const template of [personalDirectTemplate, teamDirectTemplate]) { + await page.goto(`${WEBAPP_BASE_URL}${formatDocumentsPath(template.team?.url)}`); + + // Check that the document is in the 'All' tab. + await checkDocumentTabCount(page, 'Completed', 1); + } + + await unseedTeam(team.url); +}); + +test('[DIRECT_TEMPLATES]: use direct template link with 2 recipients', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + + const secondRecipient = await seedUser(); + + const createTemplateOptions = { + Recipient: { + createMany: { + data: [ + { + email: DIRECT_TEMPLATE_RECIPIENT_EMAIL, + name: DIRECT_TEMPLATE_RECIPIENT_NAME, + token: nanoid(), + }, + { + email: secondRecipient.email, + token: nanoid(), + }, + ], + }, + }, + }; + + // Should only be visible to the owner in personal templates. + const personalDirectTemplate = await seedDirectTemplate({ + title: 'Personal direct template link', + userId: owner.id, + createTemplateOptions, + }); + + // Should be visible to team members. + const teamDirectTemplate = await seedDirectTemplate({ + title: 'Team direct template link 1', + userId: owner.id, + teamId: team.id, + createTemplateOptions, + }); + + // Run test for personal and team templates. + for (const template of [personalDirectTemplate, teamDirectTemplate]) { + // Check that the direct template link is accessible. + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + + await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail()); + + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(/\/sign/); + await expect(page.getByText('Waiting for others to sign')).toBeVisible(); + } + + await apiSignin({ + page, + email: owner.email, + }); + + // Check that the owner has the documents. + for (const template of [personalDirectTemplate, teamDirectTemplate]) { + await page.goto(`${WEBAPP_BASE_URL}${formatDocumentsPath(template.team?.url)}`); + + // Check that the document is in the 'All' tab. + await checkDocumentTabCount(page, 'All', 1); + await checkDocumentTabCount(page, 'Pending', 1); + } + + // Check that the second recipient has the 2 pending documents. + await apiSignin({ + page, + email: secondRecipient.email, + }); + + await page.goto('/documents'); + + await checkDocumentTabCount(page, 'All', 2); + await checkDocumentTabCount(page, 'Inbox', 2); + + await unseedTeam(team.url); + await unseedUser(secondRecipient.id); +}); diff --git a/packages/ee/server-only/limits/constants.ts b/packages/ee/server-only/limits/constants.ts index 4c428f34f..b3ca4c81b 100644 --- a/packages/ee/server-only/limits/constants.ts +++ b/packages/ee/server-only/limits/constants.ts @@ -3,14 +3,17 @@ import type { TLimitsSchema } from './schema'; export const FREE_PLAN_LIMITS: TLimitsSchema = { documents: 5, recipients: 10, + directTemplates: 3, }; export const TEAM_PLAN_LIMITS: TLimitsSchema = { documents: Infinity, recipients: Infinity, + directTemplates: Infinity, }; export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = { documents: Infinity, recipients: Infinity, + directTemplates: Infinity, }; diff --git a/packages/ee/server-only/limits/schema.ts b/packages/ee/server-only/limits/schema.ts index e3394995d..6583b826c 100644 --- a/packages/ee/server-only/limits/schema.ts +++ b/packages/ee/server-only/limits/schema.ts @@ -10,6 +10,10 @@ export const ZLimitsSchema = z.object({ .preprocess((v) => (v === null ? Infinity : Number(v)), z.number()) .optional() .default(0), + directTemplates: z + .preprocess((v) => (v === null ? Infinity : Number(v)), z.number()) + .optional() + .default(0), }); export type TLimitsSchema = z.infer; diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts index abed86da7..ad079c95d 100644 --- a/packages/ee/server-only/limits/server.ts +++ b/packages/ee/server-only/limits/server.ts @@ -2,11 +2,12 @@ import { DateTime } from 'luxon'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { prisma } from '@documenso/prisma'; -import { SubscriptionStatus } from '@documenso/prisma/client'; +import { DocumentSource, SubscriptionStatus } from '@documenso/prisma/client'; import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts'; import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants'; import { ERROR_CODES } from './errors'; +import type { TLimitsResponseSchema } from './schema'; import { ZLimitsSchema } from './schema'; export type GetServerLimitsOptions = { @@ -14,7 +15,10 @@ export type GetServerLimitsOptions = { teamId?: number | null; }; -export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => { +export const getServerLimits = async ({ + email, + teamId, +}: GetServerLimitsOptions): Promise => { if (!IS_BILLING_ENABLED()) { return { quota: SELFHOSTED_PLAN_LIMITS, @@ -74,19 +78,37 @@ const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => { remaining = structuredClone(quota); } } + + // Assume all active subscriptions provide unlimited direct templates. + remaining.directTemplates = Infinity; } - const documents = await prisma.document.count({ - where: { - userId: user.id, - teamId: null, - createdAt: { - gte: DateTime.utc().startOf('month').toJSDate(), + const [documents, directTemplates] = await Promise.all([ + prisma.document.count({ + where: { + userId: user.id, + teamId: null, + createdAt: { + gte: DateTime.utc().startOf('month').toJSDate(), + }, + source: { + not: DocumentSource.TEMPLATE_DIRECT_LINK, + }, }, - }, - }); + }), + prisma.template.count({ + where: { + userId: user.id, + teamId: null, + directLink: { + isNot: null, + }, + }, + }), + ]); remaining.documents = Math.max(remaining.documents - documents, 0); + remaining.directTemplates = Math.max(remaining.directTemplates - directTemplates, 0); return { quota, @@ -127,10 +149,12 @@ const handleTeamLimits = async ({ email, teamId }: HandleTeamLimitsOptions) => { quota: { documents: 0, recipients: 0, + directTemplates: 0, }, remaining: { documents: 0, recipients: 0, + directTemplates: 0, }, }; } diff --git a/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts b/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts index cda583e81..a2aac4f27 100644 --- a/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts +++ b/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts @@ -8,6 +8,7 @@ import { alphaid, nanoid } from '@documenso/lib/universal/id'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { prisma } from '@documenso/prisma'; import { + DocumentSource, DocumentStatus, FieldType, ReadStatus, @@ -86,6 +87,7 @@ export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersChecko status: DocumentStatus.COMPLETED, userId: newUser.id, documentDataId, + source: DocumentSource.DOCUMENT, }, }); diff --git a/packages/email/templates/document-created-from-direct-template.tsx b/packages/email/templates/document-created-from-direct-template.tsx new file mode 100644 index 000000000..e1512d041 --- /dev/null +++ b/packages/email/templates/document-created-from-direct-template.tsx @@ -0,0 +1,93 @@ +import config from '@documenso/tailwind-config'; + +import { + Body, + Button, + Container, + Head, + Html, + Img, + Preview, + Section, + Tailwind, + Text, +} from '../components'; +import TemplateDocumentImage from '../template-components/template-document-image'; +import { TemplateFooter } from '../template-components/template-footer'; + +export type DocumentCompletedEmailTemplateProps = { + recipientName?: string; + documentLink?: string; + documentName?: string; + assetBaseUrl?: string; +}; + +export const DocumentCreatedFromDirectTemplateEmailTemplate = ({ + recipientName = 'John Doe', + documentLink = 'http://localhost:3000', + documentName = 'Open Source Pledge.pdf', + assetBaseUrl = 'http://localhost:3002', +}: DocumentCompletedEmailTemplateProps) => { + const previewText = `Completed Document`; + + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + + + {previewText} + + +
+ +
+ Documenso Logo + + + +
+ + {recipientName} signed a document by using one of your direct links + + +
+ {documentName} +
+ +
+ +
+
+
+
+ + + + +
+ +
+ + ); +}; + +export default DocumentCreatedFromDirectTemplateEmailTemplate; diff --git a/packages/lib/constants/template.ts b/packages/lib/constants/template.ts index 061b9e594..029f3e26d 100644 --- a/packages/lib/constants/template.ts +++ b/packages/lib/constants/template.ts @@ -1,2 +1,28 @@ export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i; export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i; + +export const DIRECT_TEMPLATE_DOCUMENTATION = [ + { + title: 'Enable Direct Link Signing', + description: + 'Once enabled, you can select any active recipient to be a direct link signing recipient, or create a new one. This recipient type cannot be edited or deleted.', + }, + { + title: 'Configure Direct Recipient', + description: + 'Update the role and add fields as required for the direct recipient. The individual who uses the direct link will sign the document as the direct recipient.', + }, + { + title: 'Share the Link', + description: + 'Once your template is set up, share the link anywhere you want. The person who opens the link will be able to enter their information in the direct link recipient field and complete any other fields assigned to them.', + }, + { + title: 'Document Creation', + description: + 'After submission, a document will be automatically generated and added to your documents page. You will also receive a notification via email.', + }, +]; + +export const DIRECT_TEMPLATE_RECIPIENT_EMAIL = 'direct.link@documenso.com'; +export const DIRECT_TEMPLATE_RECIPIENT_NAME = 'Direct link recipient'; diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index b48e45d54..de2883d93 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -12,6 +12,7 @@ export enum AppErrorCode { 'EXPIRED_CODE' = 'ExpiredCode', 'INVALID_BODY' = 'InvalidBody', 'INVALID_REQUEST' = 'InvalidRequest', + 'LIMIT_EXCEEDED' = 'LimitExceeded', 'NOT_FOUND' = 'NotFound', 'NOT_SETUP' = 'NotSetup', 'UNAUTHORIZED' = 'Unauthorized', diff --git a/packages/lib/server-only/document/create-document.ts b/packages/lib/server-only/document/create-document.ts index 1d145a60d..c4521f504 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -5,7 +5,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log 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 { DocumentSource, WebhookTriggerEvents } from '@documenso/prisma/client'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -54,6 +54,7 @@ export const createDocument = async ({ userId, teamId, formValues, + source: DocumentSource.DOCUMENT, }, }); @@ -65,6 +66,9 @@ export const createDocument = async ({ requestMetadata, data: { title, + source: { + type: DocumentSource.DOCUMENT, + }, }, }), }); diff --git a/packages/lib/server-only/document/duplicate-document-by-id.ts b/packages/lib/server-only/document/duplicate-document-by-id.ts index 4e6a7bd87..febcda465 100644 --- a/packages/lib/server-only/document/duplicate-document-by-id.ts +++ b/packages/lib/server-only/document/duplicate-document-by-id.ts @@ -1,5 +1,5 @@ import { prisma } from '@documenso/prisma'; -import type { Prisma } from '@documenso/prisma/client'; +import { DocumentSource, type Prisma } from '@documenso/prisma/client'; import { getDocumentWhereInput } from './get-document-by-id'; @@ -64,6 +64,7 @@ export const duplicateDocumentById = async ({ ...document.documentMeta, }, }, + source: DocumentSource.DOCUMENT, }, }; diff --git a/packages/lib/server-only/document/get-document-by-token.ts b/packages/lib/server-only/document/get-document-by-token.ts index 6add46c1d..7f2d2172a 100644 --- a/packages/lib/server-only/document/get-document-by-token.ts +++ b/packages/lib/server-only/document/get-document-by-token.ts @@ -99,7 +99,7 @@ export const getDocumentAndSenderByToken = async ({ if (requireAccessAuth) { documentAccessValid = await isRecipientAuthorized({ type: 'ACCESS', - document: result, + documentAuthOptions: result.authOptions, recipient, userId, authOptions: accessAuth, @@ -159,7 +159,7 @@ export const getDocumentAndRecipientByToken = async ({ if (requireAccessAuth) { documentAccessValid = await isRecipientAuthorized({ type: 'ACCESS', - document: result, + documentAuthOptions: result.authOptions, recipient, userId, authOptions: accessAuth, diff --git a/packages/lib/server-only/document/is-recipient-authorized.ts b/packages/lib/server-only/document/is-recipient-authorized.ts index 5da50d6c7..151235fe2 100644 --- a/packages/lib/server-only/document/is-recipient-authorized.ts +++ b/packages/lib/server-only/document/is-recipient-authorized.ts @@ -14,8 +14,8 @@ import { extractDocumentAuthMethods } from '../../utils/document-auth'; type IsRecipientAuthorizedOptions = { type: 'ACCESS' | 'ACTION'; - document: Document; - recipient: Recipient; + documentAuthOptions: Document['authOptions']; + recipient: Pick; /** * The ID of the user who initiated the request. @@ -50,13 +50,13 @@ const getUserByEmail = async (email: string) => { */ export const isRecipientAuthorized = async ({ type, - document, + documentAuthOptions, recipient, userId, authOptions, }: IsRecipientAuthorizedOptions): Promise => { const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({ - documentAuth: document.authOptions, + documentAuth: documentAuthOptions, recipientAuth: recipient.authOptions, }); diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index fc65e8c6e..3697f88fc 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -12,7 +12,13 @@ import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; 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'; +import { + DocumentSource, + DocumentStatus, + RecipientRole, + SendStatus, + SigningStatus, +} from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; @@ -92,6 +98,8 @@ export const sendDocument = async ({ const { documentData } = document; + const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK; + if (!documentData.data) { throw new Error('Document data not found'); } @@ -133,10 +141,21 @@ export const sendDocument = async ({ const { email, name } = recipient; const selfSigner = email === user.email; + const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; + const recipientActionVerb = actionVerb.toLowerCase(); - const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[ - recipient.role - ].actionVerb.toLowerCase()} it.`; + let emailMessage = customEmail?.message || ''; + let emailSubject = `Please ${recipientActionVerb} this document`; + + if (selfSigner) { + emailMessage = `You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`; + emailSubject = `Please ${recipientActionVerb} your document`; + } + + if (isDirectTemplate) { + emailMessage = `A document was created by your direct template that requires you to ${recipientActionVerb} it.`; + emailSubject = `Please ${recipientActionVerb} this document created by your direct template`; + } const customEmailTemplate = { 'signer.name': name, @@ -153,22 +172,11 @@ export const sendDocument = async ({ inviterEmail: user.email, assetBaseUrl, signDocumentLink, - customBody: renderCustomEmailTemplate( - selfSigner && !customEmail?.message - ? selfSignerCustomEmail - : customEmail?.message || '', - customEmailTemplate, - ), + customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate), role: recipient.role, selfSigner, }); - const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; - - const emailSubject = selfSigner - ? `Please ${actionVerb.toLowerCase()} your document` - : `Please ${actionVerb.toLowerCase()} this document`; - await prisma.$transaction( async (tx) => { await mailer.sendMail({ @@ -220,7 +228,8 @@ export const sendDocument = async ({ } const allRecipientsHaveNoActionToTake = document.Recipient.every( - (recipient) => recipient.role === RecipientRole.CC, + (recipient) => + recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED, ); if (allRecipientsHaveNoActionToTake) { diff --git a/packages/lib/server-only/document/validate-field-auth.ts b/packages/lib/server-only/document/validate-field-auth.ts new file mode 100644 index 000000000..8dd140395 --- /dev/null +++ b/packages/lib/server-only/document/validate-field-auth.ts @@ -0,0 +1,52 @@ +import type { Document, Field, Recipient } from '@documenso/prisma/client'; +import { FieldType } from '@documenso/prisma/client'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import type { TRecipientActionAuth } from '../../types/document-auth'; +import { extractDocumentAuthMethods } from '../../utils/document-auth'; +import { isRecipientAuthorized } from './is-recipient-authorized'; + +export type ValidateFieldAuthOptions = { + documentAuthOptions: Document['authOptions']; + recipient: Pick; + field: Field; + userId?: number; + authOptions?: TRecipientActionAuth; +}; + +/** + * Throws an error if the reauth for a field is invalid. + * + * Returns the derived recipient action authentication if valid. + */ +export const validateFieldAuth = async ({ + documentAuthOptions, + recipient, + field, + userId, + authOptions, +}: ValidateFieldAuthOptions) => { + const { derivedRecipientActionAuth } = extractDocumentAuthMethods({ + documentAuth: documentAuthOptions, + recipientAuth: recipient.authOptions, + }); + + // Override all non-signature fields to not require any auth. + if (field.type !== FieldType.SIGNATURE) { + return null; + } + + const isValid = await isRecipientAuthorized({ + type: 'ACTION', + documentAuthOptions, + recipient, + userId, + authOptions, + }); + + if (!isValid) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values'); + } + + return derivedRecipientActionAuth; +}; diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts index 359a5da68..6f7bbb029 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -8,13 +8,11 @@ import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/clie import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; -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 { validateFieldAuth } from '../document/validate-field-auth'; export type SignFieldWithTokenOptions = { token: string; @@ -26,6 +24,16 @@ export type SignFieldWithTokenOptions = { requestMetadata?: RequestMetadata; }; +/** + * Please read. + * + * Content within this function has been duplicated in the + * createDocumentFromDirectTemplate file. + * + * Any update to this should be reflected in the other file if required. + * + * Todo: Extract common logic. + */ export const signFieldWithToken = async ({ token, fieldId, @@ -79,33 +87,14 @@ export const signFieldWithToken = async ({ throw new Error(`Field ${fieldId} has no recipientId`); } - let { derivedRecipientActionAuth } = extractDocumentAuthMethods({ - documentAuth: document.authOptions, - recipientAuth: recipient.authOptions, + const derivedRecipientActionAuth = await validateFieldAuth({ + documentAuthOptions: document.authOptions, + recipient, + field, + userId, + authOptions, }); - // Override all non-signature fields to not require any auth. - if (field.type !== FieldType.SIGNATURE) { - derivedRecipientActionAuth = null; - } - - let isValid = true; - - // Only require auth on signature fields for now. - if (field.type === FieldType.SIGNATURE) { - isValid = await isRecipientAuthorized({ - type: 'ACTION', - document: document, - recipient: recipient, - userId, - authOptions, - }); - } - - if (!isValid) { - throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values'); - } - const documentMeta = await prisma.documentMeta.findFirst({ where: { documentId: document.id, @@ -142,10 +131,6 @@ export const signFieldWithToken = async ({ }); if (isSignatureField) { - if (!field.recipientId) { - throw new Error('Field has no recipientId'); - } - const signature = await tx.signature.upsert({ where: { fieldId: field.id, diff --git a/packages/lib/server-only/recipient/set-recipients-for-template.ts b/packages/lib/server-only/recipient/set-recipients-for-template.ts index 73d05ab4e..c76a4a5ef 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-template.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-template.ts @@ -3,6 +3,10 @@ import { prisma } from '@documenso/prisma'; import type { Recipient } from '@documenso/prisma/client'; import { RecipientRole } from '@documenso/prisma/client'; +import { + DIRECT_TEMPLATE_RECIPIENT_EMAIL, + DIRECT_TEMPLATE_RECIPIENT_NAME, +} from '../../constants/template'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { type TRecipientActionAuthTypes, @@ -48,6 +52,9 @@ export const setRecipientsForTemplate = async ({ }, ], }, + include: { + directLink: true, + }, }); if (!template) { @@ -71,10 +78,21 @@ export const setRecipientsForTemplate = async ({ } } - const normalizedRecipients = recipients.map((recipient) => ({ - ...recipient, - email: recipient.email.toLowerCase(), - })); + const normalizedRecipients = recipients.map((recipient) => { + // Force replace any changes to the name or email of the direct recipient. + if (template.directLink && recipient.id === template.directLink.directTemplateRecipientId) { + return { + ...recipient, + email: DIRECT_TEMPLATE_RECIPIENT_EMAIL, + name: DIRECT_TEMPLATE_RECIPIENT_NAME, + }; + } + + return { + ...recipient, + email: recipient.email.toLowerCase(), + }; + }); const existingRecipients = await prisma.recipient.findMany({ where: { @@ -90,6 +108,27 @@ export const setRecipientsForTemplate = async ({ ), ); + if (template.directLink !== null) { + const updatedDirectRecipient = recipients.find( + (recipient) => recipient.id === template.directLink?.directTemplateRecipientId, + ); + + const deletedDirectRecipient = removedRecipients.find( + (recipient) => recipient.id === template.directLink?.directTemplateRecipientId, + ); + + if (updatedDirectRecipient?.role === RecipientRole.CC) { + throw new AppError(AppErrorCode.INVALID_BODY, 'Cannot set direct recipient as CC'); + } + + if (deletedDirectRecipient) { + throw new AppError( + AppErrorCode.INVALID_BODY, + 'Cannot delete direct recipient while direct template exists', + ); + } + } + const linkedRecipients = normalizedRecipients.map((recipient) => { const existing = existingRecipients.find( (existingRecipient) => diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts new file mode 100644 index 000000000..eeb639bb8 --- /dev/null +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -0,0 +1,526 @@ +import { createElement } from 'react'; + +import { DateTime } from 'luxon'; +import { match } from 'ts-pattern'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template'; +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import type { Field, Signature } from '@documenso/prisma/client'; +import { + DocumentSource, + DocumentStatus, + FieldType, + RecipientRole, + SendStatus, + SigningStatus, +} from '@documenso/prisma/client'; +import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import type { TRecipientActionAuthTypes } from '../../types/document-auth'; +import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth'; +import type { RequestMetadata } from '../../universal/extract-request-metadata'; +import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs'; +import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; +import { + createDocumentAuthOptions, + createRecipientAuthOptions, + extractDocumentAuthMethods, +} from '../../utils/document-auth'; +import { formatDocumentsPath } from '../../utils/teams'; +import { sendDocument } from '../document/send-document'; +import { validateFieldAuth } from '../document/validate-field-auth'; + +export type CreateDocumentFromDirectTemplateOptions = { + directRecipientEmail: string; + directTemplateToken: string; + signedFieldValues: TSignFieldWithTokenMutationSchema[]; + templateUpdatedAt: Date; + requestMetadata: RequestMetadata; + user?: { + id: number; + name?: string; + email: string; + }; +}; + +type CreatedDirectRecipientField = { + field: Field & { Signature?: Signature | null }; + derivedRecipientActionAuth: TRecipientActionAuthTypes | null; +}; + +export const createDocumentFromDirectTemplate = async ({ + directRecipientEmail, + directTemplateToken, + signedFieldValues, + templateUpdatedAt, + requestMetadata, + user, +}: CreateDocumentFromDirectTemplateOptions) => { + const template = await prisma.template.findFirst({ + where: { + directLink: { + token: directTemplateToken, + }, + }, + include: { + Recipient: { + include: { + Field: true, + }, + }, + directLink: true, + templateDocumentData: true, + templateMeta: true, + User: true, + }, + }); + + if (!template?.directLink?.enabled) { + throw new AppError(AppErrorCode.INVALID_REQUEST, 'Invalid or missing template'); + } + + const { Recipient: recipients, directLink, User: templateOwner } = template; + + const directTemplateRecipient = recipients.find( + (recipient) => recipient.id === directLink.directTemplateRecipientId, + ); + + if (!directTemplateRecipient || directTemplateRecipient.role === RecipientRole.CC) { + throw new AppError(AppErrorCode.INVALID_REQUEST, 'Invalid or missing direct recipient'); + } + + if (template.updatedAt.getTime() !== templateUpdatedAt.getTime()) { + throw new AppError(AppErrorCode.INVALID_REQUEST, 'Template no longer matches'); + } + + if (user && user.email !== directRecipientEmail) { + throw new AppError(AppErrorCode.INVALID_REQUEST, 'Email must match if you are logged in'); + } + + const { derivedRecipientAccessAuth, documentAuthOption: templateAuthOptions } = + extractDocumentAuthMethods({ + documentAuth: template.authOptions, + }); + + const directRecipientName = user?.name; + + // Ensure typesafety when we add more options. + const isAccessAuthValid = match(derivedRecipientAccessAuth) + .with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail) + .with(null, () => true) + .exhaustive(); + + if (!isAccessAuthValid) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'You must be logged in'); + } + + const directTemplateRecipientAuthOptions = ZRecipientAuthOptionsSchema.parse( + directTemplateRecipient.authOptions, + ); + + const nonDirectTemplateRecipients = template.Recipient.filter( + (recipient) => recipient.id !== directTemplateRecipient.id, + ); + + const metaTimezone = template.templateMeta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE; + const metaDateFormat = template.templateMeta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT; + + // Associate, validate and map to a query every direct template recipient field with the provided fields. + const createDirectRecipientFieldArgs = await Promise.all( + directTemplateRecipient.Field.map(async (templateField) => { + const signedFieldValue = signedFieldValues.find( + (value) => value.fieldId === templateField.id, + ); + + if (!signedFieldValue) { + throw new AppError(AppErrorCode.INVALID_BODY, 'Invalid, missing or changed fields'); + } + + if (templateField.type === FieldType.NAME && directRecipientName === undefined) { + directRecipientName === signedFieldValue.value; + } + + const derivedRecipientActionAuth = await validateFieldAuth({ + documentAuthOptions: template.authOptions, + recipient: { + authOptions: directTemplateRecipient.authOptions, + email: directRecipientEmail, + }, + field: templateField, + userId: user?.id, + authOptions: signedFieldValue.authOptions, + }); + + const { value, isBase64 } = signedFieldValue; + + const isSignatureField = + templateField.type === FieldType.SIGNATURE || + templateField.type === FieldType.FREE_SIGNATURE; + + let customText = !isSignatureField ? value : ''; + + const signatureImageAsBase64 = isSignatureField && isBase64 ? value : undefined; + const typedSignature = isSignatureField && !isBase64 ? value : undefined; + + if (templateField.type === FieldType.DATE) { + customText = DateTime.now().setZone(metaTimezone).toFormat(metaDateFormat); + } + + if (isSignatureField && !signatureImageAsBase64 && !typedSignature) { + throw new Error('Signature field must have a signature'); + } + + return { + templateField, + customText, + derivedRecipientActionAuth, + signature: isSignatureField + ? { + signatureImageAsBase64, + typedSignature, + } + : null, + }; + }), + ); + + const directTemplateNonSignatureFields = createDirectRecipientFieldArgs.filter( + ({ signature }) => signature === null, + ); + + const directTemplateSignatureFields = createDirectRecipientFieldArgs.filter( + ({ signature }) => signature !== null, + ); + + const initialRequestTime = new Date(); + + const { documentId, directRecipientToken } = await prisma.$transaction(async (tx) => { + const documentData = await tx.documentData.create({ + data: { + type: template.templateDocumentData.type, + data: template.templateDocumentData.data, + initialData: template.templateDocumentData.initialData, + }, + }); + + // Create the document and non direct template recipients. + const document = await tx.document.create({ + data: { + source: DocumentSource.TEMPLATE_DIRECT_LINK, + templateId: template.id, + userId: template.userId, + teamId: template.teamId, + title: template.title, + createdAt: initialRequestTime, + status: DocumentStatus.PENDING, + documentDataId: documentData.id, + authOptions: createDocumentAuthOptions({ + globalAccessAuth: templateAuthOptions.globalAccessAuth, + globalActionAuth: templateAuthOptions.globalActionAuth, + }), + Recipient: { + createMany: { + data: nonDirectTemplateRecipients.map((recipient) => { + const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions); + + return { + email: recipient.email, + name: recipient.name, + role: recipient.role, + authOptions: createRecipientAuthOptions({ + accessAuth: authOptions.accessAuth, + actionAuth: authOptions.actionAuth, + }), + sendStatus: + recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, + signingStatus: + recipient.role === RecipientRole.CC + ? SigningStatus.SIGNED + : SigningStatus.NOT_SIGNED, + token: nanoid(), + }; + }), + }, + }, + }, + include: { + Recipient: true, + team: { + select: { + url: true, + }, + }, + }, + }); + + let nonDirectRecipientFieldsToCreate: Omit[] = []; + + Object.values(nonDirectTemplateRecipients).forEach((templateRecipient) => { + const recipient = document.Recipient.find( + (recipient) => recipient.email === templateRecipient.email, + ); + + if (!recipient) { + throw new Error('Recipient not found.'); + } + + nonDirectRecipientFieldsToCreate = nonDirectRecipientFieldsToCreate.concat( + templateRecipient.Field.map((field) => ({ + documentId: document.id, + recipientId: recipient.id, + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + customText: '', + inserted: false, + })), + ); + }); + + await tx.field.createMany({ + data: nonDirectRecipientFieldsToCreate, + }); + + // Create the direct recipient and their non signature fields. + const createdDirectRecipient = await tx.recipient.create({ + data: { + documentId: document.id, + email: directRecipientEmail, + name: directRecipientName, + authOptions: createRecipientAuthOptions({ + accessAuth: directTemplateRecipientAuthOptions.accessAuth, + actionAuth: directTemplateRecipientAuthOptions.actionAuth, + }), + role: directTemplateRecipient.role, + token: nanoid(), + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + signedAt: initialRequestTime, + Field: { + createMany: { + data: directTemplateNonSignatureFields.map(({ templateField, customText }) => ({ + documentId: document.id, + type: templateField.type, + page: templateField.page, + positionX: templateField.positionX, + positionY: templateField.positionY, + width: templateField.width, + height: templateField.height, + customText, + inserted: true, + })), + }, + }, + }, + include: { + Field: true, + }, + }); + + // Create any direct recipient signature fields. + // Note: It's done like this because we can't nest things in createMany. + const createdDirectRecipientSignatureFields: CreatedDirectRecipientField[] = await Promise.all( + directTemplateSignatureFields.map( + async ({ templateField, signature, derivedRecipientActionAuth }) => { + if (!signature) { + throw new Error('Not possible.'); + } + + const field = await tx.field.create({ + data: { + documentId: document.id, + recipientId: createdDirectRecipient.id, + type: templateField.type, + page: templateField.page, + positionX: templateField.positionX, + positionY: templateField.positionY, + width: templateField.width, + height: templateField.height, + customText: '', + inserted: true, + Signature: { + create: { + recipientId: createdDirectRecipient.id, + signatureImageAsBase64: signature.signatureImageAsBase64, + typedSignature: signature.typedSignature, + }, + }, + }, + include: { + Signature: true, + }, + }); + + return { + field, + derivedRecipientActionAuth, + }; + }, + ), + ); + + const createdDirectRecipientFields: CreatedDirectRecipientField[] = [ + ...createdDirectRecipient.Field.map((field) => ({ + field, + derivedRecipientActionAuth: null, + })), + ...createdDirectRecipientSignatureFields, + ]; + + /** + * Create the following audit logs. + * - DOCUMENT_CREATED + * - DOCUMENT_FIELD_INSERTED + * - DOCUMENT_RECIPIENT_COMPLETED + */ + const auditLogsToCreate: CreateDocumentAuditLogDataResponse[] = [ + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED, + documentId: document.id, + user: { + id: user?.id, + name: user?.name, + email: directRecipientEmail, + }, + requestMetadata, + data: { + title: document.title, + source: { + type: DocumentSource.TEMPLATE_DIRECT_LINK, + templateId: template.id, + directRecipientEmail, + }, + }, + }), + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, + documentId: document.id, + user: { + id: user?.id, + name: user?.name, + email: directRecipientEmail, + }, + requestMetadata, + data: { + recipientEmail: createdDirectRecipient.email, + recipientId: createdDirectRecipient.id, + recipientName: createdDirectRecipient.name, + recipientRole: createdDirectRecipient.role, + accessAuth: derivedRecipientAccessAuth || undefined, + }, + }), + ...createdDirectRecipientFields.map(({ field, derivedRecipientActionAuth }) => + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED, + documentId: document.id, + user: { + id: user?.id, + name: user?.name, + email: directRecipientEmail, + }, + requestMetadata, + data: { + recipientEmail: createdDirectRecipient.email, + recipientId: createdDirectRecipient.id, + recipientName: createdDirectRecipient.name, + recipientRole: createdDirectRecipient.role, + fieldId: field.secondaryId, + field: match(field.type) + .with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, (type) => ({ + type, + data: + field.Signature?.signatureImageAsBase64 || field.Signature?.typedSignature || '', + })) + .with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({ + type, + data: field.customText, + })) + .exhaustive(), + fieldSecurity: derivedRecipientActionAuth + ? { + type: derivedRecipientActionAuth, + } + : undefined, + }, + }), + ), + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, + documentId: document.id, + user: { + id: user?.id, + name: user?.name, + email: directRecipientEmail, + }, + requestMetadata, + data: { + recipientEmail: createdDirectRecipient.email, + recipientId: createdDirectRecipient.id, + recipientName: createdDirectRecipient.name, + recipientRole: createdDirectRecipient.role, + }, + }), + ]; + + await tx.documentAuditLog.createMany({ + data: auditLogsToCreate, + }); + + // Send email to template owner. + const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, { + recipientName: directRecipientEmail, + documentLink: `${formatDocumentsPath(document.team?.url)}/${document.id}`, + documentName: document.title, + assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000', + }); + + await mailer.sendMail({ + to: [ + { + name: templateOwner.name || '', + address: templateOwner.email, + }, + ], + from: { + name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso', + address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com', + }, + subject: 'Document created from direct template', + html: render(emailTemplate), + text: render(emailTemplate, { plainText: true }), + }); + + return { + documentId: document.id, + directRecipientToken: createdDirectRecipient.token, + }; + }); + + try { + // This handles sending emails and sealing the document if required. + await sendDocument({ + documentId, + userId: template.userId, + teamId: template.teamId || undefined, + requestMetadata, + }); + } catch (err) { + console.error('[CREATE_DOCUMENT_FROM_DIRECT_TEMPLATE]:', err); + + // Don't launch an error since the document has already been created. + // Log and reseal as required until we configure middleware. + } + + return directRecipientToken; +}; diff --git a/packages/lib/server-only/template/create-document-from-template-legacy.ts b/packages/lib/server-only/template/create-document-from-template-legacy.ts index fadbae4c3..b1ecd3913 100644 --- a/packages/lib/server-only/template/create-document-from-template-legacy.ts +++ b/packages/lib/server-only/template/create-document-from-template-legacy.ts @@ -1,6 +1,6 @@ import { nanoid } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; -import type { RecipientRole } from '@documenso/prisma/client'; +import { DocumentSource, type RecipientRole } from '@documenso/prisma/client'; export type CreateDocumentFromTemplateLegacyOptions = { templateId: number; @@ -62,6 +62,8 @@ export const createDocumentFromTemplateLegacy = async ({ const document = await prisma.document.create({ data: { + source: DocumentSource.TEMPLATE, + templateId: template.id, userId, teamId: template.teamId, title: template.title, diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 92590cfb2..da864e367 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -1,7 +1,14 @@ import { nanoid } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; import type { Field } from '@documenso/prisma/client'; -import { type Recipient, WebhookTriggerEvents } from '@documenso/prisma/client'; +import { + DocumentSource, + type Recipient, + RecipientRole, + SendStatus, + SigningStatus, + WebhookTriggerEvents, +} from '@documenso/prisma/client'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; @@ -139,6 +146,8 @@ export const createDocumentFromTemplate = async ({ return await prisma.$transaction(async (tx) => { const document = await tx.document.create({ data: { + source: DocumentSource.TEMPLATE, + templateId: template.id, userId, teamId: template.teamId, title: override?.title || template.title, @@ -170,6 +179,12 @@ export const createDocumentFromTemplate = async ({ accessAuth: authOptions.accessAuth, actionAuth: authOptions.actionAuth, }), + sendStatus: + recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, + signingStatus: + recipient.role === RecipientRole.CC + ? SigningStatus.SIGNED + : SigningStatus.NOT_SIGNED, token: nanoid(), }; }), @@ -223,6 +238,10 @@ export const createDocumentFromTemplate = async ({ requestMetadata, data: { title: document.title, + source: { + type: DocumentSource.TEMPLATE, + templateId: template.id, + }, }, }), }); diff --git a/packages/lib/server-only/template/create-template-direct-link.ts b/packages/lib/server-only/template/create-template-direct-link.ts new file mode 100644 index 000000000..1ea2dc2ba --- /dev/null +++ b/packages/lib/server-only/template/create-template-direct-link.ts @@ -0,0 +1,107 @@ +'use server'; + +import { nanoid } from 'nanoid'; + +import { prisma } from '@documenso/prisma'; +import type { Recipient, TemplateDirectLink } from '@documenso/prisma/client'; + +import { + DIRECT_TEMPLATE_RECIPIENT_EMAIL, + DIRECT_TEMPLATE_RECIPIENT_NAME, +} from '../../constants/template'; +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export type CreateTemplateDirectLinkOptions = { + templateId: number; + userId: number; + directRecipientId?: number; +}; + +export const createTemplateDirectLink = async ({ + templateId, + userId, + directRecipientId, +}: CreateTemplateDirectLinkOptions): Promise => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, + include: { + Recipient: true, + directLink: true, + }, + }); + + if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found'); + } + + if (template.directLink) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Direct template already exists'); + } + + if ( + directRecipientId && + !template.Recipient.find((recipient) => recipient.id === directRecipientId) + ) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Recipient not found'); + } + + if ( + !directRecipientId && + template.Recipient.find( + (recipient) => recipient.email.toLowerCase() === DIRECT_TEMPLATE_RECIPIENT_EMAIL, + ) + ) { + throw new AppError(AppErrorCode.INVALID_BODY, 'Cannot generate placeholder direct recipient'); + } + + return await prisma.$transaction(async (tx) => { + let recipient: Recipient | undefined; + + if (directRecipientId) { + recipient = await tx.recipient.update({ + where: { + templateId, + id: directRecipientId, + }, + data: { + name: DIRECT_TEMPLATE_RECIPIENT_NAME, + email: DIRECT_TEMPLATE_RECIPIENT_EMAIL, + }, + }); + } else { + recipient = await tx.recipient.create({ + data: { + templateId, + name: DIRECT_TEMPLATE_RECIPIENT_NAME, + email: DIRECT_TEMPLATE_RECIPIENT_EMAIL, + token: nanoid(), + }, + }); + } + + return await tx.templateDirectLink.create({ + data: { + templateId, + enabled: true, + token: nanoid(), + directTemplateRecipientId: recipient.id, + }, + }); + }); +}; diff --git a/packages/lib/server-only/template/delete-template-direct-link.ts b/packages/lib/server-only/template/delete-template-direct-link.ts new file mode 100644 index 000000000..bb4e575d9 --- /dev/null +++ b/packages/lib/server-only/template/delete-template-direct-link.ts @@ -0,0 +1,68 @@ +'use server'; + +import { generateAvaliableRecipientPlaceholder } from '@documenso/lib/utils/templates'; +import { prisma } from '@documenso/prisma'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export type DeleteTemplateDirectLinkOptions = { + templateId: number; + userId: number; +}; + +export const deleteTemplateDirectLink = async ({ + templateId, + userId, +}: DeleteTemplateDirectLinkOptions): Promise => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, + include: { + directLink: true, + Recipient: true, + }, + }); + + if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found'); + } + + const { directLink } = template; + + if (!directLink) { + return; + } + + await prisma.$transaction(async (tx) => { + await tx.recipient.update({ + where: { + templateId: template.id, + id: directLink.directTemplateRecipientId, + }, + data: { + ...generateAvaliableRecipientPlaceholder(template.Recipient), + }, + }); + + await tx.templateDirectLink.delete({ + where: { + templateId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/template/find-templates.ts b/packages/lib/server-only/template/find-templates.ts index 9252d32ea..d5d38adf1 100644 --- a/packages/lib/server-only/template/find-templates.ts +++ b/packages/lib/server-only/template/find-templates.ts @@ -8,6 +8,9 @@ export type FindTemplatesOptions = { perPage: number; }; +export type FindTemplatesResponse = Awaited>; +export type FindTemplateRow = FindTemplatesResponse['templates'][number]; + export const findTemplates = async ({ userId, teamId, @@ -45,6 +48,12 @@ export const findTemplates = async ({ }, Field: true, Recipient: true, + directLink: { + select: { + token: true, + enabled: true, + }, + }, }, skip: Math.max(page - 1, 0) * perPage, orderBy: { diff --git a/packages/lib/server-only/template/get-template-by-direct-link-token.ts b/packages/lib/server-only/template/get-template-by-direct-link-token.ts new file mode 100644 index 000000000..49d518468 --- /dev/null +++ b/packages/lib/server-only/template/get-template-by-direct-link-token.ts @@ -0,0 +1,33 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetTemplateByDirectLinkTokenOptions { + token: string; +} + +export const getTemplateByDirectLinkToken = async ({ + token, +}: GetTemplateByDirectLinkTokenOptions) => { + const template = await prisma.template.findFirstOrThrow({ + where: { + directLink: { + token, + enabled: true, + }, + }, + include: { + directLink: true, + Recipient: { + include: { + Field: true, + }, + }, + templateDocumentData: true, + templateMeta: true, + }, + }); + + return { + ...template, + Field: template.Recipient.map((recipient) => recipient.Field).flat(), + }; +}; diff --git a/packages/lib/server-only/template/get-template-with-details-by-id.ts b/packages/lib/server-only/template/get-template-with-details-by-id.ts index 7d02c87cf..791295298 100644 --- a/packages/lib/server-only/template/get-template-with-details-by-id.ts +++ b/packages/lib/server-only/template/get-template-with-details-by-id.ts @@ -29,6 +29,7 @@ export const getTemplateWithDetailsById = async ({ ], }, include: { + directLink: true, templateDocumentData: true, templateMeta: true, Recipient: true, diff --git a/packages/lib/server-only/template/toggle-template-direct-link.ts b/packages/lib/server-only/template/toggle-template-direct-link.ts new file mode 100644 index 000000000..47c414d71 --- /dev/null +++ b/packages/lib/server-only/template/toggle-template-direct-link.ts @@ -0,0 +1,61 @@ +'use server'; + +import { prisma } from '@documenso/prisma'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export type ToggleTemplateDirectLinkOptions = { + templateId: number; + userId: number; + enabled: boolean; +}; + +export const toggleTemplateDirectLink = async ({ + templateId, + userId, + enabled, +}: ToggleTemplateDirectLinkOptions) => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, + include: { + Recipient: true, + directLink: true, + }, + }); + + if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found'); + } + + const { directLink } = template; + + if (!directLink) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Direct template link not found'); + } + + return await prisma.templateDirectLink.update({ + where: { + id: directLink.id, + }, + data: { + templateId: template.id, + enabled, + }, + }); +}; diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index cfdedd462..602396b3a 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -6,7 +6,7 @@ ///////////////////////////////////////////////////////////////////////////////////////////// import { z } from 'zod'; -import { FieldType } from '@documenso/prisma/client'; +import { DocumentSource, FieldType } from '@documenso/prisma/client'; import { ZRecipientActionAuthTypesSchema } from './document-auth'; @@ -192,6 +192,22 @@ export const ZDocumentAuditLogEventDocumentCreatedSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED), data: z.object({ title: z.string(), + source: z + .union([ + z.object({ + type: z.literal(DocumentSource.DOCUMENT), + }), + z.object({ + type: z.literal(DocumentSource.TEMPLATE), + templateId: z.number(), + }), + z.object({ + type: z.literal(DocumentSource.TEMPLATE_DIRECT_LINK), + templateId: z.number(), + directRecipientEmail: z.string().email(), + }), + ]) + .optional(), }), }); diff --git a/packages/lib/utils/templates.ts b/packages/lib/utils/templates.ts new file mode 100644 index 000000000..8573c7e49 --- /dev/null +++ b/packages/lib/utils/templates.ts @@ -0,0 +1,44 @@ +import type { Recipient } from '@documenso/prisma/client'; + +import { WEBAPP_BASE_URL } from '../constants/app'; + +export const formatDirectTemplatePath = (token: string) => { + return `${WEBAPP_BASE_URL}/d/${token}`; +}; + +/** + * Generate a placeholder recipient using an index number. + * + * May collide with existing recipients. + * + * Note: + * - Update TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX if this is ever changed. + * - Update TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX if this is ever changed. + * + */ +export const generateRecipientPlaceholder = (index: number) => { + return { + name: `Recipient ${index}`, + email: `recipient.${index}@documenso.com`, + }; +}; + +/** + * Generates a placeholder that does not collide with any existing recipients. + * + * @param currentRecipients The current recipients that exist for a template. + */ +export const generateAvaliableRecipientPlaceholder = (currentRecipients: Recipient[]) => { + const recipientEmails = currentRecipients.map((recipient) => recipient.email); + let recipientPlaceholder = generateRecipientPlaceholder(0); + + for (let i = 1; i <= currentRecipients.length + 1; i++) { + recipientPlaceholder = generateRecipientPlaceholder(i); + + if (!recipientEmails.includes(recipientPlaceholder.email)) { + return recipientPlaceholder; + } + } + + return recipientPlaceholder; +}; diff --git a/packages/prisma/migrations/20240530055436_add_template_direct_links/migration.sql b/packages/prisma/migrations/20240530055436_add_template_direct_links/migration.sql new file mode 100644 index 000000000..5e6f5e213 --- /dev/null +++ b/packages/prisma/migrations/20240530055436_add_template_direct_links/migration.sql @@ -0,0 +1,45 @@ +/* + Warnings: + + - Added the required column `source` to the `Document` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "DocumentSource" AS ENUM ('DOCUMENT', 'TEMPLATE', 'TEMPLATE_DIRECT_LINK'); + +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "source" "DocumentSource", +ADD COLUMN "templateId" INTEGER; + +-- Custom: UpdateTable +UPDATE "Document" SET "source" = 'DOCUMENT' WHERE "source" IS NULL; + +-- Custom: AlterColumn +ALTER TABLE "Document" ALTER COLUMN "source" SET NOT NULL; + +-- CreateTable +CREATE TABLE "TemplateDirectLink" ( + "id" TEXT NOT NULL, + "templateId" INTEGER NOT NULL, + "token" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "enabled" BOOLEAN NOT NULL, + "directTemplateRecipientId" INTEGER NOT NULL, + + CONSTRAINT "TemplateDirectLink_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TemplateDirectLink_id_key" ON "TemplateDirectLink"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "TemplateDirectLink_templateId_key" ON "TemplateDirectLink"("templateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TemplateDirectLink_token_key" ON "TemplateDirectLink"("token"); + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TemplateDirectLink" ADD CONSTRAINT "TemplateDirectLink_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 908bb10c1..cb1456d2b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -261,6 +261,12 @@ enum DocumentStatus { COMPLETED } +enum DocumentSource { + DOCUMENT + TEMPLATE + TEMPLATE_DIRECT_LINK +} + model Document { id Int @id @default(autoincrement()) userId Int @@ -281,6 +287,9 @@ model Document { deletedAt DateTime? teamId Int? team Team? @relation(fields: [teamId], references: [id]) + templateId Int? + template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull) + source DocumentSource auditLogs DocumentAuditLog[] @@ -572,15 +581,29 @@ model Template { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) - templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) - User User @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) Recipient Recipient[] Field Field[] + directLink TemplateDirectLink? + documents Document[] @@unique([templateDocumentDataId]) } +model TemplateDirectLink { + id String @id @unique @default(cuid()) + templateId Int @unique + token String @unique + createdAt DateTime @default(now()) + enabled Boolean + + directTemplateRecipientId Int + + template Template @relation(fields: [templateId], references: [id], onDelete: Cascade) +} + model SiteSettings { id String @id enabled Boolean @default(false) diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts index 2e6462daa..738cc8a04 100644 --- a/packages/prisma/seed/documents.ts +++ b/packages/prisma/seed/documents.ts @@ -7,6 +7,7 @@ import { match } from 'ts-pattern'; import { prisma } from '..'; import { DocumentDataType, + DocumentSource, DocumentStatus, FieldType, Prisma, @@ -68,6 +69,7 @@ export const seedBlankDocument = async (owner: User, options: CreateDocumentOpti return await prisma.document.create({ data: { + source: DocumentSource.DOCUMENT, title: `[TEST] Document ${key} - Draft`, status: DocumentStatus.DRAFT, documentDataId: documentData.id, @@ -102,6 +104,7 @@ export const seedDraftDocument = async ( const document = await prisma.document.create({ data: { + source: DocumentSource.DOCUMENT, title: `[TEST] Document ${key} - Draft`, status: DocumentStatus.DRAFT, documentDataId: documentData.id, @@ -170,6 +173,7 @@ export const seedPendingDocument = async ( const document = await prisma.document.create({ data: { + source: DocumentSource.DOCUMENT, title: `[TEST] Document ${key} - Pending`, status: DocumentStatus.PENDING, documentDataId: documentData.id, @@ -375,6 +379,7 @@ export const seedCompletedDocument = async ( const document = await prisma.document.create({ data: { + source: DocumentSource.DOCUMENT, title: `[TEST] Document ${key} - Completed`, status: DocumentStatus.COMPLETED, documentDataId: documentData.id, diff --git a/packages/prisma/seed/initial-seed.ts b/packages/prisma/seed/initial-seed.ts index 6409c5bd9..66c944c9b 100644 --- a/packages/prisma/seed/initial-seed.ts +++ b/packages/prisma/seed/initial-seed.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { hashSync } from '@documenso/lib/server-only/auth/hash'; import { prisma } from '..'; -import { DocumentDataType, Role } from '../client'; +import { DocumentDataType, DocumentSource, Role } from '../client'; export const seedDatabase = async () => { const examplePdf = fs @@ -54,6 +54,7 @@ export const seedDatabase = async () => { await prisma.document.create({ data: { + source: DocumentSource.DOCUMENT, title: 'Example Document', documentDataId: examplePdfData.id, userId: exampleUser.id, diff --git a/packages/prisma/seed/templates.ts b/packages/prisma/seed/templates.ts index f37306c87..861b26bd4 100644 --- a/packages/prisma/seed/templates.ts +++ b/packages/prisma/seed/templates.ts @@ -1,6 +1,11 @@ import fs from 'node:fs'; import path from 'node:path'; +import { + DIRECT_TEMPLATE_RECIPIENT_EMAIL, + DIRECT_TEMPLATE_RECIPIENT_NAME, +} from '@documenso/lib/constants/template'; + import { prisma } from '..'; import type { Prisma, User } from '../client'; import { DocumentDataType, ReadStatus, RecipientRole, SendStatus, SigningStatus } from '../client'; @@ -13,6 +18,7 @@ type SeedTemplateOptions = { title?: string; userId: number; teamId?: number; + createTemplateOptions?: Partial; }; type CreateTemplateOptions = { @@ -88,3 +94,81 @@ export const seedTemplate = async (options: SeedTemplateOptions) => { }, }); }; + +export const seedDirectTemplate = async (options: SeedTemplateOptions) => { + const { title = 'Untitled', userId, teamId } = options; + + const documentData = await prisma.documentData.create({ + data: { + type: DocumentDataType.BYTES_64, + data: examplePdf, + initialData: examplePdf, + }, + }); + + const template = await prisma.template.create({ + data: { + title, + templateDocumentData: { + connect: { + id: documentData.id, + }, + }, + User: { + connect: { + id: userId, + }, + }, + Recipient: { + create: { + email: DIRECT_TEMPLATE_RECIPIENT_EMAIL, + name: DIRECT_TEMPLATE_RECIPIENT_NAME, + token: Math.random().toString().slice(2, 7), + }, + }, + ...(teamId + ? { + team: { + connect: { + id: teamId, + }, + }, + } + : {}), + ...options.createTemplateOptions, + }, + include: { + Recipient: true, + User: true, + }, + }); + + const directTemplateRecpient = template.Recipient.find( + (recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL, + ); + + if (!directTemplateRecpient) { + throw new Error('Need to create a direct template recipient'); + } + + await prisma.templateDirectLink.create({ + data: { + templateId: template.id, + enabled: true, + token: Math.random().toString(), + directTemplateRecipientId: directTemplateRecpient.id, + }, + }); + + return await prisma.template.findFirstOrThrow({ + where: { + id: template.id, + }, + include: { + directLink: true, + Field: true, + Recipient: true, + team: true, + }, + }); +}; diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts index 9f7f80a71..8583473bb 100644 --- a/packages/prisma/seed/users.ts +++ b/packages/prisma/seed/users.ts @@ -4,8 +4,6 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash'; import { prisma } from '..'; -export const seedTestEmail = () => `user-${Date.now()}@test.documenso.com`; - type SeedUserOptions = { name?: string; email?: string; @@ -15,6 +13,8 @@ type SeedUserOptions = { const nanoid = customAlphabet('1234567890abcdef', 10); +export const seedTestEmail = () => `${nanoid()}@test.documenso.com`; + export const seedUser = async ({ name, email, diff --git a/packages/prisma/types/template.ts b/packages/prisma/types/template.ts index c5dc054a7..093509993 100644 --- a/packages/prisma/types/template.ts +++ b/packages/prisma/types/template.ts @@ -3,6 +3,7 @@ import type { Field, Recipient, Template, + TemplateDirectLink, TemplateMeta, } from '@documenso/prisma/client'; @@ -12,6 +13,7 @@ export type TemplateWithData = Template & { }; export type TemplateWithDetails = Template & { + directLink: TemplateDirectLink | null; templateDocumentData: DocumentData; templateMeta: TemplateMeta | null; Recipient: Recipient[]; diff --git a/packages/trpc/server/singleplayer-router/router.ts b/packages/trpc/server/singleplayer-router/router.ts index 2634ca895..1df83b405 100644 --- a/packages/trpc/server/singleplayer-router/router.ts +++ b/packages/trpc/server/singleplayer-router/router.ts @@ -13,6 +13,7 @@ import { getFile } from '@documenso/lib/universal/upload/get-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { prisma } from '@documenso/prisma'; import { + DocumentSource, DocumentStatus, FieldType, ReadStatus, @@ -95,6 +96,7 @@ export const singleplayerRouter = router({ // Create document. const document = await tx.document.create({ data: { + source: DocumentSource.DOCUMENT, title: documentName, status: DocumentStatus.COMPLETED, documentDataId, diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 112ab6006..f708ee2d7 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -1,24 +1,32 @@ import { TRPCError } from '@trpc/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; -import { AppError } from '@documenso/lib/errors/app-error'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; +import { createDocumentFromDirectTemplate } from '@documenso/lib/server-only/template/create-document-from-direct-template'; import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { createTemplate } from '@documenso/lib/server-only/template/create-template'; +import { createTemplateDirectLink } from '@documenso/lib/server-only/template/create-template-direct-link'; import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; +import { deleteTemplateDirectLink } from '@documenso/lib/server-only/template/delete-template-direct-link'; import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template'; import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id'; +import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link'; import { updateTemplateSettings } from '@documenso/lib/server-only/template/update-template-settings'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { Document } from '@documenso/prisma/client'; -import { authenticatedProcedure, router } from '../trpc'; +import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc'; import { + ZCreateDocumentFromDirectTemplateMutationSchema, ZCreateDocumentFromTemplateMutationSchema, + ZCreateTemplateDirectLinkMutationSchema, ZCreateTemplateMutationSchema, + ZDeleteTemplateDirectLinkMutationSchema, ZDeleteTemplateMutationSchema, ZDuplicateTemplateMutationSchema, ZGetTemplateWithDetailsByIdQuerySchema, + ZToggleTemplateDirectLinkMutationSchema, ZUpdateTemplateSettingsMutationSchema, } from './schema'; @@ -45,6 +53,36 @@ export const templateRouter = router({ } }), + createDocumentFromDirectTemplate: maybeAuthenticatedProcedure + .input(ZCreateDocumentFromDirectTemplateMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { directRecipientEmail, directTemplateToken, signedFieldValues, templateUpdatedAt } = + input; + + const requestMetadata = extractNextApiRequestMetadata(ctx.req); + + return await createDocumentFromDirectTemplate({ + directRecipientEmail, + directTemplateToken, + signedFieldValues, + templateUpdatedAt, + user: ctx.user + ? { + id: ctx.user.id, + name: ctx.user.name || undefined, + email: ctx.user.email, + } + : undefined, + requestMetadata, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + createDocumentFromTemplate: authenticatedProcedure .input(ZCreateDocumentFromTemplateMutationSchema) .mutation(async ({ input, ctx }) => { @@ -175,4 +213,64 @@ export const templateRouter = router({ }); } }), + + createTemplateDirectLink: authenticatedProcedure + .input(ZCreateTemplateDirectLinkMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId, directRecipientId } = input; + + const userId = ctx.user.id; + + const limits = await getServerLimits({ email: ctx.user.email }); + + if (limits.remaining.directTemplates === 0) { + throw new AppError( + AppErrorCode.LIMIT_EXCEEDED, + 'You have reached your direct templates limit.', + ); + } + + return await createTemplateDirectLink({ userId, templateId, directRecipientId }); + } catch (err) { + console.error(err); + + const error = AppError.parseError(err); + throw AppError.parseErrorToTRPCError(error); + } + }), + + deleteTemplateDirectLink: authenticatedProcedure + .input(ZDeleteTemplateDirectLinkMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId } = input; + + const userId = ctx.user.id; + + return await deleteTemplateDirectLink({ userId, templateId }); + } catch (err) { + console.error(err); + + const error = AppError.parseError(err); + throw AppError.parseErrorToTRPCError(error); + } + }), + + toggleTemplateDirectLink: authenticatedProcedure + .input(ZToggleTemplateDirectLinkMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId, enabled } = input; + + const userId = ctx.user.id; + + return await toggleTemplateDirectLink({ userId, templateId, enabled }); + } catch (err) { + console.error(err); + + const error = AppError.parseError(err); + throw AppError.parseErrorToTRPCError(error); + } + }), }); diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 79d609488..36fde8453 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -6,12 +6,21 @@ import { ZDocumentActionAuthTypesSchema, } from '@documenso/lib/types/document-auth'; +import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema'; + export const ZCreateTemplateMutationSchema = z.object({ title: z.string().min(1).trim(), teamId: z.number().optional(), templateDocumentDataId: z.string().min(1), }); +export const ZCreateDocumentFromDirectTemplateMutationSchema = z.object({ + directRecipientEmail: z.string().email(), + directTemplateToken: z.string().min(1), + signedFieldValues: z.array(ZSignFieldWithTokenMutationSchema), + templateUpdatedAt: z.date(), +}); + export const ZCreateDocumentFromTemplateMutationSchema = z.object({ templateId: z.number(), teamId: z.number().optional(), @@ -35,6 +44,20 @@ export const ZDuplicateTemplateMutationSchema = z.object({ teamId: z.number().optional(), }); +export const ZCreateTemplateDirectLinkMutationSchema = z.object({ + templateId: z.number().min(1), + directRecipientId: z.number().min(1).optional(), +}); + +export const ZDeleteTemplateDirectLinkMutationSchema = z.object({ + templateId: z.number().min(1), +}); + +export const ZToggleTemplateDirectLinkMutationSchema = z.object({ + templateId: z.number().min(1), + enabled: z.boolean(), +}); + export const ZDeleteTemplateMutationSchema = z.object({ id: z.number().min(1), }); diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index b2543e363..396d172b7 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -29,6 +29,16 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next }) => { }); }); +export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next }) => { + return await next({ + ctx: { + ...ctx, + user: ctx.user, + session: ctx.session, + }, + }); +}); + export const adminMiddleware = t.middleware(async ({ ctx, next }) => { if (!ctx.session || !ctx.user) { throw new TRPCError({ @@ -49,7 +59,6 @@ export const adminMiddleware = t.middleware(async ({ ctx, next }) => { return await next({ ctx: { ...ctx, - user: ctx.user, session: ctx.session, }, @@ -62,4 +71,6 @@ export const adminMiddleware = t.middleware(async ({ ctx, next }) => { export const router = t.router; export const procedure = t.procedure; export const authenticatedProcedure = t.procedure.use(authenticatedMiddleware); +// While this is functionally the same as `procedure`, it's useful for indicating purpose +export const maybeAuthenticatedProcedure = t.procedure.use(maybeAuthenticatedMiddleware); export const adminProcedure = t.procedure.use(adminMiddleware); diff --git a/packages/ui/components/recipient/recipient-role-select.tsx b/packages/ui/components/recipient/recipient-role-select.tsx index eb1735a34..8b7a2c9ae 100644 --- a/packages/ui/components/recipient/recipient-role-select.tsx +++ b/packages/ui/components/recipient/recipient-role-select.tsx @@ -10,88 +10,94 @@ import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons'; import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; -export type RecipientRoleSelectProps = SelectProps; +export type RecipientRoleSelectProps = SelectProps & { + hideCCRecipients?: boolean; +}; -export const RecipientRoleSelect = forwardRef((props, ref) => ( - + + {/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */} + {ROLE_ICONS[props.value as RecipientRole]} + - - -
-
- {ROLE_ICONS[RecipientRole.SIGNER]} - Needs to sign + + +
+
+ {ROLE_ICONS[RecipientRole.SIGNER]} + Needs to sign +
+ + + + + +

The recipient is required to sign the document for it to be completed.

+
+
- - - - - -

The recipient is required to sign the document for it to be completed.

-
-
-
- + - -
-
- {ROLE_ICONS[RecipientRole.APPROVER]} - Needs to approve + +
+
+ {ROLE_ICONS[RecipientRole.APPROVER]} + Needs to approve +
+ + + + + +

The recipient is required to approve the document for it to be completed.

+
+
- - - - - -

The recipient is required to approve the document for it to be completed.

-
-
-
- + - -
-
- {ROLE_ICONS[RecipientRole.VIEWER]} - Needs to view + +
+
+ {ROLE_ICONS[RecipientRole.VIEWER]} + Needs to view +
+ + + + + +

The recipient is required to view the document for it to be completed.

+
+
- - - - - -

The recipient is required to view the document for it to be completed.

-
-
-
- + - -
-
- {ROLE_ICONS[RecipientRole.CC]} - Receives copy -
- - - - - -

- The recipient is not required to take any action and receives a copy of the document - after it is completed. -

-
-
-
-
- - -)); + {!hideCCRecipients && ( + +
+
+ {ROLE_ICONS[RecipientRole.CC]} + Receives copy +
+ + + + + +

+ The recipient is not required to take any action and receives a copy of the + document after it is completed. +

+
+
+
+
+ )} + + + ), +); RecipientRoleSelect.displayName = 'RecipientRoleSelect'; diff --git a/packages/ui/primitives/data-table-pagination.tsx b/packages/ui/primitives/data-table-pagination.tsx index 13d63442e..1d419ba87 100644 --- a/packages/ui/primitives/data-table-pagination.tsx +++ b/packages/ui/primitives/data-table-pagination.tsx @@ -34,7 +34,7 @@ export function DataTablePagination({ const visibleRows = table.getFilteredRowModel().rows.length; return ( - + Showing {visibleRows} result{visibleRows > 1 && 's'}. ); diff --git a/packages/ui/primitives/dialog.tsx b/packages/ui/primitives/dialog.tsx index c4491b9a0..66c186773 100644 --- a/packages/ui/primitives/dialog.tsx +++ b/packages/ui/primitives/dialog.tsx @@ -74,7 +74,10 @@ const DialogContent = React.forwardRef< > {children} {!hideClose && ( - + Close diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx index 1069290e6..a1bce432d 100644 --- a/packages/ui/primitives/pdf-viewer.tsx +++ b/packages/ui/primitives/pdf-viewer.tsx @@ -12,7 +12,6 @@ import { match } from 'ts-pattern'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { getFile } from '@documenso/lib/universal/upload/get-file'; import type { DocumentData } from '@documenso/prisma/client'; -import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { cn } from '../lib/utils'; import { PasswordDialog } from './document-password-dialog'; @@ -46,7 +45,6 @@ const PDFLoader = () => ( export type PDFViewerProps = { className?: string; documentData: DocumentData; - document?: DocumentWithData; password?: string | null; onPasswordSubmit?: (password: string) => void | Promise; onDocumentLoad?: (_doc: LoadedPDFDocument) => void; diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx index aa6eaec3c..d45251e01 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -1,15 +1,17 @@ 'use client'; -import React, { useId, useMemo, useState } from 'react'; +import React, { useEffect, useId, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { motion } from 'framer-motion'; -import { Plus, Trash } from 'lucide-react'; +import { Link2Icon, Plus, Trash } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { useFieldArray, useForm } from 'react-hook-form'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { nanoid } from '@documenso/lib/universal/id'; +import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates'; +import type { TemplateDirectLink } from '@documenso/prisma/client'; import { type Field, type Recipient, RecipientRole } from '@documenso/prisma/client'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select'; @@ -30,6 +32,7 @@ import { ShowFieldItem } from '../document-flow/show-field-item'; import type { DocumentFlowStep } from '../document-flow/types'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; import { useStep } from '../stepper'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; @@ -37,6 +40,7 @@ export type AddTemplatePlaceholderRecipientsFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; fields: Field[]; + templateDirectLink: TemplateDirectLink | null; isEnterprise: boolean; isDocumentPdfLoaded: boolean; onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void; @@ -46,6 +50,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ documentFlow, isEnterprise, recipients, + templateDirectLink, fields, isDocumentPdfLoaded, onSubmit, @@ -61,32 +66,43 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ const { currentStep, totalSteps, previousStep } = useStep(); + const generateDefaultFormSigners = () => { + if (recipients.length === 0) { + return [ + { + formId: initialId, + role: RecipientRole.SIGNER, + actionAuth: undefined, + ...generateRecipientPlaceholder(1), + }, + ]; + } + + return recipients.map((recipient) => ({ + nativeId: recipient.id, + formId: String(recipient.id), + name: recipient.name, + email: recipient.email, + role: recipient.role, + actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, + })); + }; + const form = useForm({ resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema), defaultValues: { - signers: - recipients.length > 0 - ? recipients.map((recipient) => ({ - nativeId: recipient.id, - formId: String(recipient.id), - name: recipient.name, - email: recipient.email, - role: recipient.role, - actionAuth: - ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, - })) - : [ - { - formId: initialId, - name: `Recipient 1`, - email: `recipient.1@documenso.com`, - role: RecipientRole.SIGNER, - actionAuth: undefined, - }, - ], + signers: generateDefaultFormSigners(), }, }); + useEffect(() => { + form.reset({ + signers: generateDefaultFormSigners(), + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recipients]); + // Always show advanced settings if any recipient has auth options. const alwaysShowAdvancedSettings = useMemo(() => { const recipientHasAuthOptions = recipients.find((recipient) => { @@ -130,11 +146,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ const onAddPlaceholderRecipient = () => { appendSigner({ formId: nanoid(12), - // Update TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX if this is ever changed. - name: `Recipient ${placeholderRecipientCount}`, - // Update TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX if this is ever changed. - email: `recipient.${placeholderRecipientCount}@documenso.com`, role: RecipientRole.SIGNER, + ...generateRecipientPlaceholder(placeholderRecipientCount), }); setPlaceholderRecipientCount((count) => count + 1); @@ -144,6 +157,15 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ removeSigner(index); }; + const isSignerDirectRecipient = ( + signer: TAddTemplatePlacholderRecipientsFormSchema['signers'][number], + ): boolean => { + return ( + templateDirectLink !== null && + signer.nativeId === templateDirectLink?.directTemplateRecipientId + ); + }; + return ( <> @@ -183,7 +205,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ type="email" placeholder="Email" {...field} - disabled={isSubmitting || signers[index].email === user?.email} + disabled={ + field.disabled || + isSubmitting || + signers[index].email === user?.email || + isSignerDirectRecipient(signer) + } /> @@ -208,7 +235,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ @@ -246,6 +278,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ {...field} onValueChange={field.onChange} disabled={isSubmitting} + hideCCRecipients={isSignerDirectRecipient(signer)} /> @@ -254,14 +287,32 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ )} /> - + {isSignerDirectRecipient(signer) ? ( + + + + + +

+ Direct link receiver +

+

+ This field cannot be modified or deleted. When you share this template's + direct link or add it to your public profile, anyone who accesses it can + input their name and email, and fill in the fields assigned to them. +

+
+
+ ) : ( + + )} ))}