From 2f86bb523b21f94ac4a7af657aba2ce98fbdca7b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 10 May 2024 19:45:19 +0700 Subject: [PATCH] feat: add template enhancements (#1154) ## Description General enhancements for templates. ## Changes Made Added the following changes to the template flow: - Allow adding document meta settings - Allow adding email settings - Allow adding document access & action authentication - Allow adding recipient action authentication - Save the state between template steps similar to how it works for documents Other changes: - Extract common fields between document and template flows - Remove the title field from "Use template" since we now have it as part of the template flow - Add new API endpoint for generating templates ## Testing Performed Added E2E tests for templates and creating documents from templates --- .../documents/[id]/edit-document.tsx | 1 + .../[id]/edit/document-edit-page-view.tsx | 10 +- .../templates/[id]/edit-template.tsx | 160 +++++++-- .../templates/[id]/template-page-view.tsx | 30 +- .../templates/new-template-dialog.tsx | 172 ++------- packages/api/v1/contract.ts | 20 ++ packages/api/v1/implementation.ts | 82 +++++ packages/api/v1/schema.ts | 54 +++ .../e2e/document-flow/settings-step.spec.ts | 21 +- .../e2e/document-flow/signers-step.spec.ts | 24 +- .../template-settings-step.spec.ts | 167 +++++++++ .../template-signers-step.spec.ts | 106 ++++++ .../create-document-from-template.spec.ts | 285 +++++++++++++++ packages/lib/schemas/common.ts | 12 + .../field/set-fields-for-template.ts | 39 ++- .../recipient/set-recipients-for-template.ts | 120 +++++-- .../template/create-document-from-template.ts | 89 ++++- .../get-template-with-details-by-id.ts | 38 ++ .../template/update-template-settings.ts | 139 ++++++++ .../migration.sql | 22 ++ packages/prisma/schema.prisma | 22 +- packages/prisma/seed/templates.ts | 27 ++ packages/prisma/types/template.ts | 19 + packages/trpc/server/admin-router/router.ts | 6 +- packages/trpc/server/field-router/router.ts | 2 +- .../trpc/server/recipient-router/router.ts | 4 +- .../trpc/server/recipient-router/schema.ts | 2 + .../trpc/server/template-router/router.ts | 52 +++ .../trpc/server/template-router/schema.ts | 36 +- .../document-global-auth-access-select.tsx | 66 ++++ .../document-global-auth-action-select.tsx | 80 +++++ .../document-send-email-message-helper.tsx | 34 ++ .../recipient/recipient-role-select.tsx | 152 ++++---- .../primitives/document-flow/add-settings.tsx | 113 +----- .../primitives/document-flow/add-subject.tsx | 28 +- .../add-template-placeholder-recipients.tsx | 18 +- .../template-flow/add-template-settings.tsx | 326 ++++++++++++++++++ .../add-template-settings.types.tsx | 35 ++ 38 files changed, 2103 insertions(+), 510 deletions(-) create mode 100644 packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts create mode 100644 packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts create mode 100644 packages/app-tests/e2e/templates/create-document-from-template.spec.ts create mode 100644 packages/lib/schemas/common.ts create mode 100644 packages/lib/server-only/template/get-template-with-details-by-id.ts create mode 100644 packages/lib/server-only/template/update-template-settings.ts create mode 100644 packages/prisma/migrations/20240508150017_add_template_settings/migration.sql create mode 100644 packages/prisma/types/template.ts create mode 100644 packages/ui/components/document/document-global-auth-access-select.tsx create mode 100644 packages/ui/components/document/document-global-auth-action-select.tsx create mode 100644 packages/ui/components/document/document-send-email-message-helper.tsx create mode 100644 packages/ui/primitives/template-flow/add-template-settings.tsx create mode 100644 packages/ui/primitives/template-flow/add-template-settings.types.tsx diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 2e2f0c889..1ad3d382b 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -332,6 +332,7 @@ export const EditDocumentForm = ({ isDocumentPdfLoaded={isDocumentPdfLoaded} onSubmit={onAddSettingsFormSubmit} /> + 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 d9da6c27c..21be26129 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx @@ -1,10 +1,14 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client'; +import { + DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + SKIP_QUERY_BATCH_META, +} from '@documenso/lib/constants/trpc'; +import type { TemplateWithDetails } from '@documenso/prisma/types/template'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -19,52 +23,135 @@ import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template- import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients'; import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; +import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-settings'; +import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { useOptionalCurrentTeam } from '~/providers/team'; + export type EditTemplateFormProps = { className?: string; - user: User; - template: Template; - recipients: Recipient[]; - fields: Field[]; - documentData: DocumentData; + initialTemplate: TemplateWithDetails; + isEnterprise: boolean; templateRootPath: string; }; -type EditTemplateStep = 'signers' | 'fields'; -const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields']; +type EditTemplateStep = 'settings' | 'signers' | 'fields'; +const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields']; export const EditTemplateForm = ({ + initialTemplate, className, - template, - recipients, - fields, - user: _user, - documentData, + isEnterprise, templateRootPath, }: EditTemplateFormProps) => { const { toast } = useToast(); const router = useRouter(); - const [step, setStep] = useState('signers'); + const team = useOptionalCurrentTeam(); + + const [step, setStep] = useState('settings'); + + const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); + + const utils = trpc.useUtils(); + + const { data: template, refetch: refetchTemplate } = + trpc.template.getTemplateWithDetailsById.useQuery( + { + id: initialTemplate.id, + }, + { + initialData: initialTemplate, + ...SKIP_QUERY_BATCH_META, + }, + ); + + const { Recipient: recipients, Field: fields, templateDocumentData } = template; const documentFlow: Record = { + settings: { + title: 'General', + description: 'Configure general settings for the template.', + stepIndex: 1, + }, signers: { title: 'Add Placeholders', description: 'Add all relevant placeholders for each recipient.', - stepIndex: 1, + stepIndex: 2, }, fields: { title: 'Add Fields', description: 'Add all relevant fields for each recipient.', - stepIndex: 2, + stepIndex: 3, }, }; const currentDocumentFlow = documentFlow[step]; - const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation(); - const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation(); + const { mutateAsync: updateTemplateSettings } = trpc.template.updateTemplateSettings.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (newData) => { + utils.template.getTemplateWithDetailsById.setData( + { + id: initialTemplate.id, + }, + (oldData) => ({ ...(oldData || initialTemplate), ...newData }), + ); + }, + }); + + const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (newData) => { + utils.template.getTemplateWithDetailsById.setData( + { + id: initialTemplate.id, + }, + (oldData) => ({ ...(oldData || initialTemplate), ...newData }), + ); + }, + }); + + const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (newData) => { + utils.template.getTemplateWithDetailsById.setData( + { + id: initialTemplate.id, + }, + (oldData) => ({ ...(oldData || initialTemplate), ...newData }), + ); + }, + }); + + const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => { + try { + await updateTemplateSettings({ + templateId: template.id, + teamId: team?.id, + data: { + title: data.title, + globalAccessAuth: data.globalAccessAuth ?? null, + globalActionAuth: data.globalActionAuth ?? null, + }, + meta: data.meta, + }); + + // Router refresh is here to clear the router cache for when navigating to /documents. + router.refresh(); + + setStep('signers'); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while updating the document settings.', + variant: 'destructive', + }); + } + }; const onAddTemplatePlaceholderFormSubmit = async ( data: TAddTemplatePlacholderRecipientsFormSchema, @@ -72,9 +159,11 @@ export const EditTemplateForm = ({ try { await addTemplateSigners({ templateId: template.id, + teamId: team?.id, signers: data.signers, }); + // Router refresh is here to clear the router cache for when navigating to /documents. router.refresh(); setStep('fields'); @@ -100,6 +189,9 @@ export const EditTemplateForm = ({ duration: 5000, }); + // Router refresh is here to clear the router cache for when navigating to /documents. + router.refresh(); + router.push(templateRootPath); } catch (err) { toast({ @@ -110,6 +202,15 @@ export const EditTemplateForm = ({ } }; + /** + * Refresh the data in the background when steps change. + */ + useEffect(() => { + void refetchTemplate(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step]); + return (
- + setIsDocumentPdfLoaded(true)} + /> @@ -135,14 +240,25 @@ export const EditTemplateForm = ({ currentStep={currentDocumentFlow.stepIndex} setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])} > + + null); @@ -44,18 +43,10 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps) redirect(templateRootPath); } - const { templateDocumentData } = template; - - const [templateRecipients, templateFields] = await Promise.all([ - getRecipientsForTemplate({ - templateId, - userId: user.id, - }), - getFieldsForTemplate({ - templateId, - userId: user.id, - }), - ]); + const isTemplateEnterprise = await isUserEnterprise({ + userId: user.id, + teamId: team?.id, + }); return (
@@ -74,12 +65,9 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
); diff --git a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx index 1a6e34584..ec9cb5911 100644 --- a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx @@ -1,21 +1,16 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { FilePlus, X } from 'lucide-react'; +import { FilePlus, Loader } from 'lucide-react'; import { useSession } from 'next-auth/react'; -import { useForm } from 'react-hook-form'; -import * as z from 'zod'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; -import { base64 } from '@documenso/lib/universal/base64'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Dialog, DialogClose, @@ -27,24 +22,8 @@ import { DialogTrigger, } from '@documenso/ui/primitives/dialog'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -const ZCreateTemplateFormSchema = z.object({ - name: z.string(), -}); - -type TCreateTemplateFormSchema = z.infer; - type NewTemplateDialogProps = { teamId?: number; templateRootPath: string; @@ -56,50 +35,20 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo const { data: session } = useSession(); const { toast } = useToast(); - const form = useForm({ - defaultValues: { - name: '', - }, - resolver: zodResolver(ZCreateTemplateFormSchema), - }); - const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation(); const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false); - const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>(); + const [isUploadingFile, setIsUploadingFile] = useState(false); const onFileDrop = async (file: File) => { - try { - const arrayBuffer = await file.arrayBuffer(); - const base64String = base64.encode(new Uint8Array(arrayBuffer)); - - setUploadedFile({ - file, - fileBase64: `data:application/pdf;base64,${base64String}`, - }); - - if (!form.getValues('name')) { - form.setValue('name', file.name); - } - } catch { - toast({ - title: 'Something went wrong', - description: 'Please try again later.', - variant: 'destructive', - }); - } - }; - - const onSubmit = async (values: TCreateTemplateFormSchema) => { - if (!uploadedFile) { + if (isUploadingFile) { return; } - const file: File = uploadedFile.file; + setIsUploadingFile(true); try { const { type, data } = await putPdfFile(file); - const { id: templateDocumentDataId } = await createDocumentData({ type, data, @@ -107,7 +56,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo const { id } = await createTemplate({ teamId, - title: values.name ? values.name : file.name, + title: file.name, templateDocumentDataId, }); @@ -127,26 +76,16 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo description: 'Please try again later.', variant: 'destructive', }); + + setIsUploadingFile(false); } }; - const resetForm = () => { - if (form.getValues('name') === uploadedFile?.file.name) { - form.reset(); - } - - setUploadedFile(null); - }; - - useEffect(() => { - if (!showNewTemplateDialog) { - form.reset(); - setUploadedFile(null); - } - }, [form, showNewTemplateDialog]); - return ( - + !isUploadingFile && setShowNewTemplateDialog(value)} + > + {isUploadingFile && ( +
+ +
+ )} +
-
-
-
-
-
- -

- Uploaded Document -

- - - {uploadedFile.file.name} - - - - ) : ( - - )} -
- - - - - - - - - - - + + + + + ); diff --git a/packages/api/v1/contract.ts b/packages/api/v1/contract.ts index ca2b6e2f5..577143ead 100644 --- a/packages/api/v1/contract.ts +++ b/packages/api/v1/contract.ts @@ -12,6 +12,8 @@ import { ZDeleteFieldMutationSchema, ZDeleteRecipientMutationSchema, ZDownloadDocumentSuccessfulSchema, + ZGenerateDocumentFromTemplateMutationResponseSchema, + ZGenerateDocumentFromTemplateMutationSchema, ZGetDocumentsQuerySchema, ZSendDocumentForSigningMutationSchema, ZSuccessfulDocumentResponseSchema, @@ -85,6 +87,24 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Create a new document from an existing template', + deprecated: true, + description: `This has been deprecated in favour of "/api/v1/templates/:templateId/generate-document". You may face unpredictable behavior using this endpoint as it is no longer maintained.`, + }, + + generateDocumentFromTemplate: { + method: 'POST', + path: '/api/v1/templates/:templateId/generate-document', + body: ZGenerateDocumentFromTemplateMutationSchema, + responses: { + 200: ZGenerateDocumentFromTemplateMutationResponseSchema, + 400: ZUnsuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + 500: ZUnsuccessfulResponseSchema, + }, + summary: 'Create a new document from an existing template', + description: + 'Create a new document from an existing template. Passing in values for title and meta will override the original values defined in the template. If you do not pass in values for recipients, it will use the values defined in the template.', }, sendDocument: { diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index ee8bf5996..7e729262e 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -1,6 +1,7 @@ import { createNextRoute } from '@ts-rest/next'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; +import { AppError } from '@documenso/lib/errors/app-error'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { createDocument } from '@documenso/lib/server-only/document/create-document'; @@ -19,6 +20,8 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient'; +import type { CreateDocumentFromTemplateResponse } from '@documenso/lib/server-only/template/create-document-from-template'; +import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { getFile } from '@documenso/lib/universal/upload/get-file'; @@ -351,6 +354,85 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { }; }), + generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => { + const { body, params } = args; + + const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id }); + + if (remaining.documents <= 0) { + return { + status: 400, + body: { + message: 'You have reached the maximum number of documents allowed for this month', + }, + }; + } + + const templateId = Number(params.templateId); + + let document: CreateDocumentFromTemplateResponse | null = null; + + try { + document = await createDocumentFromTemplate({ + templateId, + userId: user.id, + teamId: team?.id, + recipients: body.recipients, + override: { + title: body.title, + ...body.meta, + }, + }); + } catch (err) { + return AppError.toRestAPIError(err); + } + + if (body.formValues) { + const fileName = document.title.endsWith('.pdf') ? document.title : `${document.title}.pdf`; + + const pdf = await getFile(document.documentData); + + const prefilled = await insertFormValuesInPdf({ + pdf: Buffer.from(pdf), + formValues: body.formValues, + }); + + const newDocumentData = await putPdfFile({ + name: fileName, + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(prefilled), + }); + + await updateDocument({ + documentId: document.id, + userId: user.id, + teamId: team?.id, + data: { + formValues: body.formValues, + documentData: { + connect: { + id: newDocumentData.id, + }, + }, + }, + }); + } + + return { + status: 200, + body: { + documentId: document.id, + recipients: document.Recipient.map((recipient) => ({ + recipientId: recipient.id, + name: recipient.name, + email: recipient.email, + token: recipient.token, + role: recipient.role, + })), + }, + }; + }), + sendDocument: authenticatedMiddleware(async (args, user, team) => { const { id } = args.params; diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index be0ea1271..f109df348 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { ZUrlSchema } from '@documenso/lib/schemas/common'; import { FieldType, ReadStatus, @@ -141,6 +142,59 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer< typeof ZCreateDocumentFromTemplateMutationResponseSchema >; +export const ZGenerateDocumentFromTemplateMutationSchema = z.object({ + title: z.string().optional(), + recipients: z + .array( + z.object({ + id: z.number(), + name: z.string().optional(), + email: z.string().email().min(1), + }), + ) + .refine( + (schema) => { + const emails = schema.map((signer) => signer.email.toLowerCase()); + const ids = schema.map((signer) => signer.id); + + return new Set(emails).size === emails.length && new Set(ids).size === ids.length; + }, + { message: 'Recipient IDs and emails must be unique' }, + ), + meta: z + .object({ + subject: z.string(), + message: z.string(), + timezone: z.string(), + dateFormat: z.string(), + redirectUrl: ZUrlSchema, + }) + .partial() + .optional(), + formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), +}); + +export type TGenerateDocumentFromTemplateMutationSchema = z.infer< + typeof ZGenerateDocumentFromTemplateMutationSchema +>; + +export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({ + documentId: z.number(), + recipients: z.array( + z.object({ + recipientId: z.number(), + name: z.string(), + email: z.string().email().min(1), + token: z.string(), + role: z.nativeEnum(RecipientRole), + }), + ), +}); + +export type TGenerateDocumentFromTemplateMutationResponseSchema = z.infer< + typeof ZGenerateDocumentFromTemplateMutationResponseSchema +>; + export const ZCreateRecipientMutationSchema = z.object({ name: z.string().min(1), email: z.string().email().min(1), diff --git a/packages/app-tests/e2e/document-flow/settings-step.spec.ts b/packages/app-tests/e2e/document-flow/settings-step.spec.ts index b416baa7c..cef428a24 100644 --- a/packages/app-tests/e2e/document-flow/settings-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/settings-step.spec.ts @@ -41,8 +41,8 @@ test.describe('[EE_ONLY]', () => { // Set EE action auth. await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require account').getByText('Require account').click(); - await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account'); + await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); // Save the settings by going to the next step. await page.getByRole('button', { name: 'Continue' }).click(); @@ -52,11 +52,7 @@ test.describe('[EE_ONLY]', () => { await page.getByRole('button', { name: 'Go Back' }).click(); await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); - // Todo: Verify that the values are correct once we fix the issue where going back - // does not show the updated values. - // await expect(page.getByLabel('Title')).toContainText('New Title'); - // await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); - // await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account'); + await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); await unseedUser(user.id); }); @@ -89,8 +85,8 @@ test.describe('[EE_ONLY]', () => { // Set EE action auth. await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require account').getByText('Require account').click(); - await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account'); + await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); // Save the settings by going to the next step. await page.getByRole('button', { name: 'Continue' }).click(); @@ -168,11 +164,8 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => { await page.getByRole('button', { name: 'Go Back' }).click(); await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); - // Todo: Verify that the values are correct once we fix the issue where going back - // does not show the updated values. - // await expect(page.getByLabel('Title')).toContainText('New Title'); - // await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); - // await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account'); + await expect(page.getByLabel('Title')).toHaveValue('New Title'); + await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); await unseedUser(user.id); }); diff --git a/packages/app-tests/e2e/document-flow/signers-step.spec.ts b/packages/app-tests/e2e/document-flow/signers-step.spec.ts index 8676d05ed..a832c69a6 100644 --- a/packages/app-tests/e2e/document-flow/signers-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/signers-step.spec.ts @@ -48,7 +48,7 @@ test.describe('[EE_ONLY]', () => { await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2'); // Display advanced settings. - await page.getByLabel('Show advanced settings').click(); + await page.getByLabel('Show advanced settings').check(); // Navigate to the next step and back. await page.getByRole('button', { name: 'Continue' }).click(); @@ -62,7 +62,6 @@ test.describe('[EE_ONLY]', () => { }); }); -// Note: Not complete yet due to issue with back button. test('[DOCUMENT_FLOW]: add signers', async ({ page }) => { const user = await seedUser(); const document = await seedBlankDocument(user); @@ -93,26 +92,5 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => { await page.getByRole('button', { name: 'Go Back' }).click(); await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); - // Todo: Fix stepper component back issue before finishing test. - - // // Expect that the advanced settings is unchecked, since no advanced settings were applied. - // await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false }); - - // // Add advanced settings for a single recipient. - // await page.getByLabel('Show advanced settings').click(); - // await page.getByRole('combobox').first().click(); - // await page.getByLabel('Require account').click(); - - // // Navigate to the next step and back. - // await page.getByRole('button', { name: 'Continue' }).click(); - // await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); - // await page.getByRole('button', { name: 'Go Back' }).click(); - // await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); - - // Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced - // settings were applied. - - // Todo: Fix stepper component back issue before finishing test. - await unseedUser(user.id); }); diff --git a/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts new file mode 100644 index 000000000..517a3f093 --- /dev/null +++ b/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts @@ -0,0 +1,167 @@ +import { expect, test } from '@playwright/test'; + +import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; +import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('[EE_ONLY]', () => { + const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || ''; + + test.beforeEach(() => { + test.skip( + process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId, + 'Billing required for this test', + ); + }); + + test('[TEMPLATE_FLOW] add action auth settings', async ({ page }) => { + const user = await seedUser(); + + await seedUserSubscription({ + userId: user.id, + priceId: enterprisePriceId, + }); + + const template = await seedBlankTemplate(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/templates/${template.id}`, + }); + + // Set EE action auth. + await page.getByTestId('documentActionSelectValue').click(); + await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible(); + + // Return to the settings step to check that the results are saved correctly. + await page.getByRole('button', { name: 'Go Back' }).click(); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + + await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); + + await unseedUser(user.id); + }); + + test('[TEMPLATE_FLOW] enterprise team member can add action auth settings', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + const teamMemberUser = team.members[1].user; + + // Make the team enterprise by giving the owner the enterprise subscription. + await seedUserSubscription({ + userId: team.ownerUserId, + priceId: enterprisePriceId, + }); + + const template = await seedBlankTemplate(owner, { + createTemplateOptions: { + teamId: team.id, + }, + }); + + await apiSignin({ + page, + email: teamMemberUser.email, + redirectPath: `/t/${team.url}/templates/${template.id}`, + }); + + // Set EE action auth. + await page.getByTestId('documentActionSelectValue').click(); + await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible(); + + // Advanced settings should be visible. + await expect(page.getByLabel('Show advanced settings')).toBeVisible(); + + await unseedTeam(team.url); + }); + + test('[TEMPLATE_FLOW] enterprise team member should not have access to enterprise on personal account', async ({ + page, + }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const teamMemberUser = team.members[1].user; + + // Make the team enterprise by giving the owner the enterprise subscription. + await seedUserSubscription({ + userId: team.ownerUserId, + priceId: enterprisePriceId, + }); + + const template = await seedBlankTemplate(teamMemberUser); + + await apiSignin({ + page, + email: teamMemberUser.email, + redirectPath: `/templates/${template.id}`, + }); + + // Global action auth should not be visible. + await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible(); + + // Next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible(); + + // Advanced settings should not be visible. + await expect(page.getByLabel('Show advanced settings')).not.toBeVisible(); + + await unseedTeam(team.url); + }); +}); + +test('[TEMPLATE_FLOW]: add settings', async ({ page }) => { + const user = await seedUser(); + const template = await seedBlankTemplate(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/templates/${template.id}`, + }); + + // Set title. + await page.getByLabel('Title').fill('New Title'); + + // Set access auth. + await page.getByTestId('documentAccessSelectValue').click(); + await page.getByLabel('Require account').getByText('Require account').click(); + await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); + + // Action auth should NOT be visible. + await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible(); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible(); + + // Return to the settings step to check that the results are saved correctly. + await page.getByRole('button', { name: 'Go Back' }).click(); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + + await expect(page.getByLabel('Title')).toHaveValue('New Title'); + await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); + + await unseedUser(user.id); +}); diff --git a/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts new file mode 100644 index 000000000..37b58f53b --- /dev/null +++ b/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from '@playwright/test'; + +import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('[EE_ONLY]', () => { + const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || ''; + + test.beforeEach(() => { + test.skip( + process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId, + 'Billing required for this test', + ); + }); + + test('[TEMPLATE_FLOW] add EE settings', async ({ page }) => { + const user = await seedUser(); + + await seedUserSubscription({ + userId: user.id, + priceId: enterprisePriceId, + }); + + const template = await seedBlankTemplate(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/templates/${template.id}`, + }); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible(); + + // Add 2 signers. + await page.getByPlaceholder('Email').fill('recipient1@documenso.com'); + await page.getByPlaceholder('Name').fill('Recipient 1'); + await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click(); + await page + .getByRole('textbox', { name: 'Email', exact: true }) + .fill('recipient2@documenso.com'); + await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2'); + + // Display advanced settings. + await page.getByLabel('Show advanced settings').check(); + + // Navigate to the next step and back. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + await page.getByRole('button', { name: 'Go Back' }).click(); + await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible(); + + // Expect that the advanced settings is unchecked, since no advanced settings were applied. + await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false }); + + // Add advanced settings for a single recipient. + await page.getByLabel('Show advanced settings').check(); + await page.getByRole('combobox').first().click(); + await page.getByLabel('Require passkey').click(); + + // Navigate to the next step and back. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + await page.getByRole('button', { name: 'Go Back' }).click(); + await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible(); + + // Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced + // settings were applied. + await expect(page.getByLabel('Show advanced settings')).toBeHidden(); + + await unseedUser(user.id); + }); +}); + +test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => { + const user = await seedUser(); + const template = await seedBlankTemplate(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/templates/${template.id}`, + }); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible(); + + // Add 2 signers. + await page.getByPlaceholder('Email').fill('recipient1@documenso.com'); + await page.getByPlaceholder('Name').fill('Recipient 1'); + await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click(); + await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com'); + await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2'); + + // Advanced settings should not be visible for non EE users. + await expect(page.getByLabel('Show advanced settings')).toBeHidden(); + + await unseedUser(user.id); +}); diff --git a/packages/app-tests/e2e/templates/create-document-from-template.spec.ts b/packages/app-tests/e2e/templates/create-document-from-template.spec.ts new file mode 100644 index 000000000..4dfa14eb7 --- /dev/null +++ b/packages/app-tests/e2e/templates/create-document-from-template.spec.ts @@ -0,0 +1,285 @@ +import { expect, test } from '@playwright/test'; + +import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { prisma } from '@documenso/prisma'; +import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; +import { seedTeam } from '@documenso/prisma/seed/teams'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || ''; + +/** + * 1. Create a template with all settings filled out + * 2. Create a document from the template + * 3. Ensure all values are correct + * + * Note: There is a direct copy paste of this test below for teams. + * + * If you update this test please update that test as well. + */ +test('[TEMPLATE]: should create a document from a template', async ({ page }) => { + const user = await seedUser(); + const template = await seedBlankTemplate(user); + + const isBillingEnabled = + process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId; + + await seedUserSubscription({ + userId: user.id, + priceId: enterprisePriceId, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/templates/${template.id}`, + }); + + // Set template title. + await page.getByLabel('Title').fill('TEMPLATE_TITLE'); + + // Set template document access. + await page.getByTestId('documentAccessSelectValue').click(); + await page.getByLabel('Require account').getByText('Require account').click(); + await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); + + // Set EE action auth. + if (isBillingEnabled) { + await page.getByTestId('documentActionSelectValue').click(); + await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); + } + + // Set email options. + await page.getByRole('button', { name: 'Email Options' }).click(); + await page.getByLabel('Subject (Optional)').fill('SUBJECT'); + await page.getByLabel('Message (Optional)').fill('MESSAGE'); + + // Set advanced options. + await page.getByRole('button', { name: 'Advanced Options' }).click(); + await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click(); + await page.getByLabel('DD/MM/YYYY').click(); + + await page.locator('.time-zone-field').click(); + await page.getByRole('option', { name: 'Etc/UTC' }).click(); + await page.getByLabel('Redirect URL').fill('https://documenso.com'); + await page.getByRole('button', { name: 'Continue' }).click(); + + await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible(); + + // Add 2 signers. + await page.getByPlaceholder('Email').fill('recipient1@documenso.com'); + await page.getByPlaceholder('Name').fill('Recipient 1'); + await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click(); + await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com'); + await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2'); + + // Apply require passkey for Recipient 1. + if (isBillingEnabled) { + await page.getByLabel('Show advanced settings').check(); + await page.getByRole('combobox').first().click(); + await page.getByLabel('Require passkey').click(); + } + + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.getByRole('button', { name: 'Save template' }).click(); + + // Use template + await page.waitForURL('/templates'); + await page.getByRole('button', { name: 'Use Template' }).click(); + await page.getByRole('button', { name: 'Create as draft' }).click(); + + // Review that the document was created with the correct values. + await page.waitForURL(/documents/); + + const documentId = Number(page.url().split('/').pop()); + + const document = await prisma.document.findFirstOrThrow({ + where: { + id: documentId, + }, + include: { + Recipient: true, + documentMeta: true, + }, + }); + + const documentAuth = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + }); + + expect(document.title).toEqual('TEMPLATE_TITLE'); + expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT'); + expect(documentAuth.documentAuthOption.globalActionAuth).toEqual( + isBillingEnabled ? 'PASSKEY' : null, + ); + expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a'); + expect(document.documentMeta?.message).toEqual('MESSAGE'); + expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com'); + expect(document.documentMeta?.subject).toEqual('SUBJECT'); + expect(document.documentMeta?.timezone).toEqual('Etc/UTC'); + + const recipientOne = document.Recipient[0]; + const recipientTwo = document.Recipient[1]; + + const recipientOneAuth = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipientOne.authOptions, + }); + + const recipientTwoAuth = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipientTwo.authOptions, + }); + + if (isBillingEnabled) { + expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY'); + } + + expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); + expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); +}); + +/** + * This is a direct copy paste of the above test but for teams. + */ +test('[TEMPLATE]: should create a team document from a team template', async ({ page }) => { + const { owner, ...team } = await seedTeam({ + createTeamMembers: 2, + }); + + const template = await seedBlankTemplate(owner, { + createTemplateOptions: { + teamId: team.id, + }, + }); + + const isBillingEnabled = + process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId; + + await seedUserSubscription({ + userId: owner.id, + priceId: enterprisePriceId, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/templates/${template.id}`, + }); + + // Set template title. + await page.getByLabel('Title').fill('TEMPLATE_TITLE'); + + // Set template document access. + await page.getByTestId('documentAccessSelectValue').click(); + await page.getByLabel('Require account').getByText('Require account').click(); + await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); + + // Set EE action auth. + if (isBillingEnabled) { + await page.getByTestId('documentActionSelectValue').click(); + await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); + } + + // Set email options. + await page.getByRole('button', { name: 'Email Options' }).click(); + await page.getByLabel('Subject (Optional)').fill('SUBJECT'); + await page.getByLabel('Message (Optional)').fill('MESSAGE'); + + // Set advanced options. + await page.getByRole('button', { name: 'Advanced Options' }).click(); + await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click(); + await page.getByLabel('DD/MM/YYYY').click(); + + await page.locator('.time-zone-field').click(); + await page.getByRole('option', { name: 'Etc/UTC' }).click(); + await page.getByLabel('Redirect URL').fill('https://documenso.com'); + await page.getByRole('button', { name: 'Continue' }).click(); + + await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible(); + + // Add 2 signers. + await page.getByPlaceholder('Email').fill('recipient1@documenso.com'); + await page.getByPlaceholder('Name').fill('Recipient 1'); + await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click(); + await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com'); + await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2'); + + // Apply require passkey for Recipient 1. + if (isBillingEnabled) { + await page.getByLabel('Show advanced settings').check(); + await page.getByRole('combobox').first().click(); + await page.getByLabel('Require passkey').click(); + } + + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.getByRole('button', { name: 'Save template' }).click(); + + // Use template + await page.waitForURL(`/t/${team.url}/templates`); + await page.getByRole('button', { name: 'Use Template' }).click(); + await page.getByRole('button', { name: 'Create as draft' }).click(); + + // Review that the document was created with the correct values. + await page.waitForURL(/documents/); + + const documentId = Number(page.url().split('/').pop()); + + const document = await prisma.document.findFirstOrThrow({ + where: { + id: documentId, + }, + include: { + Recipient: true, + documentMeta: true, + }, + }); + + expect(document.teamId).toEqual(team.id); + + const documentAuth = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + }); + + expect(document.title).toEqual('TEMPLATE_TITLE'); + expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT'); + expect(documentAuth.documentAuthOption.globalActionAuth).toEqual( + isBillingEnabled ? 'PASSKEY' : null, + ); + expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a'); + expect(document.documentMeta?.message).toEqual('MESSAGE'); + expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com'); + expect(document.documentMeta?.subject).toEqual('SUBJECT'); + expect(document.documentMeta?.timezone).toEqual('Etc/UTC'); + + const recipientOne = document.Recipient[0]; + const recipientTwo = document.Recipient[1]; + + const recipientOneAuth = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipientOne.authOptions, + }); + + const recipientTwoAuth = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipientTwo.authOptions, + }); + + if (isBillingEnabled) { + expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY'); + } + + expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); + expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); +}); diff --git a/packages/lib/schemas/common.ts b/packages/lib/schemas/common.ts new file mode 100644 index 000000000..101aeeff5 --- /dev/null +++ b/packages/lib/schemas/common.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { URL_REGEX } from '../constants/url-regex'; + +/** + * Note this allows empty strings. + */ +export const ZUrlSchema = z + .string() + .refine((value) => value === undefined || value === '' || URL_REGEX.test(value), { + message: 'Please enter a valid URL', + }); diff --git a/packages/lib/server-only/field/set-fields-for-template.ts b/packages/lib/server-only/field/set-fields-for-template.ts index 2062e06bc..62e8cbcd1 100644 --- a/packages/lib/server-only/field/set-fields-for-template.ts +++ b/packages/lib/server-only/field/set-fields-for-template.ts @@ -1,22 +1,19 @@ import { prisma } from '@documenso/prisma'; import type { FieldType } from '@documenso/prisma/client'; -export type Field = { - id?: number | null; - type: FieldType; - signerEmail: string; - signerId?: number; - pageNumber: number; - pageX: number; - pageY: number; - pageWidth: number; - pageHeight: number; -}; - export type SetFieldsForTemplateOptions = { userId: number; templateId: number; - fields: Field[]; + fields: { + id?: number | null; + type: FieldType; + signerEmail: string; + pageNumber: number; + pageX: number; + pageY: number; + pageWidth: number; + pageHeight: number; + }[]; }; export const setFieldsForTemplate = async ({ @@ -58,11 +55,7 @@ export const setFieldsForTemplate = async ({ }); const removedFields = existingFields.filter( - (existingField) => - !fields.find( - (field) => - field.id === existingField.id || field.signerEmail === existingField.Recipient?.email, - ), + (existingField) => !fields.find((field) => field.id === existingField.id), ); const linkedFields = fields.map((field) => { @@ -127,5 +120,13 @@ export const setFieldsForTemplate = async ({ }); } - return persistedFields; + // Filter out fields that have been removed or have been updated. + const filteredFields = existingFields.filter((field) => { + const isRemoved = removedFields.find((removedField) => removedField.id === field.id); + const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id); + + return !isRemoved && !isUpdated; + }); + + return [...filteredFields, ...persistedFields]; }; 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 5315711a5..73d05ab4e 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-template.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-template.ts @@ -1,21 +1,32 @@ +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { prisma } from '@documenso/prisma'; -import type { RecipientRole } from '@documenso/prisma/client'; +import type { Recipient } from '@documenso/prisma/client'; +import { RecipientRole } from '@documenso/prisma/client'; +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { + type TRecipientActionAuthTypes, + ZRecipientAuthOptionsSchema, +} from '../../types/document-auth'; import { nanoid } from '../../universal/id'; +import { createRecipientAuthOptions } from '../../utils/document-auth'; export type SetRecipientsForTemplateOptions = { userId: number; + teamId?: number; templateId: number; recipients: { id?: number; email: string; name: string; role: RecipientRole; + actionAuth?: TRecipientActionAuthTypes | null; }[]; }; export const setRecipientsForTemplate = async ({ userId, + teamId, templateId, recipients, }: SetRecipientsForTemplateOptions) => { @@ -43,6 +54,23 @@ export const setRecipientsForTemplate = async ({ throw new Error('Template not found'); } + const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); + + // Check if user has permission to set the global action auth. + if (recipientsHaveActionAuth) { + const isDocumentEnterprise = await isUserEnterprise({ + userId, + teamId, + }); + + if (!isDocumentEnterprise) { + throw new AppError( + AppErrorCode.UNAUTHORIZED, + 'You do not have permission to set the action auth', + ); + } + } + const normalizedRecipients = recipients.map((recipient) => ({ ...recipient, email: recipient.email.toLowerCase(), @@ -74,31 +102,59 @@ export const setRecipientsForTemplate = async ({ }; }); - const persistedRecipients = await prisma.$transaction( - // Disabling as wrapping promises here causes type issues - // eslint-disable-next-line @typescript-eslint/promise-function-async - linkedRecipients.map((recipient) => - prisma.recipient.upsert({ - where: { - id: recipient._persisted?.id ?? -1, - templateId, - }, - update: { - name: recipient.name, - email: recipient.email, - role: recipient.role, - templateId, - }, - create: { - name: recipient.name, - email: recipient.email, - role: recipient.role, - token: nanoid(), - templateId, - }, + const persistedRecipients = await prisma.$transaction(async (tx) => { + return await Promise.all( + linkedRecipients.map(async (recipient) => { + let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions); + + if (recipient.actionAuth !== undefined) { + authOptions = createRecipientAuthOptions({ + accessAuth: authOptions.accessAuth, + actionAuth: recipient.actionAuth, + }); + } + + const upsertedRecipient = await tx.recipient.upsert({ + where: { + id: recipient._persisted?.id ?? -1, + templateId, + }, + update: { + name: recipient.name, + email: recipient.email, + role: recipient.role, + templateId, + authOptions, + }, + create: { + name: recipient.name, + email: recipient.email, + role: recipient.role, + token: nanoid(), + templateId, + authOptions, + }, + }); + + const recipientId = upsertedRecipient.id; + + // Clear all fields if the recipient role is changed to a type that cannot have fields. + if ( + recipient._persisted && + recipient._persisted.role !== recipient.role && + (recipient.role === RecipientRole.CC || recipient.role === RecipientRole.VIEWER) + ) { + await tx.field.deleteMany({ + where: { + recipientId, + }, + }); + } + + return upsertedRecipient; }), - ), - ); + ); + }); if (removedRecipients.length > 0) { await prisma.recipient.deleteMany({ @@ -110,5 +166,17 @@ export const setRecipientsForTemplate = async ({ }); } - return persistedRecipients; + // Filter out recipients that have been removed or have been updated. + const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => { + const isRemoved = removedRecipients.find( + (removedRecipient) => removedRecipient.id === recipient.id, + ); + const isUpdated = persistedRecipients.find( + (persistedRecipient) => persistedRecipient.id === recipient.id, + ); + + return !isRemoved && !isUpdated; + }); + + return [...filteredRecipients, ...persistedRecipients]; }; 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 7cd098d6d..92590cfb2 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -5,15 +5,25 @@ import { type Recipient, WebhookTriggerEvents } from '@documenso/prisma/client'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import { ZRecipientAuthOptionsSchema } from '../../types/document-auth'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; +import { + createDocumentAuthOptions, + createRecipientAuthOptions, + extractDocumentAuthMethods, +} from '../../utils/document-auth'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; -type FinalRecipient = Pick & { +type FinalRecipient = Pick & { templateRecipientId: number; fields: Field[]; }; +export type CreateDocumentFromTemplateResponse = Awaited< + ReturnType +>; + export type CreateDocumentFromTemplateOptions = { templateId: number; userId: number; @@ -23,6 +33,19 @@ export type CreateDocumentFromTemplateOptions = { name?: string; email: string; }[]; + + /** + * Values that will override the predefined values in the template. + */ + override?: { + title?: string; + subject?: string; + message?: string; + timezone?: string; + password?: string; + dateFormat?: string; + redirectUrl?: string; + }; requestMetadata?: RequestMetadata; }; @@ -31,6 +54,7 @@ export const createDocumentFromTemplate = async ({ userId, teamId, recipients, + override, requestMetadata, }: CreateDocumentFromTemplateOptions) => { const user = await prisma.user.findFirstOrThrow({ @@ -65,6 +89,7 @@ export const createDocumentFromTemplate = async ({ }, }, templateDocumentData: true, + templateMeta: true, }, }); @@ -72,26 +97,34 @@ export const createDocumentFromTemplate = async ({ throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found'); } - if (recipients.length !== template.Recipient.length) { - throw new AppError(AppErrorCode.INVALID_BODY, 'Invalid number of recipients.'); - } - - const finalRecipients: FinalRecipient[] = template.Recipient.map((templateRecipient) => { - const foundRecipient = recipients.find((recipient) => recipient.id === templateRecipient.id); + // Check that all the passed in recipient IDs can be associated with a template recipient. + recipients.forEach((recipient) => { + const foundRecipient = template.Recipient.find( + (templateRecipient) => templateRecipient.id === recipient.id, + ); if (!foundRecipient) { throw new AppError( AppErrorCode.INVALID_BODY, - `Missing template recipient with ID ${templateRecipient.id}`, + `Recipient with ID ${recipient.id} not found in the template.`, ); } + }); + + const { documentAuthOption: templateAuthOptions } = extractDocumentAuthMethods({ + documentAuth: template.authOptions, + }); + + const finalRecipients: FinalRecipient[] = template.Recipient.map((templateRecipient) => { + const foundRecipient = recipients.find((recipient) => recipient.id === templateRecipient.id); return { templateRecipientId: templateRecipient.id, fields: templateRecipient.Field, - name: foundRecipient.name ?? '', - email: foundRecipient.email, + name: foundRecipient ? foundRecipient.name ?? '' : templateRecipient.name, + email: foundRecipient ? foundRecipient.email : templateRecipient.email, role: templateRecipient.role, + authOptions: templateRecipient.authOptions, }; }); @@ -108,16 +141,38 @@ export const createDocumentFromTemplate = async ({ data: { userId, teamId: template.teamId, - title: template.title, + title: override?.title || template.title, documentDataId: documentData.id, + authOptions: createDocumentAuthOptions({ + globalAccessAuth: templateAuthOptions.globalAccessAuth, + globalActionAuth: templateAuthOptions.globalActionAuth, + }), + documentMeta: { + create: { + subject: override?.subject || template.templateMeta?.subject, + message: override?.message || template.templateMeta?.message, + timezone: override?.timezone || template.templateMeta?.timezone, + password: override?.password || template.templateMeta?.password, + dateFormat: override?.dateFormat || template.templateMeta?.dateFormat, + redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl, + }, + }, Recipient: { createMany: { - data: finalRecipients.map((recipient) => ({ - email: recipient.email, - name: recipient.name, - role: recipient.role, - token: nanoid(), - })), + data: finalRecipients.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, + }), + token: nanoid(), + }; + }), }, }, }, 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 new file mode 100644 index 000000000..7d02c87cf --- /dev/null +++ b/packages/lib/server-only/template/get-template-with-details-by-id.ts @@ -0,0 +1,38 @@ +import { prisma } from '@documenso/prisma'; +import type { TemplateWithDetails } from '@documenso/prisma/types/template'; + +export type GetTemplateWithDetailsByIdOptions = { + id: number; + userId: number; +}; + +export const getTemplateWithDetailsById = async ({ + id, + userId, +}: GetTemplateWithDetailsByIdOptions): Promise => { + return await prisma.template.findFirstOrThrow({ + where: { + id, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, + include: { + templateDocumentData: true, + templateMeta: true, + Recipient: true, + Field: true, + }, + }); +}; diff --git a/packages/lib/server-only/template/update-template-settings.ts b/packages/lib/server-only/template/update-template-settings.ts new file mode 100644 index 000000000..ebf15bac0 --- /dev/null +++ b/packages/lib/server-only/template/update-template-settings.ts @@ -0,0 +1,139 @@ +'use server'; + +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; +import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { prisma } from '@documenso/prisma'; +import type { TemplateMeta } from '@documenso/prisma/client'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth'; +import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth'; + +export type UpdateTemplateSettingsOptions = { + userId: number; + teamId?: number; + templateId: number; + data: { + title?: string; + globalAccessAuth?: TDocumentAccessAuthTypes | null; + globalActionAuth?: TDocumentActionAuthTypes | null; + }; + meta?: Partial>; + requestMetadata?: RequestMetadata; +}; + +export const updateTemplateSettings = async ({ + userId, + teamId, + templateId, + meta, + data, +}: UpdateTemplateSettingsOptions) => { + if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) { + throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update'); + } + + const template = await prisma.template.findFirstOrThrow({ + where: { + id: templateId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + include: { + templateMeta: true, + }, + }); + + const { documentAuthOption } = extractDocumentAuthMethods({ + documentAuth: template.authOptions, + }); + + const { templateMeta } = template; + + const isDateSame = (templateMeta?.dateFormat || null) === (meta?.dateFormat || null); + const isMessageSame = (templateMeta?.message || null) === (meta?.message || null); + const isPasswordSame = (templateMeta?.password || null) === (meta?.password || null); + const isSubjectSame = (templateMeta?.subject || null) === (meta?.subject || null); + const isRedirectUrlSame = (templateMeta?.redirectUrl || null) === (meta?.redirectUrl || null); + const isTimezoneSame = (templateMeta?.timezone || null) === (meta?.timezone || null); + + // Early return to avoid unnecessary updates. + if ( + template.title === data.title && + data.globalAccessAuth === documentAuthOption.globalAccessAuth && + data.globalActionAuth === documentAuthOption.globalActionAuth && + isDateSame && + isMessageSame && + isPasswordSame && + isSubjectSame && + isRedirectUrlSame && + isTimezoneSame + ) { + return template; + } + + const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null; + const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null; + + // If the new global auth values aren't passed in, fallback to the current document values. + const newGlobalAccessAuth = + data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth; + const newGlobalActionAuth = + data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth; + + // Check if user has permission to set the global action auth. + if (newGlobalActionAuth) { + const isDocumentEnterprise = await isUserEnterprise({ + userId, + teamId, + }); + + if (!isDocumentEnterprise) { + throw new AppError( + AppErrorCode.UNAUTHORIZED, + 'You do not have permission to set the action auth', + ); + } + } + + const authOptions = createDocumentAuthOptions({ + globalAccessAuth: newGlobalAccessAuth, + globalActionAuth: newGlobalActionAuth, + }); + + return await prisma.template.update({ + where: { + id: templateId, + }, + data: { + title: data.title, + authOptions, + templateMeta: { + upsert: { + where: { + templateId, + }, + create: { + ...meta, + }, + update: { + ...meta, + }, + }, + }, + }, + }); +}; diff --git a/packages/prisma/migrations/20240508150017_add_template_settings/migration.sql b/packages/prisma/migrations/20240508150017_add_template_settings/migration.sql new file mode 100644 index 000000000..ca2341090 --- /dev/null +++ b/packages/prisma/migrations/20240508150017_add_template_settings/migration.sql @@ -0,0 +1,22 @@ +-- AlterTable +ALTER TABLE "Template" ADD COLUMN "authOptions" JSONB; + +-- CreateTable +CREATE TABLE "TemplateMeta" ( + "id" TEXT NOT NULL, + "subject" TEXT, + "message" TEXT, + "timezone" TEXT DEFAULT 'Etc/UTC', + "password" TEXT, + "dateFormat" TEXT DEFAULT 'yyyy-MM-dd hh:mm a', + "templateId" INTEGER NOT NULL, + "redirectUrl" TEXT, + + CONSTRAINT "TemplateMeta_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TemplateMeta_templateId_key" ON "TemplateMeta"("templateId"); + +-- AddForeignKey +ALTER TABLE "TemplateMeta" ADD CONSTRAINT "TemplateMeta_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 8acfbedfa..5c6752092 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -539,15 +539,29 @@ enum TemplateType { PRIVATE } +model TemplateMeta { + id String @id @default(cuid()) + subject String? + message String? + timezone String? @default("Etc/UTC") @db.Text + password String? + dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text + templateId Int @unique + template Template @relation(fields: [templateId], references: [id], onDelete: Cascade) + redirectUrl String? +} + model Template { - id Int @id @default(autoincrement()) - type TemplateType @default(PRIVATE) + id Int @id @default(autoincrement()) + type TemplateType @default(PRIVATE) title String userId Int teamId Int? + authOptions Json? + templateMeta TemplateMeta? templateDocumentDataId String - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + 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) diff --git a/packages/prisma/seed/templates.ts b/packages/prisma/seed/templates.ts index 3feb82289..f37306c87 100644 --- a/packages/prisma/seed/templates.ts +++ b/packages/prisma/seed/templates.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { prisma } from '..'; +import type { Prisma, User } from '../client'; import { DocumentDataType, ReadStatus, RecipientRole, SendStatus, SigningStatus } from '../client'; const examplePdf = fs @@ -14,6 +15,32 @@ type SeedTemplateOptions = { teamId?: number; }; +type CreateTemplateOptions = { + key?: string | number; + createTemplateOptions?: Partial; +}; + +export const seedBlankTemplate = async (owner: User, options: CreateTemplateOptions = {}) => { + const { key, createTemplateOptions = {} } = options; + + const documentData = await prisma.documentData.create({ + data: { + type: DocumentDataType.BYTES_64, + data: examplePdf, + initialData: examplePdf, + }, + }); + + return await prisma.template.create({ + data: { + title: `[TEST] Template ${key}`, + templateDocumentDataId: documentData.id, + userId: owner.id, + ...createTemplateOptions, + }, + }); +}; + export const seedTemplate = async (options: SeedTemplateOptions) => { const { title = 'Untitled', userId, teamId } = options; diff --git a/packages/prisma/types/template.ts b/packages/prisma/types/template.ts new file mode 100644 index 000000000..c5dc054a7 --- /dev/null +++ b/packages/prisma/types/template.ts @@ -0,0 +1,19 @@ +import type { + DocumentData, + Field, + Recipient, + Template, + TemplateMeta, +} from '@documenso/prisma/client'; + +export type TemplateWithData = Template & { + templateDocumentData?: DocumentData | null; + templateMeta?: TemplateMeta | null; +}; + +export type TemplateWithDetails = Template & { + templateDocumentData: DocumentData; + templateMeta: TemplateMeta | null; + Recipient: Recipient[]; + Field: Field[]; +}; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 05ee84736..7ab4c5d2d 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -102,7 +102,7 @@ export const adminRouter = router({ try { return await sealDocument({ documentId: id, isResealing: true }); } catch (err) { - console.log('resealDocument error', err); + console.error('resealDocument error', err); throw new TRPCError({ code: 'BAD_REQUEST', @@ -123,7 +123,7 @@ export const adminRouter = router({ return await deleteUser({ id }); } catch (err) { - console.log(err); + console.error(err); throw new TRPCError({ code: 'BAD_REQUEST', @@ -144,7 +144,7 @@ export const adminRouter = router({ requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { - console.log(err); + console.error(err); throw new TRPCError({ code: 'BAD_REQUEST', diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 354e937a5..d097e2400 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -53,7 +53,7 @@ export const fieldRouter = router({ const { templateId, fields } = input; try { - await setFieldsForTemplate({ + return await setFieldsForTemplate({ userId: ctx.user.id, templateId, fields: fields.map((field) => ({ diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index 61740e9a0..584c19ff5 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -46,16 +46,18 @@ export const recipientRouter = router({ .input(ZAddTemplateSignersMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { templateId, signers } = input; + const { templateId, signers, teamId } = input; return await setRecipientsForTemplate({ userId: ctx.user.id, + teamId, templateId, recipients: signers.map((signer) => ({ id: signer.nativeId, email: signer.email, name: signer.name, role: signer.role, + actionAuth: signer.actionAuth, })), }); } catch (err) { diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index 4b5522150..4317285c0 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -34,6 +34,7 @@ export type TAddSignersMutationSchema = z.infer { + try { + return await getTemplateWithDetailsById({ + id: input.id, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to find this template. Please try again later.', + }); + } + }), + + // Todo: Add API + updateTemplateSettings: authenticatedProcedure + .input(ZUpdateTemplateSettingsMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId, teamId, data, meta } = input; + + const userId = ctx.user.id; + + const requestMetadata = extractNextApiRequestMetadata(ctx.req); + + return await updateTemplateSettings({ + userId, + teamId, + templateId, + data, + meta, + requestMetadata, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'We were unable to update the settings for this template. Please try again later.', + }); + } + }), }); diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index ce1489ac3..79d609488 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -1,5 +1,11 @@ import { z } from 'zod'; +import { URL_REGEX } from '@documenso/lib/constants/url-regex'; +import { + ZDocumentAccessAuthTypesSchema, + ZDocumentActionAuthTypesSchema, +} from '@documenso/lib/types/document-auth'; + export const ZCreateTemplateMutationSchema = z.object({ title: z.string().min(1).trim(), teamId: z.number().optional(), @@ -33,10 +39,38 @@ export const ZDeleteTemplateMutationSchema = z.object({ id: z.number().min(1), }); +export const ZUpdateTemplateSettingsMutationSchema = z.object({ + templateId: z.number(), + teamId: z.number().min(1).optional(), + data: z.object({ + title: z.string().min(1).optional(), + globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(), + globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(), + }), + meta: z.object({ + subject: z.string(), + message: z.string(), + timezone: z.string(), + dateFormat: z.string(), + redirectUrl: z + .string() + .optional() + .refine((value) => value === undefined || value === '' || URL_REGEX.test(value), { + message: 'Please enter a valid URL', + }), + }), +}); + +export const ZGetTemplateWithDetailsByIdQuerySchema = z.object({ + id: z.number().min(1), +}); + export type TCreateTemplateMutationSchema = z.infer; export type TCreateDocumentFromTemplateMutationSchema = z.infer< typeof ZCreateDocumentFromTemplateMutationSchema >; - export type TDuplicateTemplateMutationSchema = z.infer; export type TDeleteTemplateMutationSchema = z.infer; +export type TGetTemplateWithDetailsByIdQuerySchema = z.infer< + typeof ZGetTemplateWithDetailsByIdQuerySchema +>; diff --git a/packages/ui/components/document/document-global-auth-access-select.tsx b/packages/ui/components/document/document-global-auth-access-select.tsx new file mode 100644 index 000000000..f660d7c10 --- /dev/null +++ b/packages/ui/components/document/document-global-auth-access-select.tsx @@ -0,0 +1,66 @@ +'use client'; + +import React, { forwardRef } from 'react'; + +import type { SelectProps } from '@radix-ui/react-select'; +import { InfoIcon } from 'lucide-react'; + +import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; +import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; + +export const DocumentGlobalAuthAccessSelect = forwardRef( + (props, ref) => ( + + ), +); + +DocumentGlobalAuthAccessSelect.displayName = 'DocumentGlobalAuthAccessSelect'; + +export const DocumentGlobalAuthAccessTooltip = () => ( + + + + + + +

+ Document access +

+ +

The authentication required for recipients to view the document.

+ +
    +
  • + Require account - The recipient must be signed in to view the document +
  • +
  • + None - The document can be accessed directly by the URL sent to the + recipient +
  • +
+
+
+); diff --git a/packages/ui/components/document/document-global-auth-action-select.tsx b/packages/ui/components/document/document-global-auth-action-select.tsx new file mode 100644 index 000000000..d90b492ac --- /dev/null +++ b/packages/ui/components/document/document-global-auth-action-select.tsx @@ -0,0 +1,80 @@ +'use client'; + +import React, { forwardRef } from 'react'; + +import type { SelectProps } from '@radix-ui/react-select'; +import { InfoIcon } from 'lucide-react'; + +import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; +import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; + +export const DocumentGlobalAuthActionSelect = forwardRef( + (props, ref) => ( + + ), +); + +DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect'; + +export const DocumentGlobalAuthActionTooltip = () => ( + + + + + + +

+ Global recipient action authentication +

+ +

The authentication required for recipients to sign the signature field.

+ +

+ This can be overriden by setting the authentication requirements directly on each recipient + in the next step. +

+ +
    + {/*
  • + Require account - The recipient must be signed in +
  • */} +
  • + Require passkey - The recipient must have an account and passkey + configured via their settings +
  • +
  • + Require 2FA - The recipient must have an account and 2FA enabled via + their settings +
  • +
  • + None - No authentication required +
  • +
+
+
+); diff --git a/packages/ui/components/document/document-send-email-message-helper.tsx b/packages/ui/components/document/document-send-email-message-helper.tsx new file mode 100644 index 000000000..855baefa4 --- /dev/null +++ b/packages/ui/components/document/document-send-email-message-helper.tsx @@ -0,0 +1,34 @@ +'use client'; + +import React from 'react'; + +export const DocumentSendEmailMessageHelper = () => { + return ( +
+

+ You can use the following variables in your message: +

+ +
    +
  • + + {'{signer.name}'} + {' '} + - The signer's name +
  • +
  • + + {'{signer.email}'} + {' '} + - The signer's email +
  • +
  • + + {'{document.name}'} + {' '} + - The document's name +
  • +
+
+ ); +}; diff --git a/packages/ui/components/recipient/recipient-role-select.tsx b/packages/ui/components/recipient/recipient-role-select.tsx index 43d3331ae..eb1735a34 100644 --- a/packages/ui/components/recipient/recipient-role-select.tsx +++ b/packages/ui/components/recipient/recipient-role-select.tsx @@ -1,6 +1,6 @@ 'use client'; -import React from 'react'; +import React, { forwardRef } from 'react'; import type { SelectProps } from '@radix-ui/react-select'; import { InfoIcon } from 'lucide-react'; @@ -12,86 +12,86 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive export type RecipientRoleSelectProps = SelectProps; -export const RecipientRoleSelect = (props: RecipientRoleSelectProps) => { - return ( - + + {/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */} + {ROLE_ICONS[props.value as RecipientRole]} + - - -
-
- {ROLE_ICONS[RecipientRole.SIGNER]} - Needs to sign -
- - - - - -

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

-
-
+ + +
+
+ {ROLE_ICONS[RecipientRole.SIGNER]} + Needs to sign
- + + + + + +

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

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

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

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

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

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

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

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

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. -

-
-
+ +
+
+ {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/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index ce52e03c2..5289ec483 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -7,16 +7,18 @@ import { InfoIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; -import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; -import { - DocumentAccessAuth, - DocumentActionAuth, - DocumentAuth, -} from '@documenso/lib/types/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; +import { + DocumentGlobalAuthAccessSelect, + DocumentGlobalAuthAccessTooltip, +} from '@documenso/ui/components/document/document-global-auth-access-select'; +import { + DocumentGlobalAuthActionSelect, + DocumentGlobalAuthActionTooltip, +} from '@documenso/ui/components/document/document-global-auth-action-select'; import { Accordion, AccordionContent, @@ -144,49 +146,11 @@ export const AddSettingsFormPartial = ({ Document access - - - - - - -

- Document access -

- -

The authentication required for recipients to view the document.

- -
    -
  • - Require account - The recipient must be signed in to - view the document -
  • -
  • - None - The document can be accessed directly by the URL - sent to the recipient -
  • -
-
-
+
- +
)} @@ -200,64 +164,11 @@ export const AddSettingsFormPartial = ({ Recipient action authentication - - - - - - -

- Global recipient action authentication -

- -

- The authentication required for recipients to sign the signature field. -

- -

- This can be overriden by setting the authentication requirements - directly on each recipient in the next step. -

- -
    - {/*
  • - Require account - The recipient must be signed in -
  • */} -
  • - Require passkey - The recipient must have an account - and passkey configured via their settings -
  • -
  • - Require 2FA - The recipient must have an account and - 2FA enabled via their settings -
  • -
  • - None - No authentication required -
  • -
-
-
+
- +
)} diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index 1b0608af8..bef5fbf5c 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form'; import type { Field, Recipient } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; +import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; import { FormErrorMessage } from '../form/form-error-message'; import { Input } from '../input'; @@ -104,32 +105,7 @@ export const AddSubjectFormPartial = ({ />
-
-

- You can use the following variables in your message: -

- -
    -
  • - - {'{signer.name}'} - {' '} - - The signer's name -
  • -
  • - - {'{signer.email}'} - {' '} - - The signer's email -
  • -
  • - - {'{document.name}'} - {' '} - - The document's name -
  • -
-
+
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 bbed6a39a..aa6eaec3c 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -26,6 +26,7 @@ import { DocumentFlowFormContainerFooter, DocumentFlowFormContainerStep, } from '../document-flow/document-flow-root'; +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'; @@ -36,15 +37,17 @@ export type AddTemplatePlaceholderRecipientsFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; fields: Field[]; - isTemplateOwnerEnterprise: boolean; + isEnterprise: boolean; + isDocumentPdfLoaded: boolean; onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void; }; export const AddTemplatePlaceholderRecipientsFormPartial = ({ documentFlow, - isTemplateOwnerEnterprise, + isEnterprise, recipients, - fields: _fields, + fields, + isDocumentPdfLoaded, onSubmit, }: AddTemplatePlaceholderRecipientsFormProps) => { const initialId = useId(); @@ -144,6 +147,11 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ return ( <> + {isDocumentPdfLoaded && + fields.map((field, index) => ( + + ))} +
@@ -209,7 +217,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ )} /> - {showAdvancedSettings && isTemplateOwnerEnterprise && ( + {showAdvancedSettings && isEnterprise && (
- {!alwaysShowAdvancedSettings && isTemplateOwnerEnterprise && ( + {!alwaysShowAdvancedSettings && isEnterprise && (
void; +}; + +export const AddTemplateSettingsFormPartial = ({ + documentFlow, + recipients, + fields, + isEnterprise, + isDocumentPdfLoaded, + template, + onSubmit, +}: AddTemplateSettingsFormProps) => { + const { documentAuthOption } = extractDocumentAuthMethods({ + documentAuth: template.authOptions, + }); + + const form = useForm({ + resolver: zodResolver(ZAddTemplateSettingsFormSchema), + defaultValues: { + title: template.title, + globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined, + globalActionAuth: documentAuthOption?.globalActionAuth || undefined, + meta: { + subject: template.templateMeta?.subject ?? '', + message: template.templateMeta?.message ?? '', + timezone: template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, + dateFormat: template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, + redirectUrl: template.templateMeta?.redirectUrl ?? '', + }, + }, + }); + + const { stepIndex, currentStep, totalSteps, previousStep } = useStep(); + + // We almost always want to set the timezone to the user's local timezone to avoid confusion + // when the document is signed. + useEffect(() => { + if (!form.formState.touchedFields.meta?.timezone) { + form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone); + } + }, [form, form.setValue, form.formState.touchedFields.meta?.timezone]); + + return ( + <> + + {isDocumentPdfLoaded && + fields.map((field, index) => ( + + ))} + + +
+ ( + + Template title + + + + + + + )} + /> + + ( + + + Document access + + + + + + + + )} + /> + + {isEnterprise && ( + ( + + + Recipient action authentication + + + + + + + + )} + /> + )} + + + + + Email Options + + + +
+ ( + + + Subject (Optional) + + + + + + + + + )} + /> + + ( + + + Message (Optional) + + + +