From 37e9db6626a7336427b4a2de4e3825a64186f948 Mon Sep 17 00:00:00 2001 From: Prajwal Kulkarni Date: Tue, 6 Feb 2024 00:40:53 +0530 Subject: [PATCH 01/39] Remove document on go back click on step 1 Invoke onBackStep on "go back" click and conditionally render go back label --- packages/ui/primitives/document-flow/add-fields.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 74764df80..083fbdcbf 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -566,7 +566,9 @@ export const AddFieldsFormPartial = ({ onGoBackClick={() => { previousStep(); remove(); + documentFlow.onBackStep?.(); }} + goBackLabel={currentStep === 1 && typeof documentFlow.onBackStep === "function" ? "Remove" : undefined} onGoNextClick={() => void onFormSubmit()} /> From c08768a33038f3499e95f3125d453a087cbe42fa Mon Sep 17 00:00:00 2001 From: Prajwal Kulkarni Date: Tue, 6 Feb 2024 21:01:48 +0530 Subject: [PATCH 02/39] Format code with prettier --- packages/ui/primitives/document-flow/add-fields.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 083fbdcbf..0316f2c13 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -568,7 +568,11 @@ export const AddFieldsFormPartial = ({ remove(); documentFlow.onBackStep?.(); }} - goBackLabel={currentStep === 1 && typeof documentFlow.onBackStep === "function" ? "Remove" : undefined} + goBackLabel={ + currentStep === 1 && typeof documentFlow.onBackStep === 'function' + ? 'Remove' + : undefined + } onGoNextClick={() => void onFormSubmit()} /> From 4855882ae6ae4e4381cc79f97611e275ffce5cc5 Mon Sep 17 00:00:00 2001 From: Prajwal Kulkarni Date: Wed, 7 Feb 2024 21:31:51 +0530 Subject: [PATCH 03/39] Update label render condition --- .../src/app/(marketing)/singleplayer/client.tsx | 1 + packages/ui/primitives/document-flow/add-fields.tsx | 11 +++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index a1b56257a..f9ca3bd9a 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -244,6 +244,7 @@ export const SinglePlayerClient = () => { recipients={uploadedFile ? [placeholderRecipient] : []} fields={fields} onSubmit={onFieldsSubmit} + isSinglePlayerMode={true} /> diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 0316f2c13..3e69c2e8d 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -53,6 +53,7 @@ export type AddFieldsFormProps = { recipients: Recipient[]; fields: Field[]; onSubmit: (_data: TAddFieldsFormSchema) => void; + isSinglePlayerMode?: boolean; }; export const AddFieldsFormPartial = ({ @@ -61,10 +62,12 @@ export const AddFieldsFormPartial = ({ recipients, fields, onSubmit, + isSinglePlayerMode = false, }: AddFieldsFormProps) => { const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement(); const { currentStep, totalSteps, previousStep } = useStep(); - + const canRenderBackButtonAsRemove = + currentStep === 1 && typeof documentFlow.onBackStep === 'function' && isSinglePlayerMode; const { control, handleSubmit, @@ -568,11 +571,7 @@ export const AddFieldsFormPartial = ({ remove(); documentFlow.onBackStep?.(); }} - goBackLabel={ - currentStep === 1 && typeof documentFlow.onBackStep === 'function' - ? 'Remove' - : undefined - } + goBackLabel={canRenderBackButtonAsRemove ? 'Remove' : undefined} onGoNextClick={() => void onFormSubmit()} /> From 897f0dabdefeebedb93f0a32be500d22d8c1fde0 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 15 Feb 2024 14:21:40 +0000 Subject: [PATCH 04/39] feat: 2fa pin input component --- apps/web/package.json | 2 + .../2fa/enable-authenticator-app-dialog.tsx | 44 +++++++++++++++---- package-lock.json | 32 ++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index fd4faa0c1..8656d5092 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ "perfect-freehand": "^1.2.0", "posthog-js": "^1.75.3", "posthog-node": "^3.1.1", + "rci": "^0.1.0", "react": "18.2.0", "react-dom": "18.2.0", "react-dropzone": "^14.2.3", @@ -47,6 +48,7 @@ "typescript": "5.2.2", "ua-parser-js": "^1.0.37", "uqr": "^0.1.2", + "use-is-focused": "^0.0.1", "zod": "^3.22.4" }, "devDependencies": { diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx index 7a181c4cc..7a493d5b0 100644 --- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; @@ -27,8 +27,8 @@ import { FormLabel, FormMessage, } from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; import { PasswordInput } from '@documenso/ui/primitives/password-input'; +import { PinInput, type PinInputState } from '@documenso/ui/primitives/pin-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { RecoveryCodeList } from './recovery-code-list'; @@ -54,6 +54,7 @@ export const EnableAuthenticatorAppDialog = ({ open, onOpenChange, }: EnableAuthenticatorAppDialogProps) => { + const [state, setState] = useState('input'); const router = useRouter(); const { toast } = useToast(); @@ -119,13 +120,15 @@ export const EnableAuthenticatorAppDialog = ({ token, }: TEnableTwoFactorAuthenticationForm) => { try { - await enableTwoFactorAuthentication({ code: token }); + const enabled2fa = await enableTwoFactorAuthentication({ code: token }); toast({ title: 'Two-factor authentication enabled', description: 'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.', }); + + return enabled2fa; } catch (_err) { toast({ title: 'Unable to setup two-factor authentication', @@ -136,6 +139,31 @@ export const EnableAuthenticatorAppDialog = ({ } }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const onPinInputChange = ({ currentTarget: input }: any) => { + input.value = input.value.replace(/\D+/g, ''); + + if (input.value.length === 6) { + setState('loading'); + + void onEnableTwoFactorAuthenticationFormSubmit({ token: input.value }).then((success) => { + if (success) { + setState('success'); + return; + } + + setState('error'); + + setTimeout(() => { + setState('input'); + input.value = ''; + input.dispatchEvent(new Event('input')); + input.focus(); + }, 500); + }); + } + }; + const onCompleteClick = () => { flushSync(() => { onOpenChange(false); @@ -146,7 +174,7 @@ export const EnableAuthenticatorAppDialog = ({ return ( - + Enable Authenticator App @@ -241,18 +269,18 @@ export const EnableAuthenticatorAppDialog = ({ ( + render={({ field: _field }) => ( Token - + )} /> - + {/* @@ -260,7 +288,7 @@ export const EnableAuthenticatorAppDialog = ({ - + */} )) diff --git a/package-lock.json b/package-lock.json index 3c136e801..803a68344 100644 --- a/package-lock.json +++ b/package-lock.json @@ -151,6 +151,7 @@ "perfect-freehand": "^1.2.0", "posthog-js": "^1.75.3", "posthog-node": "^3.1.1", + "rci": "^0.1.0", "react": "18.2.0", "react-dom": "18.2.0", "react-dropzone": "^14.2.3", @@ -163,6 +164,7 @@ "typescript": "5.2.2", "ua-parser-js": "^1.0.37", "uqr": "^0.1.2", + "use-is-focused": "^0.0.1", "zod": "^3.22.4" }, "devDependencies": { @@ -15726,6 +15728,18 @@ "node": ">= 0.8" } }, + "node_modules/rci": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/rci/-/rci-0.1.0.tgz", + "integrity": "sha512-o/elFrXXRLdYDAq/qQUFE175TqzJ5nU3MYwIwa6WOZfljNJ4akQSy1n7zA79swB696MNIFDWJs+Do0q2FBTy+Q==", + "dependencies": { + "use-code-input": "0.0.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/re-resizable": { "version": "6.9.6", "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.9.6.tgz", @@ -18860,6 +18874,24 @@ } } }, + "node_modules/use-code-input": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/use-code-input/-/use-code-input-0.0.2.tgz", + "integrity": "sha512-lDIUiRca0K8sF+c/KZ9cz5g6oPqlFiTmaDgwGzg0wlNSnFAvROtweKy0XpihEWJwo2tjETtgAxIh82RVGaBFHQ==", + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/use-is-focused": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/use-is-focused/-/use-is-focused-0.0.1.tgz", + "integrity": "sha512-EXVmfDqdzUJOYukC9rBCs4TYd93lDVAL6TxegnV0+3U4cBxWxhbyt1bOm5u1ox+0MZZjamBFU/NSTLTtex2uwQ==", + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", From 345c4b8b147d418fb087d093d621b3935951d881 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 15 Feb 2024 15:55:58 +0000 Subject: [PATCH 05/39] feat: use pin-input on sign in --- .../2fa/enable-authenticator-app-dialog.tsx | 56 ++++++------ apps/web/src/components/forms/signin.tsx | 45 ++++++++-- packages/ui/primitives/pin-input.tsx | 85 +++++++++++++++++++ packages/ui/styles/theme.css | 22 +++++ 4 files changed, 176 insertions(+), 32 deletions(-) create mode 100644 packages/ui/primitives/pin-input.tsx diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx index 7a493d5b0..fa5d223d8 100644 --- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -139,31 +139,6 @@ export const EnableAuthenticatorAppDialog = ({ } }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const onPinInputChange = ({ currentTarget: input }: any) => { - input.value = input.value.replace(/\D+/g, ''); - - if (input.value.length === 6) { - setState('loading'); - - void onEnableTwoFactorAuthenticationFormSubmit({ token: input.value }).then((success) => { - if (success) { - setState('success'); - return; - } - - setState('error'); - - setTimeout(() => { - setState('input'); - input.value = ''; - input.dispatchEvent(new Event('input')); - input.focus(); - }, 500); - }); - } - }; - const onCompleteClick = () => { flushSync(() => { onOpenChange(false); @@ -273,7 +248,36 @@ export const EnableAuthenticatorAppDialog = ({ Token - + { + console.log(code); + + if (code.length === 6) { + setState('loading'); + + void onEnableTwoFactorAuthenticationFormSubmit({ token: code }).then( + (success) => { + if (success) { + setState('success'); + return; + } + + setState('error'); + + setTimeout(() => { + setState('input'); + input.value = ''; + input.dispatchEvent(new Event('input')); + input.focus(); + }, 500); + }, + ); + } + }} + autoFocus + /> diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index ec690a568..b182d5d76 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -31,6 +31,7 @@ import { } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; import { PasswordInput } from '@documenso/ui/primitives/password-input'; +import { PinInput, type PinInputState } from '@documenso/ui/primitives/pin-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; const ERROR_MESSAGES: Partial> = { @@ -72,6 +73,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState< 'totp' | 'backup' >('totp'); + const [state, setState] = useState('input'); const form = useForm({ values: { @@ -151,18 +153,24 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign title: 'Unable to sign in', description: errorMessage ?? 'An unknown error occurred', }); - - return; } + setState('success'); + console.log(result); + if (!result?.url) { throw new Error('An unknown error occurred'); } window.location.href = result.url; } catch (err) { + form.setError('totpCode', { + message: 'invalid totp', + }); + toast({ title: 'An unknown error occurred', + variant: 'destructive', description: 'We encountered an unknown error while attempting to sign you In. Please try again later.', }); @@ -254,7 +262,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign open={isTwoFactorAuthenticationDialogOpen} onOpenChange={onCloseTwoFactorAuthenticationDialog} > - + Two-Factor Authentication @@ -265,13 +273,38 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign ( + render={({ field: _field }) => ( Authentication Token - + { + if (code.length === 6) { + setState('loading'); + form.setValue('totpCode', code); + + await form.handleSubmit(onFormSubmit)(); + + if (form.formState.isSubmitted && !form.formState.errors.totpCode) { + setState('success'); + return; + } + + setState('error'); + + setTimeout(() => { + setState('input'); + input.value = ''; + input.dispatchEvent(new Event('input')); + input.focus(); + }, 500); + } + }} + autoFocus + /> - )} /> diff --git a/packages/ui/primitives/pin-input.tsx b/packages/ui/primitives/pin-input.tsx new file mode 100644 index 000000000..b29d64376 --- /dev/null +++ b/packages/ui/primitives/pin-input.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useRef } from 'react'; + +import { CodeInput, getSegmentCssWidth } from 'rci'; +import { useIsFocused } from 'use-is-focused'; + +import { cn } from '@documenso/ui/lib/utils'; + +export type PinInputState = 'input' | 'loading' | 'error' | 'success'; +export type PinInputProps = { + id: string; + state: PinInputState; + autoFocus?: boolean; + onSubmit({ code, input }: { code: string; input: EventTarget & HTMLInputElement }): void; +}; + +const PinInput = ({ id, autoFocus, state, onSubmit }: PinInputProps) => { + const inputRef = useRef(null); + const focused = useIsFocused(inputRef); + + const width = getSegmentCssWidth('14px'); + + return ( + { + input.value = input.value.replace(/\D+/g, ''); + onSubmit({ code: input.value, input }); + }} + renderSegment={(segment) => { + const isCaret = focused && segment.state === 'cursor'; + const isSelection = focused && segment.state === 'selected'; + const isLoading = state === 'loading'; + const isSuccess = state === 'success'; + const isError = state === 'error'; + const isActive = isSuccess || isError || isSelection || isCaret; + + return ( +
+
+
+ ); + }} + /> + ); +}; + +export { PinInput }; diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css index fe7bfa087..15c6a2f8d 100644 --- a/packages/ui/styles/theme.css +++ b/packages/ui/styles/theme.css @@ -114,3 +114,25 @@ .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgb(100 116 139 / 0.5); } + +@keyframes blink-caret { + 50% { + background: transparent; + } +} + +@keyframes shake { + 25% { + transform: translateX(10px); + } + 75% { + transform: translateX(-10px); + } +} + +@keyframes pulse-border { + 50% { + border-color: var(--segment-color); + box-shadow: var(--segment-color) 0 0 0 1px; + } +} From 94eee8b913b027cec90429123876c825283252ef Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 16 Feb 2024 20:49:52 +0000 Subject: [PATCH 06/39] chore: change font family --- packages/ui/primitives/pin-input.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/ui/primitives/pin-input.tsx b/packages/ui/primitives/pin-input.tsx index b29d64376..9f579288c 100644 --- a/packages/ui/primitives/pin-input.tsx +++ b/packages/ui/primitives/pin-input.tsx @@ -30,13 +30,12 @@ const PinInput = ({ id, autoFocus, state, onSubmit }: PinInputProps) => { inputClassName="caret-transparent selection:bg-transparent ring:ring-2" autoFocus={autoFocus} length={6} - fontFamily="Inter" - fontSize="36px" + fontSize="30px" readOnly={state !== 'input'} disabled={state === 'loading'} inputRef={inputRef} padding={'14px'} - spacing={'18px'} + spacing={'24px'} spellCheck={false} inputMode="numeric" pattern="[0-9]*" @@ -69,8 +68,7 @@ const PinInput = ({ id, autoFocus, state, onSubmit }: PinInputProps) => { >
Date: Fri, 16 Feb 2024 21:20:16 +0000 Subject: [PATCH 07/39] chore: use token input on enable 2fa --- .../2fa/enable-authenticator-app-dialog.tsx | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx index fa5d223d8..41a5ca573 100644 --- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -130,6 +130,10 @@ export const EnableAuthenticatorAppDialog = ({ return enabled2fa; } catch (_err) { + enableTwoFactorAuthenticationForm.setError('token', { + message: 'Unable to setup two-factor authentication', + }); + toast({ title: 'Unable to setup two-factor authentication', description: @@ -251,40 +255,41 @@ export const EnableAuthenticatorAppDialog = ({ { - console.log(code); - + onSubmit={async ({ code, input }) => { if (code.length === 6) { setState('loading'); + enableTwoFactorAuthenticationForm.setValue('token', code); - void onEnableTwoFactorAuthenticationFormSubmit({ token: code }).then( - (success) => { - if (success) { - setState('success'); - return; - } + await enableTwoFactorAuthenticationForm.handleSubmit( + onEnableTwoFactorAuthenticationFormSubmit, + )(); - setState('error'); + if ( + enableTwoFactorAuthenticationForm.formState.isSubmitted && + !enableTwoFactorAuthenticationForm.formState.errors.totpCode + ) { + setState('success'); + return; + } - setTimeout(() => { - setState('input'); - input.value = ''; - input.dispatchEvent(new Event('input')); - input.focus(); - }, 500); - }, - ); + setState('error'); + + setTimeout(() => { + setState('input'); + input.value = ''; + input.dispatchEvent(new Event('input')); + input.focus(); + }, 500); } }} autoFocus /> - )} /> - {/* + @@ -292,7 +297,7 @@ export const EnableAuthenticatorAppDialog = ({ - */} + )) From 80c03fcf3f096c645ebd7b5d85191a9816e54068 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 29 Apr 2024 04:28:13 +0530 Subject: [PATCH 08/39] feat: show time in documents table --- apps/web/src/app/(dashboard)/documents/data-table.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index fa02a1ae2..d86e2940d 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -3,6 +3,7 @@ import { useTransition } from 'react'; import { Loader } from 'lucide-react'; +import { DateTime } from 'luxon'; import { useSession } from 'next-auth/react'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; @@ -62,7 +63,9 @@ export const DocumentsDataTable = ({ { header: 'Created', accessorKey: 'createdAt', - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, { header: 'Title', From 345e42537acad672dc36ef4fafba012db23c38c3 Mon Sep 17 00:00:00 2001 From: Mythie Date: Mon, 29 Apr 2024 12:42:22 +1000 Subject: [PATCH 09/39] fix: include all document meta when using the public api --- packages/api/v1/implementation.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 8ee0350bd..253803fc8 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -229,6 +229,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { requestMetadata: extractNextApiRequestMetadata(args.req), }); + await upsertDocumentMeta({ + documentId: document.id, + userId: user.id, + ...body.meta, + requestMetadata: extractNextApiRequestMetadata(args.req), + }); + const recipients = await setRecipientsForDocument({ userId: user.id, teamId: team?.id, @@ -324,10 +331,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { await upsertDocumentMeta({ documentId: document.id, userId: user.id, - subject: body.meta.subject, - message: body.meta.message, - dateFormat: body.meta.dateFormat, - timezone: body.meta.timezone, + ...body.meta, requestMetadata: extractNextApiRequestMetadata(args.req), }); } From 97d334a1da63b8e0284d2697f914631fa64671eb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 29 Apr 2024 20:15:40 +0700 Subject: [PATCH 10/39] fix: force users to have a Stripe customer on sign in --- apps/web/src/pages/api/auth/[...nextauth].ts | 32 ++++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts index 365b6ec40..5217f4f8b 100644 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ b/apps/web/src/pages/api/auth/[...nextauth].ts @@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import NextAuth from 'next-auth'; +import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { prisma } from '@documenso/prisma'; @@ -18,15 +20,27 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) { error: '/signin', }, events: { - signIn: async ({ user }) => { - await prisma.userSecurityAuditLog.create({ - data: { - userId: user.id, - ipAddress, - userAgent, - type: UserSecurityAuditLogType.SIGN_IN, - }, - }); + signIn: async ({ user: { id: userId } }) => { + const [user] = await Promise.all([ + await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }), + await prisma.userSecurityAuditLog.create({ + data: { + userId: userId, + ipAddress, + userAgent, + type: UserSecurityAuditLogType.SIGN_IN, + }, + }), + ]); + + // Create the Stripe customer and attach it to the user if it doesn't exist. + if (user.customerId === null && IS_BILLING_ENABLED()) { + await getStripeCustomerByUser(user); + } }, signOut: async ({ token }) => { const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id; From 0e16a86e74702986fe4e3146a775db9293617795 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Tue, 30 Apr 2024 11:55:01 +0530 Subject: [PATCH 11/39] chore: updated dark mode text --- .../(unauthenticated)/articles/signature-disclosure/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(unauthenticated)/articles/signature-disclosure/page.tsx b/apps/web/src/app/(unauthenticated)/articles/signature-disclosure/page.tsx index 878332f35..c56f53702 100644 --- a/apps/web/src/app/(unauthenticated)/articles/signature-disclosure/page.tsx +++ b/apps/web/src/app/(unauthenticated)/articles/signature-disclosure/page.tsx @@ -5,7 +5,7 @@ import { Button } from '@documenso/ui/primitives/button'; export default function SignatureDisclosure() { return (
-
+

Electronic Signature Disclosure

Welcome

From 8622e688534ffc374aee85715625a12262a38c20 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 30 Apr 2024 15:50:22 +0700 Subject: [PATCH 12/39] fix: add logging --- apps/web/src/pages/api/auth/[...nextauth].ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts index 5217f4f8b..04f30ef45 100644 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ b/apps/web/src/pages/api/auth/[...nextauth].ts @@ -39,7 +39,9 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) { // Create the Stripe customer and attach it to the user if it doesn't exist. if (user.customerId === null && IS_BILLING_ENABLED()) { - await getStripeCustomerByUser(user); + await getStripeCustomerByUser(user).catch((err) => { + console.error(err); + }); } }, signOut: async ({ token }) => { From cfec366c1af9db7e602f0d1e4e9f1df99d47a0b9 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 30 Apr 2024 15:54:24 +0700 Subject: [PATCH 13/39] fix: refactor --- apps/web/src/pages/api/auth/[...nextauth].ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts index 04f30ef45..31f6e9ea3 100644 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ b/apps/web/src/pages/api/auth/[...nextauth].ts @@ -29,7 +29,7 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) { }), await prisma.userSecurityAuditLog.create({ data: { - userId: userId, + userId, ipAddress, userAgent, type: UserSecurityAuditLogType.SIGN_IN, From 6974a76ed48611fd9d202148283006c0e6f45d81 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Tue, 30 Apr 2024 18:47:49 +0530 Subject: [PATCH 14/39] chore: fix button styling --- .../template-flow/add-template-placeholder-recipients.tsx | 1 + 1 file changed, 1 insertion(+) 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 e415f1aac..d285fbe44 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -282,6 +282,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ + -
- - -
-
- ))} -
- - - - - - - - + + + + +
); diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index b1e069e35..ee8bf5996 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -19,7 +19,7 @@ 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 { 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'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; @@ -286,7 +286,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`; - const document = await createDocumentFromTemplate({ + const document = await createDocumentFromTemplateLegacy({ templateId, userId: user.id, teamId: team?.id, diff --git a/packages/app-tests/e2e/templates/manage-templates.spec.ts b/packages/app-tests/e2e/templates/manage-templates.spec.ts index a298d1e38..7d75c4f65 100644 --- a/packages/app-tests/e2e/templates/manage-templates.spec.ts +++ b/packages/app-tests/e2e/templates/manage-templates.spec.ts @@ -189,7 +189,14 @@ test('[TEMPLATES]: use template', async ({ page }) => { // Use personal template. await page.getByRole('button', { name: 'Use Template' }).click(); - await page.getByRole('button', { name: 'Create Document' }).click(); + + // Enter template values. + await page.getByPlaceholder('recipient.1@documenso.com').click(); + await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email); + await page.getByPlaceholder('Recipient 1').click(); + await page.getByPlaceholder('Recipient 1').fill('name'); + + await page.getByRole('button', { name: 'Create as draft' }).click(); await page.waitForURL(/documents/); await page.getByRole('main').getByRole('link', { name: 'Documents' }).click(); await page.waitForURL('/documents'); @@ -200,7 +207,14 @@ test('[TEMPLATES]: use template', async ({ page }) => { // Use team template. await page.getByRole('button', { name: 'Use Template' }).click(); - await page.getByRole('button', { name: 'Create Document' }).click(); + + // Enter template values. + await page.getByPlaceholder('recipient.1@documenso.com').click(); + await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email); + await page.getByPlaceholder('Recipient 1').click(); + await page.getByPlaceholder('Recipient 1').fill('name'); + + await page.getByRole('button', { name: 'Create as draft' }).click(); await page.waitForURL(/\/t\/.+\/documents/); await page.getByRole('main').getByRole('link', { name: 'Documents' }).click(); await page.waitForURL(`/t/${team.url}/documents`); diff --git a/packages/lib/constants/template.ts b/packages/lib/constants/template.ts new file mode 100644 index 000000000..80dee97cf --- /dev/null +++ b/packages/lib/constants/template.ts @@ -0,0 +1 @@ +export const TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i; diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index 120df5ed6..b48e45d54 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -1,4 +1,5 @@ import { TRPCError } from '@trpc/server'; +import { match } from 'ts-pattern'; import { z } from 'zod'; import { TRPCClientError } from '@documenso/trpc/client'; @@ -149,4 +150,24 @@ export class AppError extends Error { return null; } } + + static toRestAPIError(err: unknown): { + status: 400 | 401 | 404 | 500; + body: { message: string }; + } { + const error = AppError.parseError(err); + + const status = match(error.code) + .with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => 400 as const) + .with(AppErrorCode.UNAUTHORIZED, () => 401 as const) + .with(AppErrorCode.NOT_FOUND, () => 404 as const) + .otherwise(() => 500 as const); + + return { + status, + body: { + message: status !== 500 ? error.message : 'Something went wrong', + }, + }; + } } 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 new file mode 100644 index 000000000..fadbae4c3 --- /dev/null +++ b/packages/lib/server-only/template/create-document-from-template-legacy.ts @@ -0,0 +1,144 @@ +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import type { RecipientRole } from '@documenso/prisma/client'; + +export type CreateDocumentFromTemplateLegacyOptions = { + templateId: number; + userId: number; + teamId?: number; + recipients?: { + name?: string; + email: string; + role?: RecipientRole; + }[]; +}; + +/** + * Legacy server function for /api/v1 + */ +export const createDocumentFromTemplateLegacy = async ({ + templateId, + userId, + teamId, + recipients, +}: CreateDocumentFromTemplateLegacyOptions) => { + const template = await prisma.template.findUnique({ + where: { + id: templateId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + include: { + Recipient: true, + Field: true, + templateDocumentData: true, + }, + }); + + if (!template) { + throw new Error('Template not found.'); + } + + const documentData = await prisma.documentData.create({ + data: { + type: template.templateDocumentData.type, + data: template.templateDocumentData.data, + initialData: template.templateDocumentData.initialData, + }, + }); + + const document = await prisma.document.create({ + data: { + userId, + teamId: template.teamId, + title: template.title, + documentDataId: documentData.id, + Recipient: { + create: template.Recipient.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + role: recipient.role, + token: nanoid(), + })), + }, + }, + + include: { + Recipient: { + orderBy: { + id: 'asc', + }, + }, + documentData: true, + }, + }); + + await prisma.field.createMany({ + data: template.Field.map((field) => { + const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId); + + const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email); + + if (!documentRecipient) { + throw new Error('Recipient not found.'); + } + + return { + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + customText: field.customText, + inserted: field.inserted, + documentId: document.id, + recipientId: documentRecipient.id, + }; + }), + }); + + if (recipients && recipients.length > 0) { + document.Recipient = await Promise.all( + recipients.map(async (recipient, index) => { + const existingRecipient = document.Recipient.at(index); + + return await prisma.recipient.upsert({ + where: { + documentId_email: { + documentId: document.id, + email: existingRecipient?.email ?? recipient.email, + }, + }, + update: { + name: recipient.name, + email: recipient.email, + role: recipient.role, + }, + create: { + documentId: document.id, + email: recipient.email, + name: recipient.name, + role: recipient.role, + token: nanoid(), + }, + }); + }), + ); + } + + return document; +}; 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 79a3f6f25..7cd098d6d 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -1,16 +1,29 @@ import { nanoid } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; -import type { RecipientRole } from '@documenso/prisma/client'; +import type { Field } from '@documenso/prisma/client'; +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 type { RequestMetadata } from '../../universal/extract-request-metadata'; +import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; +import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; + +type FinalRecipient = Pick & { + templateRecipientId: number; + fields: Field[]; +}; export type CreateDocumentFromTemplateOptions = { templateId: number; userId: number; teamId?: number; - recipients?: { + recipients: { + id: number; name?: string; email: string; - role?: RecipientRole; }[]; + requestMetadata?: RequestMetadata; }; export const createDocumentFromTemplate = async ({ @@ -18,7 +31,14 @@ export const createDocumentFromTemplate = async ({ userId, teamId, recipients, + requestMetadata, }: CreateDocumentFromTemplateOptions) => { + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + const template = await prisma.template.findUnique({ where: { id: templateId, @@ -39,16 +59,42 @@ export const createDocumentFromTemplate = async ({ }), }, include: { - Recipient: true, - Field: true, + Recipient: { + include: { + Field: true, + }, + }, templateDocumentData: true, }, }); if (!template) { - throw new Error('Template not found.'); + 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); + + if (!foundRecipient) { + throw new AppError( + AppErrorCode.INVALID_BODY, + `Missing template recipient with ID ${templateRecipient.id}`, + ); + } + + return { + templateRecipientId: templateRecipient.id, + fields: templateRecipient.Field, + name: foundRecipient.name ?? '', + email: foundRecipient.email, + role: templateRecipient.role, + }; + }); + const documentData = await prisma.documentData.create({ data: { type: template.templateDocumentData.type, @@ -57,85 +103,82 @@ export const createDocumentFromTemplate = async ({ }, }); - const document = await prisma.document.create({ - data: { - userId, - teamId: template.teamId, - title: template.title, - documentDataId: documentData.id, - Recipient: { - create: template.Recipient.map((recipient) => ({ - email: recipient.email, - name: recipient.name, - role: recipient.role, - token: nanoid(), - })), - }, - }, - - include: { - Recipient: { - orderBy: { - id: 'asc', + return await prisma.$transaction(async (tx) => { + const document = await tx.document.create({ + data: { + userId, + teamId: template.teamId, + title: template.title, + documentDataId: documentData.id, + Recipient: { + createMany: { + data: finalRecipients.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + role: recipient.role, + token: nanoid(), + })), + }, }, }, - documentData: true, - }, - }); + include: { + Recipient: { + orderBy: { + id: 'asc', + }, + }, + documentData: true, + }, + }); - await prisma.field.createMany({ - data: template.Field.map((field) => { - const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId); + let fieldsToCreate: Omit[] = []; - const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email); + Object.values(finalRecipients).forEach(({ email, fields }) => { + const recipient = document.Recipient.find((recipient) => recipient.email === email); - if (!documentRecipient) { + if (!recipient) { throw new Error('Recipient not found.'); } - return { - type: field.type, - page: field.page, - positionX: field.positionX, - positionY: field.positionY, - width: field.width, - height: field.height, - customText: field.customText, - inserted: field.inserted, + fieldsToCreate = fieldsToCreate.concat( + fields.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: fieldsToCreate, + }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED, documentId: document.id, - recipientId: documentRecipient.id, - }; - }), - }); - - if (recipients && recipients.length > 0) { - document.Recipient = await Promise.all( - recipients.map(async (recipient, index) => { - const existingRecipient = document.Recipient.at(index); - - return await prisma.recipient.upsert({ - where: { - documentId_email: { - documentId: document.id, - email: existingRecipient?.email ?? recipient.email, - }, - }, - update: { - name: recipient.name, - email: recipient.email, - role: recipient.role, - }, - create: { - documentId: document.id, - email: recipient.email, - name: recipient.name, - role: recipient.role, - token: nanoid(), - }, - }); + user, + requestMetadata, + data: { + title: document.title, + }, }), - ); - } + }); - return document; + await triggerWebhook({ + event: WebhookTriggerEvents.DOCUMENT_CREATED, + data: document, + userId, + teamId, + }); + + return document; + }); }; diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 4ed567b2b..3cca69548 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -1,10 +1,14 @@ import { TRPCError } from '@trpc/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { createTemplate } from '@documenso/lib/server-only/template/create-template'; import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template'; +import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import type { Document } from '@documenso/prisma/client'; import { authenticatedProcedure, router } from '../trpc'; import { @@ -49,19 +53,34 @@ export const templateRouter = router({ throw new Error('You have reached your document limit.'); } - return await createDocumentFromTemplate({ + const requestMetadata = extractNextApiRequestMetadata(ctx.req); + + let document: Document = await createDocumentFromTemplate({ templateId, teamId, userId: ctx.user.id, recipients: input.recipients, + requestMetadata, }); + + if (input.sendDocument) { + document = await sendDocument({ + documentId: document.id, + userId: ctx.user.id, + teamId, + requestMetadata, + }).catch((err) => { + console.error(err); + + throw new AppError('DOCUMENT_SEND_FAILED'); + }); + } + + return document; } catch (err) { console.error(err); - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'We were unable to create this document. Please try again later.', - }); + throw AppError.parseErrorToTRPCError(err); } }), diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 3f16d7b39..ce1489ac3 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -1,7 +1,5 @@ import { z } from 'zod'; -import { RecipientRole } from '@documenso/prisma/client'; - export const ZCreateTemplateMutationSchema = z.object({ title: z.string().min(1).trim(), teamId: z.number().optional(), @@ -14,12 +12,16 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({ recipients: z .array( z.object({ + id: z.number(), email: z.string().email(), - name: z.string(), - role: z.nativeEnum(RecipientRole), + name: z.string().optional(), }), ) - .optional(), + .refine((recipients) => { + const emails = recipients.map((signer) => signer.email); + return new Set(emails).size === emails.length; + }, 'Recipients must have unique emails'), + sendDocument: z.boolean().optional(), }); export const ZDuplicateTemplateMutationSchema = z.object({ 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 d285fbe44..cd48158c4 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -103,6 +103,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ appendSigner({ formId: nanoid(12), name: `Recipient ${placeholderRecipientCount}`, + // Update TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX if this is ever changed. email: `recipient.${placeholderRecipientCount}@documenso.com`, role: RecipientRole.SIGNER, }); From e50ccca766c4b1f48bf9fbace78c7482d353bd95 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 7 May 2024 17:22:24 +0700 Subject: [PATCH 23/39] fix: allow template recipients to be filled (#1148) ## Description Update the template flow to allow for entering recipient placeholder emails and names ## Changes Made - General refactoring - Added advanced recipient settings for future usage --- .../templates/[id]/edit-template.tsx | 2 + .../templates/[id]/template-page-view.tsx | 2 +- .../templates/use-template-dialog.tsx | 39 +- packages/lib/constants/template.ts | 3 +- .../recipient-action-auth-select.tsx | 80 ++++ .../recipient/recipient-role-select.tsx | 97 +++++ .../primitives/document-flow/add-signers.tsx | 166 +------- .../add-template-placeholder-recipients.tsx | 373 +++++++++--------- ...d-template-placeholder-recipients.types.ts | 6 + 9 files changed, 418 insertions(+), 350 deletions(-) create mode 100644 packages/ui/components/recipient/recipient-action-auth-select.tsx create mode 100644 packages/ui/components/recipient/recipient-role-select.tsx 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 f8c7f9a43..d9da6c27c 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx @@ -141,6 +141,8 @@ export const EditTemplateForm = ({ recipients={recipients} fields={fields} onSubmit={onAddTemplatePlaceholderFormSubmit} + // Todo: Add when we setup template settings. + isTemplateOwnerEnterprise={false} /> ({ @@ -98,20 +105,18 @@ export function UseTemplateDialog({ defaultValues: { sendDocument: false, recipients: recipients.map((recipient) => { - const isRecipientPlaceholder = recipient.email.match(TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX); + const isRecipientEmailPlaceholder = recipient.email.match( + TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX, + ); - if (isRecipientPlaceholder) { - return { - id: recipient.id, - name: '', - email: '', - }; - } + const isRecipientNamePlaceholder = recipient.name.match( + TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX, + ); return { id: recipient.id, - name: recipient.name, - email: recipient.email, + name: !isRecipientNamePlaceholder ? recipient.name : '', + email: !isRecipientEmailPlaceholder ? recipient.email : '', }; }), }, @@ -158,8 +163,14 @@ export function UseTemplateDialog({ name: 'recipients', }); + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + return ( - + !form.formState.isSubmitting && setOpen(value)}> - + + ))} + -
- - -
- - ))} - - + - +
+ -
- - -
+ +
+ + {!alwaysShowAdvancedSettings && isTemplateOwnerEnterprise && ( +
+ setShowAdvancedSettings(Boolean(value))} + /> + + +
+ )} + + diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts index d2ffc090b..18df2d33b 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts @@ -1,5 +1,8 @@ import { z } from 'zod'; +import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; + +import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types'; import { RecipientRole } from '.prisma/client'; export const ZAddTemplatePlacholderRecipientsFormSchema = z @@ -11,6 +14,9 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z email: z.string().min(1).email(), name: z.string(), role: z.nativeEnum(RecipientRole), + actionAuth: ZMapNegativeOneToUndefinedSchema.pipe( + ZRecipientActionAuthTypesSchema.optional(), + ), }), ), }) From 5d5d0210fa22fdc16f4c1869d7de6139a225fe33 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Wed, 8 May 2024 10:22:26 +0530 Subject: [PATCH 24/39] chore: update github actions (#1085) **Description:** This PR updates and adds a new action to assign `status: assigned` label --------- Signed-off-by: Adithya Krishna --- .github/workflows/ci.yml | 2 +- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/e2e-tests.yml | 2 +- .github/workflows/issue-assignee-check.yml | 2 +- .github/workflows/issue-labeler.yml | 25 ++++++++++++++++++++++ .github/workflows/pr-review-reminder.yml | 4 ++-- .github/workflows/stale.yml | 2 +- 7 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/issue-labeler.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bebca8e85..6101b0180 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Cache Docker layers - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 314dc7b7b..b948e560d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -33,9 +33,9 @@ jobs: - uses: ./.github/actions/cache-build - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 12a7d9521..22705c2d6 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -33,7 +33,7 @@ jobs: - name: Run Playwright tests run: npm run ci - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: test-results diff --git a/.github/workflows/issue-assignee-check.yml b/.github/workflows/issue-assignee-check.yml index dbd321509..b601a8dc3 100644 --- a/.github/workflows/issue-assignee-check.yml +++ b/.github/workflows/issue-assignee-check.yml @@ -27,7 +27,7 @@ jobs: - name: Check Assigned User's Issue Count id: parse-comment - uses: actions/github-script@v5 + uses: actions/github-script@v6 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml new file mode 100644 index 000000000..34d7a478f --- /dev/null +++ b/.github/workflows/issue-labeler.yml @@ -0,0 +1,25 @@ +name: Auto Label Assigned Issues + +on: + issues: + types: [assigned] + +jobs: + label-when-assigned: + runs-on: ubuntu-latest + steps: + - name: Label issue + uses: actions/github-script@v6 + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + const issue = context.issue; + // To run only on issues and not on PR + if (github.context.payload.issue.pull_request === undefined) { + const labelResponse = await github.rest.issues.addLabels({ + owner: issue.owner, + repo: issue.repo, + issue_number: issue.number, + labels: ['status: assigned'] + }); + } diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml index 78f927e61..c81d9a34e 100644 --- a/.github/workflows/pr-review-reminder.yml +++ b/.github/workflows/pr-review-reminder.yml @@ -2,14 +2,14 @@ name: 'PR Review Reminder' on: pull_request: - types: ['opened', 'reopened', 'ready_for_review', 'review_requested'] + types: ['opened', 'ready_for_review'] permissions: pull-requests: write jobs: checkPRs: - if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested') + if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'ready_for_review') runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 3e829d24b..a18e33f87 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -12,7 +12,7 @@ jobs: pull-requests: write steps: - - uses: actions/stale@v4 + - uses: actions/stale@v5 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-pr-stale: 90 From 2ba0f48c6186af435aa8948c9a00a89b7013e0da Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 8 May 2024 08:03:21 +0300 Subject: [PATCH 25/39] fix: unauthorized access error api tokens page team (#1134) --- .../t/[teamUrl]/settings/tokens/page.tsx | 22 ++++++++++++++++++- .../public-api/get-all-team-tokens.ts | 8 ++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/tokens/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/tokens/page.tsx index eedae29d1..7602ac70f 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/settings/tokens/page.tsx +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/tokens/page.tsx @@ -1,7 +1,10 @@ import { DateTime } from 'luxon'; +import { match } from 'ts-pattern'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import type { GetTeamTokensResponse } from '@documenso/lib/server-only/public-api/get-all-team-tokens'; import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens'; import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { Button } from '@documenso/ui/primitives/button'; @@ -23,7 +26,24 @@ export default async function ApiTokensPage({ params }: ApiTokensPageProps) { const team = await getTeamByUrl({ userId: user.id, teamUrl }); - const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }); + let tokens: GetTeamTokensResponse | null = null; + + try { + tokens = await getTeamTokens({ userId: user.id, teamId: team.id }); + } catch (err) { + const error = AppError.parseError(err); + + return ( +
+

API Tokens

+

+ {match(error.code) + .with(AppErrorCode.UNAUTHORIZED, () => error.message) + .otherwise(() => 'Something went wrong.')} +

+
+ ); + } return (
diff --git a/packages/lib/server-only/public-api/get-all-team-tokens.ts b/packages/lib/server-only/public-api/get-all-team-tokens.ts index 86c13ed1d..35285336b 100644 --- a/packages/lib/server-only/public-api/get-all-team-tokens.ts +++ b/packages/lib/server-only/public-api/get-all-team-tokens.ts @@ -1,3 +1,4 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; import { TeamMemberRole } from '@documenso/prisma/client'; @@ -6,6 +7,8 @@ export type GetUserTokensOptions = { teamId: number; }; +export type GetTeamTokensResponse = Awaited>; + export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => { const teamMember = await prisma.teamMember.findFirst({ where: { @@ -15,7 +18,10 @@ export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => }); if (teamMember?.role !== TeamMemberRole.ADMIN) { - throw new Error('You do not have permission to view tokens for this team'); + throw new AppError( + AppErrorCode.UNAUTHORIZED, + 'You do not have the required permissions to view this page.', + ); } return await prisma.apiToken.findMany({ From cc4efddabf8f20dffcc89d3cf8de33a8a1fdd38d Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Wed, 8 May 2024 17:03:57 +0530 Subject: [PATCH 26/39] chore: updated triage label --- .github/workflows/issue-opened.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue-opened.yml b/.github/workflows/issue-opened.yml index ed9f2811a..92b559d11 100644 --- a/.github/workflows/issue-opened.yml +++ b/.github/workflows/issue-opened.yml @@ -17,5 +17,5 @@ jobs: issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - labels: ["needs triage"] + labels: ["status: triage"] }) From bbcbc56e70f4683ffb1f784fa683eee780c428a9 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 8 May 2024 19:17:47 +0530 Subject: [PATCH 27/39] feat: 12h format --- apps/web/src/app/(dashboard)/documents/data-table.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index d86e2940d..c079e0165 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -64,7 +64,10 @@ export const DocumentsDataTable = ({ header: 'Created', accessorKey: 'createdAt', cell: ({ row }) => ( - + ), }, { From 2f86bb523b21f94ac4a7af657aba2ce98fbdca7b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 10 May 2024 19:45:19 +0700 Subject: [PATCH 28/39] 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) + + + +