From 37e9db6626a7336427b4a2de4e3825a64186f948 Mon Sep 17 00:00:00 2001 From: Prajwal Kulkarni Date: Tue, 6 Feb 2024 00:40:53 +0530 Subject: [PATCH 001/174] 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 002/174] 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 003/174] 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 004/174] 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 005/174] 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 006/174] 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 007/174] 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 9b5346efef1f7cc631f57502a065d3d3951a368d Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 8 Mar 2024 14:54:18 +0000 Subject: [PATCH 008/174] chore: add test for multiple recipient --- .../e2e/pr-718-add-stepper-component.spec.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts b/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts index 6e03979c0..e482c4172 100644 --- a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts +++ b/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts @@ -73,3 +73,98 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => { // Assert document was created await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); }); + +test('should be able to create a document with multiple recipients', async ({ page }) => { + await page.goto('/signin'); + + const documentTitle = `example-${Date.now()}.pdf`; + + // Sign in + await page.getByLabel('Email').fill(TEST_USER.email); + await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password); + await page.getByRole('button', { name: 'Sign In' }).click(); + + // Upload document + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.locator('input[type=file]').evaluate((e) => { + if (e instanceof HTMLInputElement) { + e.click(); + } + }), + ]); + + await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf')); + + // Wait to be redirected to the edit page + await page.waitForURL(/\/documents\/\d+/); + + // Set title + await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible(); + + await page.getByLabel('Title').fill(documentTitle); + + await page.getByRole('button', { name: 'Continue' }).click(); + + // Add signers + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + await page.getByLabel('Email*').fill('user1@example.com'); + await page.getByLabel('Name').fill('User 1'); + + await page.getByRole('button', { name: 'Add Signer' }).click(); + + await page.getByLabel('Email*').nth(1).fill('user2@example.com'); + await page.getByLabel('Name').nth(1).fill('User 2'); + + await page.getByRole('button', { name: 'Continue' }).click(); + + // Add fields + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.getByRole('button', { name: 'User 1 Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 100, + }, + }); + + await page.getByRole('button', { name: 'Email Email' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 200, + }, + }); + + await page.getByText('User 1 (user1@example.com)').click(); + await page.getByText('User 2 (user2@example.com)').click(); + + await page.getByRole('button', { name: 'User 2 Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 500, + y: 100, + }, + }); + + await page.getByRole('button', { name: 'Email Email' }).click(); + await page.locator('canvas').click({ + position: { + x: 500, + y: 200, + }, + }); + + await page.getByRole('button', { name: 'Continue' }).click(); + + // Add subject and send + await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible(); + await page.getByRole('button', { name: 'Send' }).click(); + + await page.waitForURL('/documents'); + + // Assert document was created + await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); +}); From d0b9cee500b8262d1991a522440f482b498b09d5 Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Tue, 12 Mar 2024 18:55:59 +0530 Subject: [PATCH 009/174] feat: created the dialog file for delete of document --- .../admin/documents/[id]/delete-document-dialog.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx new file mode 100644 index 000000000..339659ccc --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx @@ -0,0 +1,11 @@ +'use client'; + +import type { Document } from '@documenso/prisma/client'; + +export type DeleteDocumentDialogProps = { + document: Document; +}; + +export const DeleteDocumentDialog = ({ document }: DeleteDocumentDialogProps) => { + return
; +}; From 884eab36eb6df985b59a9cff2225028f91f74e53 Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Tue, 12 Mar 2024 20:02:05 +0530 Subject: [PATCH 010/174] feat: adding the schema for the admin delete document mutation --- packages/trpc/server/admin-router/schema.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/trpc/server/admin-router/schema.ts b/packages/trpc/server/admin-router/schema.ts index cfedb06ba..a26d92fa6 100644 --- a/packages/trpc/server/admin-router/schema.ts +++ b/packages/trpc/server/admin-router/schema.ts @@ -48,3 +48,10 @@ export const ZAdminDeleteUserMutationSchema = z.object({ }); export type TAdminDeleteUserMutationSchema = z.infer; + +export const ZAdminDeleteDocumentMutationSchema = z.object({ + id: z.number().min(1), + userId: z.number(), +}); + +export type TAdminDeleteDocomentMutationSchema = z.infer; From c10cfbf6e15714bba98d30840ce76d64b6c98bde Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Tue, 12 Mar 2024 20:03:34 +0530 Subject: [PATCH 011/174] feat: adding the router for the delete document in the admin router --- packages/trpc/server/admin-router/router.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 5be3ad9db..7955f7a18 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server'; import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient'; import { updateUser } from '@documenso/lib/server-only/admin/update-user'; +import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; import { sealDocument } from '@documenso/lib/server-only/document/seal-document'; import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting'; import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; @@ -10,6 +11,7 @@ import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; import { adminProcedure, router } from '../trpc'; import { + ZAdminDeleteDocumentMutationSchema, ZAdminDeleteUserMutationSchema, ZAdminFindDocumentsQuerySchema, ZAdminResealDocumentMutationSchema, @@ -118,4 +120,18 @@ export const adminRouter = router({ }); } }), + deleteDocument: adminProcedure + .input(ZAdminDeleteDocumentMutationSchema) + .mutation(async ({ input }) => { + const { id, userId } = input; + try { + return await deleteDocument({ id, userId }); + } catch (err) { + console.log(err); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to delete the specified document. Please try again.', + }); + } + }), }); From d8911ee97b30c412b84d11a079adedb0f7cb5f55 Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Tue, 12 Mar 2024 20:16:48 +0530 Subject: [PATCH 012/174] feat: added the dialog delete file --- .../documents/[id]/delete-document-dialog.tsx | 113 +++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx index 339659ccc..aacb49d65 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx @@ -1,11 +1,122 @@ 'use client'; +import { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + import type { Document } from '@documenso/prisma/client'; +import { TRPCClientError } from '@documenso/trpc/client'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; export type DeleteDocumentDialogProps = { document: Document; }; export const DeleteDocumentDialog = ({ document }: DeleteDocumentDialogProps) => { - return
; + const router = useRouter(); + const { toast } = useToast(); + const [reason, setReason] = useState(''); + const { mutateAsync: deleteDocument, isLoading: isDeletingDocument } = + trpc.admin.deleteDocument.useMutation(); + + const handleDeleteDocument = async () => { + try { + await deleteDocument({ id: 1, userId: 1 }); + toast({ + title: 'Document deleted', + description: 'The Document has been deleted successfully.', + duration: 5000, + }); + router.push('admin/documents'); + } catch (err) { + if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { + toast({ + title: 'An error occurred', + description: err.message, + variant: 'destructive', + }); + } else { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + err.message ?? + 'We encountered an unknown error while attempting to delete your document. Please try again later.', + }); + } + } + }; + + return ( +
+
+ +
+ Delete Account + + Delete the users account and all its contents. This action is irreversible and will + cancel their subscription, so proceed with caution. + +
+ +
+ + + + + + + + Delete Account + + + + This action is not reversible. Please be certain. + + + + +
+ To confirm, please the reason + + setReason(e.target.value)} + /> +
+ + + + +
+
+
+
+
+
+ ); }; From 3b65447b0ff21623ae848e32cc243ad9208df79b Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Tue, 12 Mar 2024 20:38:11 +0530 Subject: [PATCH 013/174] feat: updating the dialog and page of document --- .../documents/[id]/delete-document-dialog.tsx | 15 +++++++-------- .../app/(dashboard)/admin/documents/[id]/page.tsx | 5 +++++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx index aacb49d65..c38ca03e2 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx @@ -34,7 +34,7 @@ export const DeleteDocumentDialog = ({ document }: DeleteDocumentDialogProps) => const handleDeleteDocument = async () => { try { - await deleteDocument({ id: 1, userId: 1 }); + await deleteDocument({ id: document.id, userId: document.userId }); toast({ title: 'Document deleted', description: 'The Document has been deleted successfully.', @@ -68,22 +68,21 @@ export const DeleteDocumentDialog = ({ document }: DeleteDocumentDialogProps) => variant="neutral" >
- Delete Account + Delete Document - Delete the users account and all its contents. This action is irreversible and will - cancel their subscription, so proceed with caution. + Delete the document. This action is irreversible so proceed with caution.
- + - Delete Account + Delete Document @@ -93,7 +92,7 @@ export const DeleteDocumentDialog = ({ document }: DeleteDocumentDialogProps) =>
- To confirm, please the reason + To confirm, please enter the reason loading={isDeletingDocument} variant="destructive" > - {isDeletingDocument ? 'Deleting account...' : 'Delete Account'} + {isDeletingDocument ? 'Deleting document...' : 'Delete Document'} diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx index a22345457..5135a6236 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx @@ -13,6 +13,7 @@ import { DocumentStatus } from '~/components/formatter/document-status'; import { LocaleDate } from '~/components/formatter/locale-date'; import { AdminActions } from './admin-actions'; +import { DeleteDocumentDialog } from './delete-document-dialog'; import { RecipientItem } from './recipient-item'; type AdminDocumentDetailsPageProps = { @@ -81,6 +82,10 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument ))}
+ +
+ + {document && }
); } From a8413fa031d34676163ecf300e6614ca503015ea Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Tue, 12 Mar 2024 20:42:13 +0530 Subject: [PATCH 014/174] feat: disabled reason condition is updated on the dialog form --- .../(dashboard)/admin/documents/[id]/delete-document-dialog.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx index c38ca03e2..80715f220 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx @@ -107,6 +107,7 @@ export const DeleteDocumentDialog = ({ document }: DeleteDocumentDialogProps) => onClick={handleDeleteDocument} loading={isDeletingDocument} variant="destructive" + disabled={!reason} > {isDeletingDocument ? 'Deleting document...' : 'Delete Document'} From 4dc9e1295b9fbbc2c14e6142add8a7f2a7b8def6 Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Tue, 12 Mar 2024 21:15:17 +0530 Subject: [PATCH 015/174] feat: added the templates for the delete of the documents from the admin --- .../template-document-delete.tsx | 35 ++++++++++ packages/email/templates/document-delete.tsx | 69 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 packages/email/template-components/template-document-delete.tsx create mode 100644 packages/email/templates/document-delete.tsx diff --git a/packages/email/template-components/template-document-delete.tsx b/packages/email/template-components/template-document-delete.tsx new file mode 100644 index 000000000..99cbe9706 --- /dev/null +++ b/packages/email/template-components/template-document-delete.tsx @@ -0,0 +1,35 @@ +import { Section, Text } from '../components'; +import { TemplateDocumentImage } from './template-document-image'; + +export interface TemplateDocumentDeleteProps { + inviterName: string; + inviterEmail: string; + reason: string; + documentName: string; + assetBaseUrl: string; +} + +export const TemplateDocumentDelete = ({ + reason, + documentName, + assetBaseUrl, +}: TemplateDocumentDeleteProps) => { + return ( + <> + + +
+ + Your document has been deleted +
"{documentName}" +
+ + Reason as below +
"{reason}" +
+
+ + ); +}; + +export default TemplateDocumentDelete; diff --git a/packages/email/templates/document-delete.tsx b/packages/email/templates/document-delete.tsx new file mode 100644 index 000000000..79e40e3d8 --- /dev/null +++ b/packages/email/templates/document-delete.tsx @@ -0,0 +1,69 @@ +import config from '@documenso/tailwind-config'; + +import { Body, Container, Head, Hr, Html, Img, Preview, Section, Tailwind } from '../components'; +import { + TemplateDocumentDelete, + type TemplateDocumentDeleteProps, +} from '../template-components/template-document-delete'; +import { TemplateFooter } from '../template-components/template-footer'; + +export type DocumentDeleteEmailTemplateProps = Partial; + +export const DocumentDeleteTemplate = ({ + inviterName = 'Lucas Smith', + inviterEmail = 'lucas@documenso.com', + documentName = 'Open Source Pledge.pdf', + assetBaseUrl = 'http://localhost:3002', + reason = 'Unknown', +}: DocumentDeleteEmailTemplateProps) => { + const previewText = `${inviterName} has cancelled the document ${documentName}, you don't need to sign it anymore.`; + + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + + + {previewText} + + +
+ +
+ Documenso Logo + +
+
+ +
+ + + + +
+ +
+ + ); +}; + +export default DocumentDeleteTemplate; From 3fb57c877ef0f71910f9b86e22641c1334f778f0 Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Wed, 13 Mar 2024 10:54:53 +0530 Subject: [PATCH 016/174] feat: send delete email is added --- .../template-document-delete.tsx | 2 - packages/email/templates/document-delete.tsx | 10 ++-- .../server-only/document/send-delete-email.ts | 51 +++++++++++++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 packages/lib/server-only/document/send-delete-email.ts diff --git a/packages/email/template-components/template-document-delete.tsx b/packages/email/template-components/template-document-delete.tsx index 99cbe9706..b87b4d5bd 100644 --- a/packages/email/template-components/template-document-delete.tsx +++ b/packages/email/template-components/template-document-delete.tsx @@ -2,8 +2,6 @@ import { Section, Text } from '../components'; import { TemplateDocumentImage } from './template-document-image'; export interface TemplateDocumentDeleteProps { - inviterName: string; - inviterEmail: string; reason: string; documentName: string; assetBaseUrl: string; diff --git a/packages/email/templates/document-delete.tsx b/packages/email/templates/document-delete.tsx index 79e40e3d8..87e6b6e9a 100644 --- a/packages/email/templates/document-delete.tsx +++ b/packages/email/templates/document-delete.tsx @@ -9,14 +9,12 @@ import { TemplateFooter } from '../template-components/template-footer'; export type DocumentDeleteEmailTemplateProps = Partial; -export const DocumentDeleteTemplate = ({ - inviterName = 'Lucas Smith', - inviterEmail = 'lucas@documenso.com', +export const DocumentDeleteEmailTemplate = ({ documentName = 'Open Source Pledge.pdf', assetBaseUrl = 'http://localhost:3002', reason = 'Unknown', }: DocumentDeleteEmailTemplateProps) => { - const previewText = `${inviterName} has cancelled the document ${documentName}, you don't need to sign it anymore.`; + const previewText = `Admin has deleted your document ${documentName}.`; const getAssetUrl = (path: string) => { return new URL(path, assetBaseUrl).toString(); @@ -45,11 +43,9 @@ export const DocumentDeleteTemplate = ({ className="mb-4 h-6" /> @@ -66,4 +62,4 @@ export const DocumentDeleteTemplate = ({ ); }; -export default DocumentDeleteTemplate; +export default DocumentDeleteEmailTemplate; diff --git a/packages/lib/server-only/document/send-delete-email.ts b/packages/lib/server-only/document/send-delete-email.ts new file mode 100644 index 000000000..8594a1c3c --- /dev/null +++ b/packages/lib/server-only/document/send-delete-email.ts @@ -0,0 +1,51 @@ +import { createElement } from 'react'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import { DocumentDeleteEmailTemplate } from '@documenso/email/templates/document-delete'; +import { prisma } from '@documenso/prisma'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; + +export interface SendDeleteEmailOptions { + documentId: number; + reason: string; +} + +export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOptions) => { + const document = await prisma.document.findFirst({ + where: { + id: documentId, + }, + include: { + User: true, + }, + }); + + if (!document) { + throw new Error('Document not found'); + } + + const { email, name } = document.User; + + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + + const template = createElement(DocumentDeleteEmailTemplate, { + documentName: document.title, + assetBaseUrl, + }); + + await mailer.sendMail({ + to: { + address: email, + name: name || '', + }, + from: { + name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso', + address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com', + }, + subject: 'Document Deleted!', + html: render(template), + text: render(template, { plainText: true }), + }); +}; From 487bc026f90812a3aaf1b0c22e30bbcca23327ac Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Wed, 13 Mar 2024 11:06:35 +0530 Subject: [PATCH 017/174] feat: reason is added to the component props --- packages/lib/server-only/document/send-delete-email.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/lib/server-only/document/send-delete-email.ts b/packages/lib/server-only/document/send-delete-email.ts index 8594a1c3c..4046d5f0f 100644 --- a/packages/lib/server-only/document/send-delete-email.ts +++ b/packages/lib/server-only/document/send-delete-email.ts @@ -32,6 +32,7 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt const template = createElement(DocumentDeleteEmailTemplate, { documentName: document.title, + reason, assetBaseUrl, }); From 35c1b0bceef5cf32b9b43b50ed803e4f8662a098 Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Wed, 13 Mar 2024 11:15:06 +0530 Subject: [PATCH 018/174] feat: corrected the document redirection after delete --- .../(dashboard)/admin/documents/[id]/delete-document-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx index 80715f220..2e97eabf1 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx @@ -40,7 +40,7 @@ export const DeleteDocumentDialog = ({ document }: DeleteDocumentDialogProps) => description: 'The Document has been deleted successfully.', duration: 5000, }); - router.push('admin/documents'); + router.push('/admin/documents'); } catch (err) { if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { toast({ From af6ec5df4290dadd54c0b432cda97f9b2a5de225 Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Wed, 13 Mar 2024 11:30:20 +0530 Subject: [PATCH 019/174] feat: reason is added to the email --- .../admin/documents/[id]/delete-document-dialog.tsx | 2 +- packages/trpc/server/admin-router/router.ts | 7 +++++-- packages/trpc/server/admin-router/schema.ts | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx index 2e97eabf1..7414390b0 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/delete-document-dialog.tsx @@ -34,7 +34,7 @@ export const DeleteDocumentDialog = ({ document }: DeleteDocumentDialogProps) => const handleDeleteDocument = async () => { try { - await deleteDocument({ id: document.id, userId: document.userId }); + await deleteDocument({ id: document.id, userId: document.userId, reason }); toast({ title: 'Document deleted', description: 'The Document has been deleted successfully.', diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 7955f7a18..1215f1c39 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -5,6 +5,7 @@ import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipie import { updateUser } from '@documenso/lib/server-only/admin/update-user'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; import { sealDocument } from '@documenso/lib/server-only/document/seal-document'; +import { sendDeleteEmail } from '@documenso/lib/server-only/document/send-delete-email'; import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting'; import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; @@ -123,9 +124,11 @@ export const adminRouter = router({ deleteDocument: adminProcedure .input(ZAdminDeleteDocumentMutationSchema) .mutation(async ({ input }) => { - const { id, userId } = input; + const { id, userId, reason } = input; try { - return await deleteDocument({ id, userId }); + await deleteDocument({ id, userId }); + await sendDeleteEmail({ documentId: id, reason }); + return; } catch (err) { console.log(err); throw new TRPCError({ diff --git a/packages/trpc/server/admin-router/schema.ts b/packages/trpc/server/admin-router/schema.ts index a26d92fa6..91b0df3c1 100644 --- a/packages/trpc/server/admin-router/schema.ts +++ b/packages/trpc/server/admin-router/schema.ts @@ -52,6 +52,7 @@ export type TAdminDeleteUserMutationSchema = z.infer; From 364aaa4cb6573318b258bb7a5b634b911b34b60a Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Wed, 13 Mar 2024 11:32:14 +0530 Subject: [PATCH 020/174] feat: reason label is changed --- packages/email/template-components/template-document-delete.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/email/template-components/template-document-delete.tsx b/packages/email/template-components/template-document-delete.tsx index b87b4d5bd..df8266e8b 100644 --- a/packages/email/template-components/template-document-delete.tsx +++ b/packages/email/template-components/template-document-delete.tsx @@ -22,7 +22,7 @@ export const TemplateDocumentDelete = ({
"{documentName}" - Reason as below + Reason
"{reason}"
From bba1ea81d63015af5af55156ce0285209219f8be Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Wed, 13 Mar 2024 11:40:12 +0530 Subject: [PATCH 021/174] feat: updated the condition of the delete dialog in the detail page --- apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx index 5135a6236..2257a5986 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx @@ -85,7 +85,7 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
- {document && } + {document && !document.deletedAt && }
); } From 3e15b5d7456b6849f2da3cd101b24a2ade71793b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Sebasti=C3=A1n=20Mendoza?= Date: Wed, 20 Mar 2024 15:05:17 -0500 Subject: [PATCH 022/174] feat: add sticky behavior to pricing options container --- apps/marketing/src/app/(marketing)/layout.tsx | 3 ++- apps/marketing/src/components/(marketing)/pricing-table.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/layout.tsx b/apps/marketing/src/app/(marketing)/layout.tsx index a7c599b36..75c2d177c 100644 --- a/apps/marketing/src/app/(marketing)/layout.tsx +++ b/apps/marketing/src/app/(marketing)/layout.tsx @@ -38,7 +38,8 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) { return (
{ return (
-
+
Date: Thu, 21 Mar 2024 00:48:49 +0000 Subject: [PATCH 023/174] fix: invalid datetime on graph --- ...rt copy.tsx => monthly-completed-documents-chart.tsx} | 2 +- apps/marketing/src/app/(marketing)/open/page.tsx | 9 +++++---- .../server-only/user/get-monthly-completed-document.ts | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) rename apps/marketing/src/app/(marketing)/open/{monthly-completed-documents-chart copy.tsx => monthly-completed-documents-chart.tsx} (97%) diff --git a/apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart copy.tsx b/apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx similarity index 97% rename from apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart copy.tsx rename to apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx index ce438145b..4efd31e76 100644 --- a/apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart copy.tsx +++ b/apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx @@ -47,7 +47,7 @@ export const MonthlyCompletedDocumentsChart = ({ fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} maxBarSize={60} - label="Total Users" + label="Monthly Completed Documents" /> diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index 4f2b0d857..02cf9302f 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -13,7 +13,7 @@ import { CallToAction } from '~/components/(marketing)/call-to-action'; import { BarMetric } from './bar-metrics'; import { CapTable } from './cap-table'; import { FundingRaised } from './funding-raised'; -import { MonthlyCompletedDocumentsChart } from './monthly-completed-documents-chart copy'; +import { MonthlyCompletedDocumentsChart } from './monthly-completed-documents-chart'; import { MonthlyNewUsersChart } from './monthly-new-users-chart'; import { MonthlyTotalUsersChart } from './monthly-total-users-chart'; import { TeamMembers } from './team-members'; @@ -133,17 +133,18 @@ export default async function OpenPage() { { total_count: mergedPullRequests }, STARGAZERS_DATA, EARLY_ADOPTERS_DATA, + MONTHLY_USERS, + MONTHLY_COMPLETED_DOCUMENTS, ] = await Promise.all([ fetchGithubStats(), fetchOpenIssues(), fetchMergedPullRequests(), fetchStargazers(), fetchEarlyAdopters(), + getUserMonthlyGrowth(), + getCompletedDocumentsMonthly(), ]); - const MONTHLY_USERS = await getUserMonthlyGrowth(); - const MONTHLY_COMPLETED_DOCUMENTS = await getCompletedDocumentsMonthly(); - return (
diff --git a/packages/lib/server-only/user/get-monthly-completed-document.ts b/packages/lib/server-only/user/get-monthly-completed-document.ts index ef1bcd4b9..644643bb3 100644 --- a/packages/lib/server-only/user/get-monthly-completed-document.ts +++ b/packages/lib/server-only/user/get-monthly-completed-document.ts @@ -17,9 +17,9 @@ type GetCompletedDocumentsMonthlyQueryResult = Array<{ export const getCompletedDocumentsMonthly = async () => { const result = await prisma.$queryRaw` SELECT - DATE_TRUNC('month', "completedAt") AS "month", + DATE_TRUNC('month', "updatedAt") AS "month", COUNT("id") as "count", - SUM(COUNT("id")) OVER (ORDER BY DATE_TRUNC('month', "completedAt")) as "cume_count" + SUM(COUNT("id")) OVER (ORDER BY DATE_TRUNC('month', "updatedAt")) as "cume_count" FROM "Document" WHERE "status" = 'COMPLETED' GROUP BY "month" From 8c1686f113569c98ef61a3266f86e6309aefeeb3 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 21 Mar 2024 01:25:23 +0000 Subject: [PATCH 024/174] feat: add total signed documents --- .../monthly-completed-documents-chart.tsx | 4 +- .../src/app/(marketing)/open/page.tsx | 24 +++++---- .../open/total-signed-documents-chart.tsx | 54 +++++++++++++++++++ 3 files changed, 70 insertions(+), 12 deletions(-) create mode 100644 apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx diff --git a/apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx b/apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx index 4efd31e76..77059f80a 100644 --- a/apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx +++ b/apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx @@ -38,7 +38,7 @@ export const MonthlyCompletedDocumentsChart = ({ labelStyle={{ color: 'hsl(var(--primary-foreground))', }} - formatter={(value) => [Number(value).toLocaleString('en-US'), 'Total Users']} + formatter={(value) => [Number(value).toLocaleString('en-US'), 'Completed Documents']} cursor={{ fill: 'hsl(var(--primary) / 10%)' }} /> @@ -47,7 +47,7 @@ export const MonthlyCompletedDocumentsChart = ({ fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} maxBarSize={60} - label="Monthly Completed Documents" + label="Completed Documents" /> diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index 02cf9302f..ea7ffda14 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -6,8 +6,6 @@ import { getCompletedDocumentsMonthly } from '@documenso/lib/server-only/user/ge import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth'; import { FUNDING_RAISED } from '~/app/(marketing)/open/data'; -import { MetricCard } from '~/app/(marketing)/open/metric-card'; -import { SalaryBands } from '~/app/(marketing)/open/salary-bands'; import { CallToAction } from '~/components/(marketing)/call-to-action'; import { BarMetric } from './bar-metrics'; @@ -16,8 +14,10 @@ import { FundingRaised } from './funding-raised'; import { MonthlyCompletedDocumentsChart } from './monthly-completed-documents-chart'; import { MonthlyNewUsersChart } from './monthly-new-users-chart'; import { MonthlyTotalUsersChart } from './monthly-total-users-chart'; +import { SalaryBands } from './salary-bands'; import { TeamMembers } from './team-members'; import { OpenPageTooltip } from './tooltip'; +import { TotalSignedDocumentsChart } from './total-signed-documents-chart'; import { Typefully } from './typefully'; export const metadata: Metadata = { @@ -128,17 +128,17 @@ const fetchEarlyAdopters = async () => { export default async function OpenPage() { const [ - { forks_count: forksCount, stargazers_count: stargazersCount }, - { total_count: openIssues }, - { total_count: mergedPullRequests }, + // { forks_count: forksCount, stargazers_count: stargazersCount }, + // { total_count: openIssues }, + // { total_count: mergedPullRequests }, STARGAZERS_DATA, EARLY_ADOPTERS_DATA, MONTHLY_USERS, MONTHLY_COMPLETED_DOCUMENTS, ] = await Promise.all([ - fetchGithubStats(), - fetchOpenIssues(), - fetchMergedPullRequests(), + // fetchGithubStats(), + // fetchOpenIssues(), + // fetchMergedPullRequests(), fetchStargazers(), fetchEarlyAdopters(), getUserMonthlyGrowth(), @@ -166,7 +166,7 @@ export default async function OpenPage() {
-
+ {/*
-
+
*/} @@ -259,6 +259,10 @@ export default async function OpenPage() { data={MONTHLY_COMPLETED_DOCUMENTS} className="col-span-12 lg:col-span-6" /> +
diff --git a/apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx b/apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx new file mode 100644 index 000000000..239d15de9 --- /dev/null +++ b/apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { DateTime } from 'luxon'; +import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; + +import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; +import { cn } from '@documenso/ui/lib/utils'; + +export type TotalSignedDocumentsChartProps = { + className?: string; + data: GetUserMonthlyGrowthResult; +}; + +export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocumentsChartProps) => { + const formattedData = [...data].reverse().map(({ month, cume_count: count }) => { + return { + month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'), + count: Number(count), + }; + }); + + return ( +
+
+

Total Signed Documents

+
+ +
+ + + + + + [Number(value).toLocaleString('en-US'), 'Signed Documents']} + cursor={{ fill: 'hsl(var(--primary) / 10%)' }} + /> + + + + +
+
+ ); +}; From facafe09971ab95cff479aed2a8d0567f11cd693 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 21 Mar 2024 01:39:14 +0000 Subject: [PATCH 025/174] feat: place card titles in the box --- .../src/app/(marketing)/open/bar-metrics.tsx | 15 +++++---- .../src/app/(marketing)/open/cap-table.tsx | 10 +++--- .../app/(marketing)/open/funding-raised.tsx | 11 ++++--- .../monthly-completed-documents-chart.tsx | 11 +++---- .../open/monthly-new-users-chart.tsx | 11 +++---- .../open/monthly-total-users-chart.tsx | 11 +++---- .../src/app/(marketing)/open/page.tsx | 31 ++++++++++--------- .../open/total-signed-documents-chart.tsx | 11 +++---- .../src/app/(marketing)/open/typefully.tsx | 11 ++++--- .../src/components/(marketing)/callout.tsx | 2 +- .../src/components/(marketing)/hero.tsx | 2 +- 11 files changed, 62 insertions(+), 64 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx b/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx index 940adb8fc..fb9c61f11 100644 --- a/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx +++ b/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx @@ -1,11 +1,10 @@ 'use client'; -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { formatMonth } from '@documenso/lib/client-only/format-month'; -import { cn } from '@documenso/ui/lib/utils'; export type BarMetricProps> = HTMLAttributes & { data: T; @@ -34,13 +33,13 @@ export const BarMetric = -
-

{title}

- {extraInfo} -
+
+
+
+

{title}

+ {extraInfo} +
-
diff --git a/apps/marketing/src/app/(marketing)/open/cap-table.tsx b/apps/marketing/src/app/(marketing)/open/cap-table.tsx index ba6a12dc4..05c122aa5 100644 --- a/apps/marketing/src/app/(marketing)/open/cap-table.tsx +++ b/apps/marketing/src/app/(marketing)/open/cap-table.tsx @@ -5,8 +5,6 @@ import { useEffect, useState } from 'react'; import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts'; -import { cn } from '@documenso/ui/lib/utils'; - import { CAP_TABLE } from './data'; const COLORS = ['#7fd843', '#a2e771', '#c6f2a4']; @@ -49,10 +47,12 @@ export const CapTable = ({ className, ...props }: CapTableProps) => { setIsSSR(false); }, []); return ( -
-

Cap Table

+
+
+
+

Cap Table

+
-
{!isSSR && ( & { data: Record[]; @@ -18,10 +17,12 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) })); return ( -
-

Total Funding Raised

+
+
+
+

Total Funding Raised

+
-
diff --git a/apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx b/apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx index 77059f80a..9626838ed 100644 --- a/apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx +++ b/apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart.tsx @@ -4,7 +4,6 @@ import { DateTime } from 'luxon'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; -import { cn } from '@documenso/ui/lib/utils'; export type MonthlyCompletedDocumentsChartProps = { className?: string; @@ -23,12 +22,12 @@ export const MonthlyCompletedDocumentsChart = ({ }); return ( -
-
-

Completed Documents per Month

-
+
+
+
+

Completed Documents per Month

+
-
diff --git a/apps/marketing/src/app/(marketing)/open/monthly-new-users-chart.tsx b/apps/marketing/src/app/(marketing)/open/monthly-new-users-chart.tsx index 0df73e30c..fe7941336 100644 --- a/apps/marketing/src/app/(marketing)/open/monthly-new-users-chart.tsx +++ b/apps/marketing/src/app/(marketing)/open/monthly-new-users-chart.tsx @@ -4,7 +4,6 @@ import { DateTime } from 'luxon'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; -import { cn } from '@documenso/ui/lib/utils'; export type MonthlyNewUsersChartProps = { className?: string; @@ -20,12 +19,12 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr }); return ( -
-
-

New Users

-
+
+
+
+

New Users

+
-
diff --git a/apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx b/apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx index 96ce34556..6ab5572ec 100644 --- a/apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx +++ b/apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx @@ -4,7 +4,6 @@ import { DateTime } from 'luxon'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; -import { cn } from '@documenso/ui/lib/utils'; export type MonthlyTotalUsersChartProps = { className?: string; @@ -20,12 +19,12 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha }); return ( -
-
-

Total Users

-
+
+
+
+

Total Users

+
-
diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index ea7ffda14..31990519e 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -11,6 +11,7 @@ import { CallToAction } from '~/components/(marketing)/call-to-action'; import { BarMetric } from './bar-metrics'; import { CapTable } from './cap-table'; import { FundingRaised } from './funding-raised'; +import { MetricCard } from './metric-card'; import { MonthlyCompletedDocumentsChart } from './monthly-completed-documents-chart'; import { MonthlyNewUsersChart } from './monthly-new-users-chart'; import { MonthlyTotalUsersChart } from './monthly-total-users-chart'; @@ -128,17 +129,17 @@ const fetchEarlyAdopters = async () => { export default async function OpenPage() { const [ - // { forks_count: forksCount, stargazers_count: stargazersCount }, - // { total_count: openIssues }, - // { total_count: mergedPullRequests }, + { forks_count: forksCount, stargazers_count: stargazersCount }, + { total_count: openIssues }, + { total_count: mergedPullRequests }, STARGAZERS_DATA, EARLY_ADOPTERS_DATA, MONTHLY_USERS, MONTHLY_COMPLETED_DOCUMENTS, ] = await Promise.all([ - // fetchGithubStats(), - // fetchOpenIssues(), - // fetchMergedPullRequests(), + fetchGithubStats(), + fetchOpenIssues(), + fetchMergedPullRequests(), fetchStargazers(), fetchEarlyAdopters(), getUserMonthlyGrowth(), @@ -166,7 +167,7 @@ export default async function OpenPage() {
- {/*
+
-
*/} +
@@ -206,7 +207,7 @@ export default async function OpenPage() { data={STARGAZERS_DATA} metricKey="stars" - title="Github: Total Stars" + title="GitHub: Total Stars" label="Stars" className="col-span-12 lg:col-span-6" /> @@ -214,27 +215,27 @@ export default async function OpenPage() { data={STARGAZERS_DATA} metricKey="mergedPRs" - title="Github: Total Merged PRs" + title="GitHub: Total Merged PRs" label="Merged PRs" - chartHeight={300} + chartHeight={400} className="col-span-12 lg:col-span-6" /> data={STARGAZERS_DATA} metricKey="forks" - title="Github: Total Forks" + title="GitHub: Total Forks" label="Forks" - chartHeight={300} + chartHeight={400} className="col-span-12 lg:col-span-6" /> data={STARGAZERS_DATA} metricKey="openIssues" - title="Github: Total Open Issues" + title="GitHub: Total Open Issues" label="Open Issues" - chartHeight={300} + chartHeight={400} className="col-span-12 lg:col-span-6" /> diff --git a/apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx b/apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx index 239d15de9..c2b7561de 100644 --- a/apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx +++ b/apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx @@ -4,7 +4,6 @@ import { DateTime } from 'luxon'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; -import { cn } from '@documenso/ui/lib/utils'; export type TotalSignedDocumentsChartProps = { className?: string; @@ -20,12 +19,12 @@ export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocume }); return ( -
-
-

Total Signed Documents

-
+
+
+
+

Total Signed Documents

+
-
diff --git a/apps/marketing/src/app/(marketing)/open/typefully.tsx b/apps/marketing/src/app/(marketing)/open/typefully.tsx index a233904db..4f298fbb3 100644 --- a/apps/marketing/src/app/(marketing)/open/typefully.tsx +++ b/apps/marketing/src/app/(marketing)/open/typefully.tsx @@ -6,18 +6,19 @@ import Link from 'next/link'; import { FaXTwitter } from 'react-icons/fa6'; -import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; export type TypefullyProps = HTMLAttributes; export const Typefully = ({ className, ...props }: TypefullyProps) => { return ( -
-

Twitter Stats

+
+
+
+

Twitter Stats

+
-
-
+

Documenso on X

diff --git a/apps/marketing/src/components/(marketing)/callout.tsx b/apps/marketing/src/components/(marketing)/callout.tsx index faa486c46..dfd358c71 100644 --- a/apps/marketing/src/components/(marketing)/callout.tsx +++ b/apps/marketing/src/components/(marketing)/callout.tsx @@ -53,7 +53,7 @@ export const Callout = ({ starCount }: CalloutProps) => { > From 94198e7584492bfc2b1db49526d291bc9cebba74 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Thu, 21 Mar 2024 13:16:17 +0100 Subject: [PATCH 026/174] chore: text --- .../(marketing)/open/total-signed-documents-chart.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx b/apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx index c2b7561de..2a8393363 100644 --- a/apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx +++ b/apps/marketing/src/app/(marketing)/open/total-signed-documents-chart.tsx @@ -22,7 +22,7 @@ export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocume
-

Total Signed Documents

+

Total Completed Documents

@@ -34,7 +34,10 @@ export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocume labelStyle={{ color: 'hsl(var(--primary-foreground))', }} - formatter={(value) => [Number(value).toLocaleString('en-US'), 'Signed Documents']} + formatter={(value) => [ + Number(value).toLocaleString('en-US'), + 'Total Completed Documents', + ]} cursor={{ fill: 'hsl(var(--primary) / 10%)' }} /> @@ -43,7 +46,7 @@ export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocume fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} maxBarSize={60} - label="Signed Documents" + label="Total Completed Documents" /> From 1cd7dd236b517134385444cce4c8cfe260d5b928 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 21 Mar 2024 16:15:29 +0000 Subject: [PATCH 027/174] chore: test signing a document --- .../src/app/(marketing)/open/bar-metrics.tsx | 2 +- .../e2e/pr-718-add-stepper-component.spec.ts | 84 +++++++++++++++++++ packages/app-tests/package.json | 1 + packages/app-tests/playwright.config.ts | 8 +- .../document/get-document-by-token.ts | 27 ++++-- 5 files changed, 115 insertions(+), 7 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx b/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx index 940adb8fc..8a01c5ecc 100644 --- a/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx +++ b/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx @@ -1,6 +1,6 @@ 'use client'; -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; diff --git a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts b/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts index e482c4172..3318afe7a 100644 --- a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts +++ b/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts @@ -1,6 +1,9 @@ import { expect, test } from '@playwright/test'; import path from 'node:path'; +import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email'; +import { DocumentStatus } from '@documenso/prisma/client'; import { TEST_USER } from '@documenso/prisma/seed/pr-718-add-stepper-component'; test(`[PR-718]: should be able to create a document`, async ({ page }) => { @@ -168,3 +171,84 @@ test('should be able to create a document with multiple recipients', async ({ pa // Assert document was created await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); }); + +test('should be able to create, send and sign a document', async ({ page }) => { + await page.goto('/signin'); + + const documentTitle = `example-${Date.now()}.pdf`; + + // Sign in + await page.getByLabel('Email').fill(TEST_USER.email); + await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password); + await page.getByRole('button', { name: 'Sign In' }).click(); + + // Upload document + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.locator('input[type=file]').evaluate((e) => { + if (e instanceof HTMLInputElement) { + e.click(); + } + }), + ]); + + await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf')); + + // Wait to be redirected to the edit page + await page.waitForURL(/\/documents\/\d+/); + + // Set title + await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible(); + + await page.getByLabel('Title').fill(documentTitle); + + await page.getByRole('button', { name: 'Continue' }).click(); + + // Add signers + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + await page.getByLabel('Email*').fill('user1@example.com'); + await page.getByLabel('Name').fill('User 1'); + + await page.getByRole('button', { name: 'Continue' }).click(); + + // Add fields + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Add subject and send + await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible(); + await page.getByRole('button', { name: 'Send' }).click(); + + await page.waitForURL('/documents'); + + // Assert document was created + await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); + await page.getByRole('link', { name: documentTitle }).click(); + + const url = await page.url().split('/'); + const documentId = url[url.length - 1]; + + const { token } = await getRecipientByEmail({ + email: 'user1@example.com', + documentId: Number(documentId), + }); + + await page.goto(`/sign/${token}`); + await page.waitForURL(`/sign/${token}`); + + // Check if document has been viewed + const { status } = await getDocumentByToken({ token }); + expect(status).toBe(DocumentStatus.PENDING); + + await page.getByRole('button', { name: 'Complete' }).click(); + await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible(); + await page.getByRole('button', { name: 'Sign' }).click(); + + await page.waitForURL(`/sign/${token}/complete`); + await expect(page.getByText('You have signed')).toBeVisible(); + + // Check if document has been signed + const { status: completedStatus } = await getDocumentByToken({ token }); + expect(completedStatus).toBe(DocumentStatus.COMPLETED); +}); diff --git a/packages/app-tests/package.json b/packages/app-tests/package.json index 9dcb32f7d..84f14d469 100644 --- a/packages/app-tests/package.json +++ b/packages/app-tests/package.json @@ -6,6 +6,7 @@ "main": "index.js", "scripts": { "test:dev": "playwright test", + "test-ui:dev": "playwright test --ui", "test:e2e": "start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\"" }, "keywords": [], diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts index 672c2f7ef..65ba20455 100644 --- a/packages/app-tests/playwright.config.ts +++ b/packages/app-tests/playwright.config.ts @@ -29,7 +29,13 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', - video: 'retain-on-failure', + // BEFORE MERGE: video: 'retain-on-failure', + video: 'on', + + // REMOVE BEFORE MERGE + launchOptions: { + slowMo: 500, + }, }, timeout: 30_000, diff --git a/packages/lib/server-only/document/get-document-by-token.ts b/packages/lib/server-only/document/get-document-by-token.ts index d242e72fd..1594efbe4 100644 --- a/packages/lib/server-only/document/get-document-by-token.ts +++ b/packages/lib/server-only/document/get-document-by-token.ts @@ -1,13 +1,30 @@ import { prisma } from '@documenso/prisma'; import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; -export interface GetDocumentAndSenderByTokenOptions { +export type GetDocumentByTokenOptions = { token: string; -} +}; -export interface GetDocumentAndRecipientByTokenOptions { - token: string; -} +export type GetDocumentAndSenderByTokenOptions = GetDocumentByTokenOptions; +export type GetDocumentAndRecipientByTokenOptions = GetDocumentByTokenOptions; + +export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) => { + if (!token) { + throw new Error('Missing token'); + } + + const result = await prisma.document.findFirstOrThrow({ + where: { + Recipient: { + some: { + token, + }, + }, + }, + }); + + return result; +}; export const getDocumentAndSenderByToken = async ({ token, From 5377d27c6a2dfda807369e930f75c4f4596d3da5 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 21 Mar 2024 16:28:42 +0000 Subject: [PATCH 028/174] chore: test for redirect url --- .../e2e/pr-718-add-stepper-component.spec.ts | 85 +++++++++++++++++++ .../primitives/document-flow/add-subject.tsx | 2 +- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts b/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts index 3318afe7a..4327935bb 100644 --- a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts +++ b/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts @@ -252,3 +252,88 @@ test('should be able to create, send and sign a document', async ({ page }) => { const { status: completedStatus } = await getDocumentByToken({ token }); expect(completedStatus).toBe(DocumentStatus.COMPLETED); }); + +test('should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({ + page, +}) => { + await page.goto('/signin'); + + const documentTitle = `example-${Date.now()}.pdf`; + + // Sign in + await page.getByLabel('Email').fill(TEST_USER.email); + await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password); + await page.getByRole('button', { name: 'Sign In' }).click(); + + // Upload document + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.locator('input[type=file]').evaluate((e) => { + if (e instanceof HTMLInputElement) { + e.click(); + } + }), + ]); + + await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf')); + + // Wait to be redirected to the edit page + await page.waitForURL(/\/documents\/\d+/); + + // Set title + await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible(); + + await page.getByLabel('Title').fill(documentTitle); + + await page.getByRole('button', { name: 'Continue' }).click(); + + // Add signers + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + await page.getByLabel('Email*').fill('user1@example.com'); + await page.getByLabel('Name').fill('User 1'); + + await page.getByRole('button', { name: 'Continue' }).click(); + + // Add fields + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Add subject and send + await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible(); + await page.getByRole('button', { name: 'Advanced Options' }).click(); + await page.getByLabel('Redirect URL').fill('https://documenso.com'); + + await page.getByRole('button', { name: 'Send' }).click(); + + await page.waitForURL('/documents'); + + // Assert document was created + await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); + await page.getByRole('link', { name: documentTitle }).click(); + + const url = await page.url().split('/'); + const documentId = url[url.length - 1]; + + const { token } = await getRecipientByEmail({ + email: 'user1@example.com', + documentId: Number(documentId), + }); + + await page.goto(`/sign/${token}`); + await page.waitForURL(`/sign/${token}`); + + // Check if document has been viewed + const { status } = await getDocumentByToken({ token }); + expect(status).toBe(DocumentStatus.PENDING); + + await page.getByRole('button', { name: 'Complete' }).click(); + await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible(); + await page.getByRole('button', { name: 'Sign' }).click(); + + await page.waitForURL('https://documenso.com'); + + // Check if document has been signed + const { status: completedStatus } = await getDocumentByToken({ token }); + expect(completedStatus).toBe(DocumentStatus.COMPLETED); +}); diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index bfc7f3fc5..aa0bc148f 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -230,7 +230,7 @@ export const AddSubjectFormPartial = ({
); diff --git a/package-lock.json b/package-lock.json index 5b1f8f0be..1d8663908 100644 --- a/package-lock.json +++ b/package-lock.json @@ -115,6 +115,7 @@ "next-axiom": "^1.1.1", "next-plausible": "^3.10.1", "next-themes": "^0.2.1", + "papaparse": "^5.4.1", "perfect-freehand": "^1.2.0", "posthog-js": "^1.75.3", "posthog-node": "^3.1.1", @@ -138,6 +139,7 @@ "@types/formidable": "^2.0.6", "@types/luxon": "^3.3.1", "@types/node": "20.1.0", + "@types/papaparse": "^5.3.14", "@types/react": "18.2.18", "@types/react-dom": "18.2.7", "@types/ua-parser-js": "^0.7.39", @@ -8081,6 +8083,15 @@ "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==" }, + "node_modules/@types/papaparse": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz", + "integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse5": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", @@ -17254,6 +17265,11 @@ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, + "node_modules/papaparse": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", + "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", From ea64ccae29480ed6a761b077d496af3e54b09c84 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 5 Apr 2024 12:02:05 +0000 Subject: [PATCH 073/174] fix: unnecesary requests --- .../(marketing)/status-widget-container.tsx | 19 ++++--------------- .../components/(marketing)/status-widget.tsx | 8 +++++--- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/apps/marketing/src/components/(marketing)/status-widget-container.tsx b/apps/marketing/src/components/(marketing)/status-widget-container.tsx index ffc83bff0..025c2df56 100644 --- a/apps/marketing/src/components/(marketing)/status-widget-container.tsx +++ b/apps/marketing/src/components/(marketing)/status-widget-container.tsx @@ -13,20 +13,9 @@ export function StatusWidgetContainer() { function StatusWidgetFallback() { return ( - -
-

Operational

-
- - - - - -
+
+ + +
); } diff --git a/apps/marketing/src/components/(marketing)/status-widget.tsx b/apps/marketing/src/components/(marketing)/status-widget.tsx index d53a79f43..1c94c0707 100644 --- a/apps/marketing/src/components/(marketing)/status-widget.tsx +++ b/apps/marketing/src/components/(marketing)/status-widget.tsx @@ -1,6 +1,7 @@ -import { use } from 'react'; +import { use, useMemo } from 'react'; -import { type Status, getStatus } from '@openstatus/react'; +import type { Status } from '@openstatus/react'; +import { getStatus } from '@openstatus/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -45,7 +46,8 @@ const getStatusLevel = (level: Status) => { }; export function StatusWidget() { - const { status } = use(getStatus('documenso-status')); + const getStatusMemoized = useMemo(async () => getStatus('documenso-status'), []); + const { status } = use(getStatusMemoized); const level = getStatusLevel(status); return ( From 950a6971150f90b827357a59bb53f5bae8333adb Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Fri, 5 Apr 2024 17:21:29 +0200 Subject: [PATCH 074/174] fix: description part 1 --- .../content/blog/building-documenso-pt1.mdx | 2 +- apps/marketing/public/blog/eu-validate-1.png | Bin 0 -> 119908 bytes apps/marketing/public/blog/eu-validate-2.png | Bin 0 -> 220213 bytes 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 apps/marketing/public/blog/eu-validate-1.png create mode 100644 apps/marketing/public/blog/eu-validate-2.png diff --git a/apps/marketing/content/blog/building-documenso-pt1.mdx b/apps/marketing/content/blog/building-documenso-pt1.mdx index ad81a069b..4675bd9ab 100644 --- a/apps/marketing/content/blog/building-documenso-pt1.mdx +++ b/apps/marketing/content/blog/building-documenso-pt1.mdx @@ -1,6 +1,6 @@ --- title: 'Building Documenso — Part 1: Certificates' -description: In today's fast-paced world, productivity and efficiency are crucial for success, both in personal and professional endeavors. We all strive to make the most of our time and energy to achieve our goals effectively. However, it's not always easy to stay on track and maintain peak performance. In this blog post, we'll explore 10 valuable tips to help you boost productivity and efficiency in your daily life. +description: Let's take a look why you need a signing certificate and how Documenso does it. authorName: 'Timur Ercan' authorImage: '/blog/blog-author-timur.jpeg' authorRole: 'Co-Founder' diff --git a/apps/marketing/public/blog/eu-validate-1.png b/apps/marketing/public/blog/eu-validate-1.png new file mode 100644 index 0000000000000000000000000000000000000000..9903359e31ada6ec82c8e304764b209d6f3ada6a GIT binary patch literal 119908 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%0DrPUTfD^nsBOU;57g~LubsU zCAHJ!o{Px{P+H2y+O^TFJG5L_@&jyvs!AwCj;h=XHyEmuz0fIYC|J^3tz%2!j)?0W z!iRs6iqX9D{7r258u)t(cQ>Vnf z$|!Waf0r7@ieS>9m?ypwnF#aW&u<0qlK9$;w+5nL(LaPI`6NTSnfMih1(j&3+1+*j z!UzCm7u?-*A34;sz*ntU3p>{!ZwC*#H~dR^?4ctj#x;PIzBZqfa&#;z3y)8+78&3< zaWtvvSA`MPS0hy|G|b8N8kPB@v$m@Bx-Y(e=WeL^+51zC)9VjxAOO?<$Kxbfy})3? zL%aR_z~8iY-$1*|8u}I0lhLBOyCoJNGmt*S0wu!XtZ=7<4nIK_YT7;PF}VG;!R?(l z6;1m=5Qg1Gh>-o9@~WOPWuf>{0nXJ3pml@Y%xLxg`>3rdc_jRxQj<^4l=x7-(7U*m zErigRy8W{E8QvHZ2mW8KuZT0GGi|82p(fMaKca0PepDd_{uwqG$$z8?WUCH?TidJ> zi=)m8BBO-9Vd(nNSZtzUSxj-W{k0&@oU7QBTcBw6!5OB?ZTV^gFxH9+`aN$Z^CU9Z zM&K6U9!b$MR6(|8$xCePu*$Eu)N^8_Tw%4$n z;KJbME-OaQvrHw~HMn9TQNvz3T?jp@p~NbOUEHr>lUw!=J*`ot?R4LU3XswpET6SyiJl z(pS=wkX^wRiT?B$In=}w^K(#(635jmjig`V$jaxPPQJtoeni+`+C0FE6En@4m zj1kv9qVE-1qru{ripY4fp_G5*d3y*s0Eaa(=`9AjQlNMASrr)$0^zOeB({*Sn5+O|F)M?$;ggaiqdwN2Ycukh5>=HlaFLYjH){3;vhMWE<@zJ-ImHK9YgpH3idTuw^cMWw5=PSn450e=B4E7B+hXd?pz16w$aQv;;pEj=a)`PbCBM1ok89F8`^;ByJ+RbB=*3=&(MG&%puLW# zZlF_R8<9$L4lP38qp!HueTVrwp{!qH1_Rt3wR{P_uJlh9;45?+q3!X&^OdxIFgLvi zo!h(%Y`phGQa)-R-5Yt^?IU3Jp1bh+(zFP@O%JkMU)&|yFQTOyK9@?_4SHAG@v7=i zjO^wZspPgDbe*Ih#9rd~Nc>%e^(6mq7Qj44e@g{r){@%!_Yg~)9u9HU3d7T>>ggRw zf;7ZL<2y`E`+g^%Onb9~lH`{U!ej5NL=NbD5!Z74xLnClkVlh{qRBgP^Zno^h~;~y zWXByZhlYuf6jrwT;Q(*b^YJ7Qkjt@{RC(ag6TPehmt2WEE#QP>Lr5=REhpx%EQGmK zXq=4uV&{Q)eaQ*QpMi_&)tU5$ZEcr=R(R1fn~_chj3ljS^nC4@lR~#^@(FyRR&m7N zQMJ)L$pc|Z>mNA2H3d!b>j|$aed#=tSa3KH`}c;f=`K+4So{P{N4|uyk?C{u!>)h$ zYE4R}-lSZx{)35ZA6kP&WcZSJwj1Pe$#$98vmG!CTET|=n8T{JXKq%zuf%SKwzTPf zf)e(XYPpA{L(x3z6{NP5E^5bcAac#Oa?ecI@B z4sIqIYgmMh;ZATadB$zJ!da=s`x*JXp)Lx#N-Zu48@=+HImFI$z{C`U3&D^C><)|8q3O-tTJCK;w$Fy*wXuGc{r@V5gV5sxpp|=fmnl)COul%(Ro{ym{R@ygrufiS{jWSXP7D~ z8((IVf5ha27MHWwdS?b+ds=Mx-O@^wnumZ*%DAXV2OKis^j$AEaC(?9o>gb=v1q3 z0JHOB4*!$kSBy&Ryo$dH0`xMztS3K0QR^55rE(FB8T^-v2I|y-Gs8u7v3I6IQ&*cU zL=0YZgfWaBZ_*?kFj<4uv|j;TYP~y5z*m@poz)u_xQ-CX*-S=eM1=Gsn-0uT6%Bp& zEttHDWqSn0c?kbUXw*)&v!?urrCruQ>GLoaWBMM_N_@=a4O$x4B&nyDy@Lj|{5uMQlRZ;W%=`)GRfgUeol|2n_wN0* zBq_T`PSHv#aCuJXRI#@j+GK+_-+g(r<9dCzCi-)LqWOZtjaI;94_XE^%fLs^oC--^ zhN#*H8Y6ZzBz#p|jnGw-mTVpj6jaY+_M2O1K6+tw2X>*` z+HH@JO~NtQ)<^ImqcWJbmQ)nh)Z{R(bkp^Yw3!)WgO<|mI~*oIIL7^XU<;#cjyu@K zlyQjfPg0wm(Gdz8Jdnm?%pwVeVC=F8VSIXf;`p~(K>G_dKD7c-mkiHIES@O3F(|o< z{cl{U3JWcVL6xw`upM%n9PCL3VYBk@ypz8JIZ41yhh2?Ao9p>ka*PfrP7`n{Z_6oCRSm%*}p)B%dGbDe+ zFq~o(^^fCuod9eB_A@LOYng6r|CpIttth0%e&q3Bh~Sg}^!Ii32`Yr%bgS={ShUFq{l z?Z5QYx@sSV2B%@<8MDn(Kf%R*%)1cnQna!fXCkqdswLynJ;9}X)?vQdy2;48F+c4* z_a&+J$!v9M+tWfk3U4;?c?|k}&b4ppOHU{86vXNFg zaX5Slgm#DT-wZL{8L&n>Q#SS;h2$td0#|caaxlB^(}47?BHQpsJCrVAP^VtvJ@K1` zHrx1~z*Oas4YuRHc63Ugy|GelP1>vA=UgtJ1pr(PfNd}|1f=Jowo0TF?>9H#W|pVJ zK|3gIi2epF-uzb{WyvU%(N3R_7TY)oa%o=7y>(N~EXGUTAODB4cMOl~d*685piyHxnXs{K zn~iPTwrx#p+vWrfo5n`d*w#sZ`k(KsbDep)uGwq$UbFD5d#~qn_risC+%+x3BIcVf z#F5HODBQfpjlLgo69CLdd`cX6+M1v`@~ug@;4={J2ODVkz}qn6j6&uzb_cp2?rC|n z86x3e$QM`dLwJy%Y$EC zS0ebn%-)n^MilNv2>t~n?$OCz!H6K5dco7@zu< z?T6_abjFbvdml{;)lqOdZ?MybMl`K#$j1eU1EZcQ)ud`Z|9~qHx0r3a;%rXeoyt>Un6Ztc69@8Fp=X4+v5}2%P^p z*J}lZD69ibNC21{Y4EFh#s1j(ZV@bFtUUuoI1Rr(z#p-7TUc*z48oOx*WxtRRabZ@ zhub+L$(s=dFh}1mj!Ys}VdJ4T)vujv+mI?=aI}qYB7on&;QH!L@`3my;GMg+`;My! z^u}K}b1)mK?W=ZdDBgme`(5q%S@6A>=>uRCFZUx80jQzVy`p1;Uhe6K7_pP0H9b@a z<5<1h#b=Bq2&FsMOqYo(}eKI z(5)_6e#lX-&=OjtqGBbvj2;)C@t|6>FZRUMPuJd++Mnh{WCiQu(|8@TFi>o%r}tY+ zzU=~~H`1uyfu794f>I7#viN>e%zuHT{yCGvFlFNz*J{j#-i6Xa0!26jq*9`CRsGI^ zK~1sRQ)gYV^d`6QtF|y`0SYp%OZ9ZS3gySMk_LIP8`f%hw%xUq+Ou6-3QPuQuz(7e z4>}4S^E^(=_nd{|{zpTd9fAD$3RCupG6F$`eQ_oFpRI_UWbsXr#{12^a(#GOOSa@U z9@IxCxOg5X!|Yer=ER@l#4B*eT{#Y;=p}~gCR&ci+s56wTj`+s5xS!kBSoJptK&I! zPAgFLy_ZVo)`Lo=28(K|oTkcfs;WxNn*uYG!DlB9^xF}Ku!x(Dw(l)YEn0Et5?!R> zPncoEmb;2}y!K}JZ+1Mxx?D~Wu>(q zbMuSjR^_Vii(<}}RW-q5ORNNSvL12hw!G~(dgnm(Uf6*}=2VaIS@R9}C<}%nS3*n5 zol6jvyjR7lt4L>s-I`mZZ=wv>_^P6_5-wQs4u`s8zw<pmh_VN! zGjIOf@eP1Tj)zU#FQ+R7&-u6mRhoM4+tQs%>C#<@NIp|M&&4U}em<3tj0{&I3Vk!=wCmP$NG^)&*j5@kGP{uX zblf;;oRG4o)j;l~6@!=~MJ@WvY@?X*`yo(!QmP*JwAhNMkxS(kgi}|7c7MyP0`O_@ z$m#%o(a)wzbawoGYZ~^+P0am#Z-uP-TBjeA4`=n*EoKXV!V=nf$CFLt9vZ%^-^EabGMi9-7me;+L7tb z|NHG@ttMV&Q#|6ovp)2M{i&flJreGBSnJSI?6H|p0$#(2(ii>bTRL8?sK%q^VLltc zpfBia#Fawi$~Y5{*pF!9P499g{L7PM3JanwaHgE!@R4G@J;c8*Sn4m@l7$wmw2rgR z0zH22AGit$h$(~*)&vjtk)-9ffAB5z#6Msfe%-J||A*cDdo1|>|JSErb|~p=SuYRr zijts#2oRR*34UA+@l5}rc6@LgREg|Y!V4d4#Hr6`Q2a`7-3UD3-o)wo0_)W_!(+m} zGNkPpUM0op55S%G|9Rt_Z!7>5C7O|g-9@iU;rAckTTRQT6s!J^qQP+r4P@K- z$j_JjGuxSaTx%8AgXJ+inclLcmj3AE1#+1(?7DhDb$^W#1;tSLQc-eKqqci+Qc~7z z$$!JHw;X1wQg8l@{&BeblP{B$5j4m6*Rmxa1s3MR|23ce2Fc&s9{!F{SPwBv%VnUy zX-mn|0#7gN4N)tjHtxRL4B0?2aEKheJuYz%O6FvzOt;#vC`aaP{uit%vGoZe+;H z`|rd?Ty-!Oh&=uzYMa4@_wpi_Q{Uave+B}-y$knuxJYy#yB5i0lEkPKNpdG1f5D4qYzwCgYh z8wzg2U@@5^?T~;g8jSjH5CmQ>>cqX_XBtd*h`ReNOh@kr*Q>`33kcicxDAbPBlY+XsanJKl(h}w6-^(NM0|{ z=T!*njprotfM!(u5a=;;*AZ-RK1kmN5d!Dy6q3>M9(lrkK)kmBO1Rngg2v^Brb-#0 z<-cb5@!Oom>snSs_ZR9@T(3j7HAf_KJnuH^Lv$MfjGmn65E*{_{f$!@B??qC(7uQu z)Zj@dnD+r~hoJpS%G?^${`>z82RLMfcPFmrK7N7W6Ff@#)}1gDCk11rtp#a%@dJd2 zx>DBxZl}fVvTrDH8$Rf%wo3sI2tk)qTBvJOE!|L!9SEQg+_}MG60|jUB<=H%(%q6+ zRGy+0@$YwFRpE?SOy6*TV|op)7TT3|2T0tW6(QD+_sd;dtCgQlS>0=rD)bY04)P>X(rSo~53ecI*J1 z)i&d?_x~HIEguFW$l|4U!}nytRNK#&v$OcxJoWnsil{cDnp6O}`TrViiX%Eym%6#4wd1My)SS+H><2 zV22I0S%z9qVU;h75W;$#UfG>(Zt;uONdKoPT!FI7t-mX}GueqaUZRe^JUnhGUJD4=wOHbIGG7>X1;`vV@w(Jl-eV7bwu_8?3(@P_L}1c({y3)qCWX| zF#hj-HI#ans5jSsf$Id}eF08-vGrNq&5biDDd~KKI~VWJdNgb?%D5&j z-Z1?X^#1p+s8feeHU3H(&770_dYgVb8)9NriHGAsZhW4l=vV0u;LlXYNmQ>NxmZg@ zqKz0fM@9JeY~iQyk)bjxx3I>(u24T&8gdg4MZCDlvX3T}J^z(O^_M-qbwi}jfR#6E zj)a7yKoRt_=erIC$dd=6vU>WPmF$b*950`Vv$WDeqJ|!UBT6mety5ndJ<$M{yOdm) z#QY{6E;8uFvbpsMFmb0!NHzCgKe#D!2)TYzCpL8XBdY&*3?=}W`ToHNTly{`#reOf z{9i$s!{3+qd3Ei<&row{WA= zzn3?2147(+2E#@85;5d?65g59DlCpk6#GA0D%m zQ|{R37^lH&!9%M;O_Hr4?|Q^!-a>s{NlbX)ux{ZGoJxX|8~I%2Kl8{=)qQ!e!g{X( zcXR{eUM7%b^`n1nD$D83v@sUcndQ9Uz7p1XPrCgE9CYk)Y$(s9C+cR%aR(I5h>2p0+6$`bPVDA+;sjNHGnlL zF>QhiJu)#Gb0Ud!{jiQpRVIy11-r|@)L5iYSl;LM_pWu_pPgySwK>=-Z^n%KdTC*l zt!C{04D2x@fw}LIH=n#W?tk+>amb@w-6j6l(gUy;`ydlm!F>E(M0qfuJCcZC(nJd! z_q_7hDOgih;r6|#2_Fn7kWu8ycM+9433cAN%?FSnl(#_)F>3w?2}$MI+jWd^LyL*@>4ii!LWtTPebl z${Q38DhS`N%n3tSuFO-iDIO8`DhZ*f28`03_BW(eGJ4O#zwj>LD{s&(ATgYCn(_<% zky0l+r?jsi6RN7j<`W|kSNr0_e&ej z>~1|9(sEG(u8}`8?nqJ~bNC@4B@NfJTaZ0wU}W$R9r_7WI-J4OJRoXrmwZn|NlbRF z1xL2AG)#J9{OJc83r3C1L^Vx|djL+FtWo&)Uw;crQaIDzn!&s=Q%VXz)nq|(;f`N} zU%CUBI2ulC47@l6i1^z1*qFQlo9^UH-vVdiAzDq|5>D4p*YXc6Sc|8QDEsJZO5}3v z_QTqWxFe*i=Rs5Z3-;ruJf9`91g%J}87AAxY_fNc zv(WTidE3}ebl(YXsdy+>Y5e$l%cB1t6o=>_9sxvmydO6edcwFAwR|@h# zQheV4`Y*@f^f1AGgsBM)Qho-@5%T;;v=Tw?4jfcR5hiT)zzWkJ_r1xoyh`fRT&dK4 zythUfE8YVPqRYc`L%S&}G0=&!WFdJ!AzudUp)Kk>CTDAbv3i!0rJ$~|Z z$DFHvmQnT}s(sCf+bOSze%*2XcbiA5=Tn9elMZ}01z)!P206e;<5%lAgawdbDx+N` zcU(C2QW`SRhDT9N61|y|WFz7vHv}@b*%qO>Q(1YSfsXn)x?dw1Exe3Oi~VjPw>C>!nr5#g z1v#FY1RY)W!;kpd%y+W3NDc_$#ybG>il11L(?s_={*u9FOhCbzn0LZ2A%)?<1Wz9D z;_oNEPUZ1VKWa@wu@oy~H$eo0GdT9ZmAA|xmsnXwaccpxHwYMWjOU2|bXP(nOF0Hd zu^E|Lwz=CU@Vk1uD7z!oF|yLh{&!NDgOUwvgvpAG;jkoEpb5;~ez2Sm9_bf4@#Pkh zp#d=(5K8I#JW0}1uTc9o4ctW1gk&fM z2|<3XlIm2E#Q|HHT8URBzI`%Z?_ZVw)65#YDz~~&QtU3_#<>_l=D=FCfGbFS#%>~r zv`$$&lv{13;+^uE=(w3CoU1V!7H$_+8ESQG%Bu`SI*vPf(c6}GZqWkY!fggq-A`Dr z<2%qnRF0flo2)aGz~S~%i#vD)Z!K?erj24pCTX;K(VJ#s?86ZrK|733PGi-Hb>_yp z%5?37L;>f3iXkrxXUGyI5`$GNZ>nXY`^slb5a(10N{W7mcQ8`tJU;KEw&rGDCdjIP z<1@+7C5iA2dO!dyR}D>M7e4zztwv^_%CyO}SnwbH%HT+MDg&3SXjN9o_1`{$nr7xF zyX}sAXy7@ubp|yU*YV02=2}W?FA?4=^p4)1G+0xc3^lSdqP~`{{fW&T@AyW1RuC73 znS}oVGwL5|hhkhEeu|@LH^u#)aupKxh_NR4C>*{{c?V~>_)2StDy1>omIBIk%neR#>nFz!;5?U>3}qilD6J!_227%_;qo=*NwkG_EYVV z9z>V;shhkxBpd0!C8zPHp{|1epifBhX5i<9Svc@c!nXS)Iiy;mahSs*3oK)$smx4j z&Lr(idxXQbe1bVndN|XlP^mkiJue`1QLs4rohJe4-w~;V&qDy3gQJA6u5?^>`FxMA z+|DJctmz6jz!;Mn-EV5!Z>Y*3MduAaU>h;&;r&*7I=O+eHZ4Txh+h`oyC7IF$b}Tl zTSWUR>Yvt#j>Miy7M{we$mX|@bZG99;oXr`q~xcDtH0s8U?NNx>kiSN>l~4cI?OfZ z1Zpb2<~~;aQ*1SLRS(ap&Sp?QckcF%-neo4lG7MUdLfyb#Pz5orf!u8oTOo%KpyqP zIGscmY$s(xPw6P*Vw@zKPk<0Z;)cIOg%}ATUQKEJS;wb?VZTGMn{h#BYO3VnnjTiM9RBvgJ5z3r=B2G2wq%LNxEqX2oKkl!EyxlG> z_ct+mw{KSVg^gylW;YxYNS;uy{kuP?ndC(I>&V?V1v4@pLGGlxkL zKraVa=loXeBU3s0;S2X=2IsbdV&=7cuLyN^!RFG^l^AA9Ml&U+0GK(uw%Ns_^Jw;K zQg4jIFgHt2$p_5x37h>V4s$|q2_eO74)B>XBPSYlPgrJ$Vy`TX$f4%dfg(AN31vBy zNNr)C6e^4Am17wy&Yu4{9*^a>x5TIyT><17E!{J->;-&NJg+8mNDc0*Ml5)4f_c2Q zF?})FeNv`FMUTfX!4y4VL|q3G+hVe7pB+4L$A&<4WA^NBHd!1kD>tkH{w2B%Ipsz%gVS&4ywH-M9qR~~T1kVXee z{PQ~QV*x~~#G?R^(ee*?65lD3%Wad6!JN5IiSNlfDpL3*QmIhFMAW6b@6AYk&y~(U z>t}b!U92W0!_d^_P|T&HxJyvMEBKYl5l+S{SU0>4vwgz$rg-j&Ps`Z@jP?%{JET|7*bIW@}bD4i~H=9c2#w){kvFZOZF z?WS-%$qo`%v~pL%Q|8;LkdT-%gi2(9oGTCmB;O)m z6Fn}y@I7uv27JxgOC;&;;D|RoeN69iU1~_VZt9D?<~qlH4KsSvcck{*BaU9J>~>3*R&I9H(g(CMA?W;P$_E1z@qzJPU{P8|C z?e9g#Grqf+)`x5#lDN|kVl(%d!@F6)UBk!xU;LI#Jlf;#aIv9F#L!Tpe8-l!=#kuA zUNX;QNQ=OQUjKULlRojRHn$^*0%XfJ+19%xSU49vlI5=sm3|pwaRKbVjNdh9pIVb& zlP&Y)TpRd=Q)l1&=w*p>$wai)f94b8{8tdE0$oNvtKzd8G;1zmZ(rA%=A8-+^nngf zsxe%qZz7~9m%1HxVK!W5_lBkr^Ng(I8N!cFz+^(80EW*3Ia(6)Ui%{cU{RFzlZue? z=G+*5TbzO+vqGrW2HG2)lQLpY*^f6TP>Oyv5sR)mfKY2mL&c%+a1gwjbvs-Sy-Li; zc?@C98Pz)hHQ=m2!W}DfNXAB>I&o3lHw^xIgt8Yx^;&bxu!Dh7xFVs!b%E*4v#cp) zI9kc}NK7}bJ=hzEp)Wvv^-M$xODt$d~^W@Mc3oj>zce_hGPoLrU49 zN*YUAp21(gHb%-MT#k7=Mu9#0loO*$@YD}OB z)){J};yb9v8 zyPsts!YBK!%E}-D^;A<^4Q(r1`&PQ4^dJRbne6X=f|2tLsVW z<)gbAAi%)DgxVLA24~gozD@K>FdZQ@ok?8z{YzuCcm1oBKG4+)3VhOAu;T0q{7@P` zL_}<`fpJ5CheW>1P%mV)cUD^ys28DWPV#>GgG{wAZTeG4lsbuHSi2=ux=XYR!PNQZ zer=%&-9ETx$H?q}nvB&BsA+s%Dk_~cMi#0e`LAgNFala4YBxijTB)=V_j7$Ml^G&0B1O_*L^oYb7Y&54|>I>Ka3H2S)10_EEkb{Z~#gl)-VWUA(}FoA6TV}~+gS3|d5$>*QaX@r2$km7POwxq^CgxQZEo$jw{ z*y%zo{SUi{Jjo9Ywq9~OdEg(SDzNiI-*rD*sHVyI-^-JaF9dw#-=cuXLzH3v;TBUhohvq(ua6#t;A8x@Q+9O z|Ge=jG>CEONCQf?0gn<&(%b(q!ahgzar*l|;ZX+B1d8;*eWtj3e}waWF#)GKk#fdo z!AV0rhEGxZ`bhbI!ggOdvAwDmUfk)Le?}u_-soN#m5^_ECNG=SbTo`)T-Fbg3@3W!-FK3pzg)f)s^b4%l&#UR+0-ymGN1flUqj&d(8_PK- zGzpaRa7zlm{@e7i0Y7K@be*H5%l{hO-jv8*lQtHi6Y}F_Ej0DQOUMfoh35@!ZzQ;xys0a;kqwbNR#a zprp;(z|R6p5$J;RL#G46AYm43wy{|4WS3uc&&*)(j<>F}fC>o(JIK{Wi)3c{;2~oA z_CpbUQ}0FIr^5eoe+o&MvZGC3Pm}wfPKFflQw$H^lw`yGx1jgSs~F|HrTKR$8m%s@ zFekl1t2o`*q-;|qHd;_hD}Rhtt{)-&8q2TE4-MZx6ZcKuKXQxFlD%K`T zvQ&DFz}IEfuE)NRx@GISl`8w3A~Nd>vevDDM5G&RR@KB2&1T~8j!E0FDS9l+5HR!+ zZ%Tk>p3@w`37_Ymq6i}mT`tuc_0OG0@5|h97aTRmr@ft(WQ@!chFXba6VXhKMqfGi z7@8%C-`Olyie!Ui2j#$HQK3%KGfY}&CR&W8F_^C{o97B#@41<&Q)Q~J$?DI22rSIZ z=-y0=JPv?P=i$UiSckw2A~*X%eCo5o3x#Rl>P=HU?j{b07wF6(I;@QNzTjOO*QaHB z?9XCbcVrhyFreM6x1j0P>;SVNEboV@&8>6Nk@_HUM$>H?T^7f)mH9z0`#&-m5RT$B znk{&;Lf56=+KXXEhUWI1@ctbAq5{5yRYsuIln4#v4YkAQ8vmKN7#D)L22U$Y(1K7(V$)&RM7B4 zaf+fNjaxI)xN>yqoOV{)S(OhCM@ySqu=oA4gWd@X71Gd&>}s%l2>vRXR|<|J4HvJb zg1EVb1>wFzbgT_4bGLEfgtnvoWRgx6i^c2;(G*k6;aC2NiQ6rjAnTLH0f#1TWkOC) z1RFRK7C}rIk{;K+>31a#k(6=&KYx(4`Q%moI@kt6V+6`_so&X0Z%<#^qs$9?3Egt_ zheX1Js4)7=77G}-!9`PI;#WEWzV3Ly6XSIwoDblGGT?T=gaaygKzB1dYIzt*kslNr z9o9T=SiacAvLht+rGBP=ogSio)*yaus~xM#rX>zp9|G&6N4^sVO$0 zF2VT;8-7RPFWA3dx5Ca?4xc1^2lw-QhZ+U05zRF1fU2SDeUA|@gu(eOvi*EE!)Zl6aT#lOX9Z>G&DF?udAovd|z;U~xJV>3WT>+}=xj37I+1KsBiQqaL{ zq;OyShFY!HW`aO1d~rx#>yqz%VO+TplOP4)O;mt}W*r`$a;LYuC(Vc(Q-w=sw^nag z>s?n7fa_132tfM1)%E*zOW;>xwd(;z_rr3ix-8S+`L6^|>v{}qp^f2S6I>uWC9UAXoV6`^{u?6A)#jJWq%^BKms8xCv{HlNtFU~A z@KHz8VAail#z**vt6Pfrw1jB(%_qDh!ur9JAqKp)1`I3t8b`F%-xFc)*`dRrUfYz28_3= z?7y|$Z-bDw4}wFlCe*KQzp%6%5hY0y6+vZ%hZC1gb%xtK%NY#dpLN++O-X@Tj4aYy z`Vil8lz@3*0aO9Z)?ZJ}$rFZ)Pk_=2zf$v6Rs}a9%o!)@&36jlcH6K1q{Yjn?6AO`35I%Ax7j zJd4YHDxZm}v$o_xLtlQl%DNrWgUk31yS=Xj0RcI!#lgEU-vu?QtphVlcIv!`GhPx6_%&xq|CtDJ7CZS0@T` zxBOiQSy#7{@S7ID$jFAvk}m-2TJ04(tnB>V-J6&x$!$kQ&ZhQmML~Bcg2M{O&%XBc zl}%KM=k3T6xZH`cA*Y0fW}2TP5^AImc)Q&Q#<4^VU#-!4^d8MlJX0^PP zP=COh`f$Nk3Wi%bZb(omCzY;&LJ30%c#k&^?$AKgyi;BMh7`_daJH__=f*UoiXxNK ziq=RW0yrJjHBO~Q+ByhK2G25IxW)FYYwzdy9JqhDFN9%E@_pR1=@;hC)~r+A9W2O+ z{smi}rw$rS2i5hQ8zNgUY|zUszkEF$o0?!in*I%Yn%{>Ik96^#VsW+vbkmtTMEF6) zas6IY--8~d_KG3D-unq9a-C+3y5tQvzRM3W8GoVeeC*(2^&n>iN$r~)AwQ(g!Avy1 z>BHBo;I#KA6qG=nd<#n;RysPrlC!;kmv`#75X7t_5o^x0D6u=^7sohSt6RGwf=Jx`cv zeqTb=m_^ZK6T^Q^ercjx6b&4a24rN%SsG9&sXuHHFw|5QXRIQbJ<=*2k8%V3NxUYZ){dMVS*-K)APC!%cTz*|kyD zfj(Iha?AtJF`J@a1PsjHDj5s}%1}(U6hDh$5<%8t4#27bp6kXYJf{mOpCIxaR^UiN zLxQ5Monv(UlmXOQ!J+Phnk1p{*iR5l;t-Es@qyMsz?=-Oo70;@K@}TX4X)y)Mg!X0 z$Qs<@Oq%jrW0zCQ30UF)@aEl`1Gl$vWyki{YZs76nSIqX;7W$np{1$%-psp3QcolNiYI`yAMYV4;nWnx8Feh;kT@>5?eSwVV(j#M z*jg7u<4_69wsE~LyDA)N_yfvzwsm-gn?izWofwN*GE?iXkkCvCPXK)Cq>BS4N+B1BW}z#6|cKXuSMz9w;uQY z#R1!FOx6cytkXAGkTQ($*Dx9Q@PcFcU|y+&MkHJ@I^+}I+hthoIp#3qc-sIMetTVv z{hz1RUFoDH8{5F45tyqf_0i+XBQp2z~*K@ zMXq-=)L94W`-3z0@Y=0~VP9Iqj`ui;P+vU5{9bXs;4B-_w9Dzt!qywQG^1TVthOcI z+N6)p?Moyx3@I8NGH$?xMVPr*XCx+nhbts(q@}^}k%aB06=F?sJr)Roa3C$$FE;QC zt2aQLXk`Lnif{X(E-jDh05CPvzzd4S%ZJgxzkrJf1=n~O00Ylgqpq`BwB6g^Bg)tz zY(F3n_mW-`Fc5=PQkLH_cRg$7W|Tn4R}a0|Lc$tNT#-T53lt9R8xs>rvJR}}M3$0A z1$}V$Wl}*34W1Mi+VeJ4!D8Y}o9VveaF@u>gDNc3HXs4+-;IOis0G=NdTG&PXK%(EaT=1EPQT+tnGWes21vIZ z~ce zr`-Oz*iyW{I9!!|_{q;dI8w{E3$ok&6X33e-Mg;xuAiG^niS=`t6}~0fR1+s8%nPM zH%93+I3}|I2$6zkm8~=Hj+}T2rG8UEM=~60BAaQlsxzBdRG5zy_nGow!Lq4yoQ*z4 z5r+SjR$oev+k!A9fyEB*WJ-QHAY*(GfG&+WQ&i)#bhWMtO^LBUPeUMmAXkua4Irge zkAAwdOGxsCeZ-XO&orfkf|vETbH?-5kt<9jpFS}!E7-T*QSiE&k5MMGdKg-E%Ts&= zpOZf+S=!V=0~iJ!MrkLo03IrCSr=3Q&pR4;yq@X3q4+#O47M_O*7*DCEtD|ah`@o4 z+Mc4~rlN2hR&?eOah>L#NmE%t~%I|ejzky1BFu^-m2cFR$=lAPtU-qbC(Y zrJ|;e2*UF1*#jec)lgFmOw`Q$fs6)gm4G2j#p~_ktxxLaCtC={7F%;+-w;&Lg9EF1 zG`v26+tQ>hZ=?gM`3wKz<}!@6ozY6_dNwabUtL6>{l2DWT`t#DvyLkT!-zlK5$4YJ zvwD~Fx0kh=i{v?&k)p-?VwK@e%XX6-V>CT0D_#Z#Md`*pT#JQs%<`r70&(497 zOd7kDkktm8f@gl~6`%=0y^YdRs)*=;BV*!N)iS-#3KtbNLS<=^@^5U!J!wmqj%>Bg z>t(lHCWP`VGmQApntwo%r$9-0A|-Qq39e@JI2N)y&F{ScE|$ms`HZtX7y8I2-G&+L zKl_hX&VWi75W(h=4MGI`yDM>@$nL3Q$uh@>w)1!9vgP|sj%UZGtV;6le*EuEODpN$ z(y^c8Uxq)nF#oA3x^I%=Lm8Sh8@Dw68;kzE{O^7|mGq;O)`d->n0*AoAhlF1*R@=>0#y55$R+wE=6J9hrm-!=PhowgVOxhIp1?3F=P1Y%pE z&@+K^2T83)i-(wxJ&aQ+#>nfBL`+Y26wDa~+K#2_(Rx&b|= z(fV&6j;*s0k6i#%a{C+tbN9&k(DqssZ*k5N8xuz5N(hwW4~M=Gmp`|^R{ z%ZTkZ86im=HkACFwd0H*W#7g}E|Z-Unhv+6YWwim4S2eMwQrGwSJ5miqu%jI+mmh+ zWfuMQNe9U6reHvuPF*PXVgfy{UftEm`6P;XJx$SC*kmFqa^ymkfI}5%2@7*4!UD$7j?P z{gW&*UXJl0!KmFI5d}|X6h@18K7&(F$%%^DrkL>mI^Q`AYiq;e7=3pW1IOu<VD#@56g+MRjAmI_&(|VuvAC>oB>vVj8Zt~}hxUH|3}uiY&uII$E?h=#c0fTS z*J$=1&1ih7+Q4MjG&9_PcdRIq&M&suU|=ZLYn6+Wo)hsanN~9pP$Ey9B)K$v?1>Q0 z%GKX`qSrK|Qbw)K#g7hIii&hvx;itds3(31HCJTo>YIx=n0~3qqE&5OSP^=uJ0%)G z88O=ET84H_1CTJkXHG6+?ib`W5f9op-!iZw;*Y`LR~6e^YurPlU}SX82y{74Vd zkldZoPKYw~tbL3r8NV*Adv+FcW4zyj;OV+TSklyZ%buTSGx)xvE)p7Yd9HIlwPaQZ z{)l~8rgr$yUGYS<$5MfeiyKuo`XP#NhR8443c+=I_4rlLO@@q%as)+xdnB`KwB6(Fwfx}l^QLE#N`}Tk zffd$4Sg2Zs^_=9A{#RQ&qv~UTY|*TD7v%(Y{sb*6i*12?vWQZlkq&A`?%-GROVvRZ zBm??v*!kxSf&gT_XPaL_)DRFIg#|@8&@SSg1qiBV#PQ!EXDe9y70E>`ba=sbK=6I4 z$|_(9Rz#uVp%)-;AD`zvM)TG96o=2~la|3^!vH`ZoKH0W-jghE6RJxC(Hb)&hK{@& zc@PGt#vx_7W#DGK0T#~hxL8-nt8pA;UY4%~Q>sT&*ZGaEVZB~)F4XL&(3WKV?su^n z#*j_MH;AyOXK7<)qPh#B8*_WOOkZcW1G>nRWL4Lwnz0Z@FtkT$09K0%d*4;_H7W%( zGtmUMg$?NE18-rD5~U+4G1F0Kp}nu##aPM2Grfr=ZeS(3&aj&IG8+Oy+4!8Q@KFfr{<>+V9}N6 ziF7XG)L#zLrs^7IrYS?@3XDsq>SPNuh>3fWoX?4(sV?*lQ)3N=hF+`kbtot(>yAdz zMRa2tTKte}aqKxw%~UF@0um`M0$v@7@p!=EkuV`6!lzJDtjfv-LC^;Z_28i_bW|kteR<{joHj}2S277p+ z7`&fQWMUCpPVR5No?#Abt@4t5x z#>vd#eQ1TnvSC*j{NgH983zJ=9L{FgfYfEj%Hj@RhM`QU>)dUX{vXo5F+9?)={Cm1 zwmGqF+t$R%#I|kQm`rTj_QbYrC#UCm@qOp~JwLnq>gw9Jde>U3YBl4mSnboqoyF@G z37VTj4cg!j#5Z__4T5Cx)Fg_OG}Xg1Cp=y44q9JH)jQrXX05mC7Suj5)=S3jU-0i5 zN>U4rN@;j}Lctds-)SZ}YcAKCVZi5hKygzb*j?UbsG)H=VN+^p=@a%`(6TQ3p0j8g zPMVk63WUxExu{ax0@JkE;hwypZA|L&Iby!BR&H!nHxMC0m3H?lYQ)Jm>hY$_KxFXJ z`z=cWISVs(>2~AU-3L_Hqw#XYt!4Fr3%JMN^91m=yB4aE6kLO1IZWe=If#81>MzW< zrJ|tL!P!!nN*6X}`z|wcH|gzSUz0?X(}E+lnMaEbYXxhG3z~YX#v=ycex4ZC#fto`BI_ zc2{2KYo1VFTulW1Vo`(p_Ti*lWRznZ;K{m}DU+D}xuEo!@LsM|aN!1b&sVZ&>B|X< zkhQ%*e^%87p0z7NoQtqV)H%1)O(Z*5DQ9$J7I3EZ|CDeuW*HK5s=ICf!o<{~Rw#EK z5~45GJ6eWn`~xNah7V(09cW%Q(sw}4nWM)FM~ns`1$pK%!j&W`aLp0;kPxQ~qn3BO6`e60&Xn1XgF$XvcF zD7+%}Bz-WAl_{>PxrfLN!jxQNRh~~B93j^Y77IDb4tdXuiL&E)T$|%{2C~ez_&s=%z#Ry(zf~;wbW)-!tKIEBH=Va!Q-57-${yoTAPG>@*a; z^u~vevI6)jt3?Z^22H33^1;!xNdOGKo!ykq#ql8g)-Mq?3VD>CnD zZEqY5rX5)Bv#z)H3>F;LmNa$Q^CRIynVZtz*~W~vqG0SpKBZOnxJ?(F!9#4!DV1J` zx4q@(>Ji>1fPL6nFc3;i^LTn!<m8FIR-5Ysse7-#<~W>&Wz~{WcB&u=LcOISn(*>CMj=yg zrJ-5TQww!hf@m|@wO0#Axgm7V+oOj#&2OE~gU;EtLYk`WQH!p#~8B4RI4mfMCr6rq@pG~PGs zD#E>m$tg-i2P<0VZ)=88 z*P*pdn}e$@c)JTS#SgY@#&XTx;tTzpgQou$i=?d_efEVh{Y0z43{x|8y8VqZ7%Gmn zK`CEssx^_45Ma`Q!c-qA$uaaWX0qb+fFE(-3X!PF1p3xw9dP%7(6gY$LP)UI3n)OE zDgKrt(|0~=QLF2-z(o-@+wmxjsk`9W-y!6VdGO~frR*43K^+st1U9ve9>_@=0wG($ zEJyo(C%E;e%g}?!6J(rZ$bk&X)E9-`Px0z&6QcHWGvK=NZ%4Uqz9X}KgBwsn;t32j zB*!pcOb;TqZ{~===Y^^DuVTO%?x%2Og-%hD!N&)SZ$!)T7GiKbOsg_PRKg`^-%>rq z2P2pTMpbDhWN$~Tg-L9?(_jO;etZxR)Wi}Ke}F2kk`c0G#N`brxrE*z_8^zsr=!j# z+pVO2iuV3CYJo{`B6;ISc5OZ_^jsq%U9rhp&RAH&0a*pyg=tmgB%uO}E_-3nt1iym zX(EIa5}h7pr!JSJm7pvEmP~R!GAc*2EfRm8n{- zlEC*4g;$zw3|8i&{cs{J#-mQ5EliVuUaWTSZm$$%FJb=7L9N%&kb{tRmDrELWdDgg zIU+B9fHh9~y(-IZk+3mA!p#QxRVFOFXO-lDr2uJz&ZadHgIB*qL9JXQYO4opsr3Zw z8ags0G!teE?Q9t{t3$*xTQ2&ns<1qkI2=~)-gTG7gNX`;IWAsTlCck1H*|&KZzXsL z=JA2kIVoAQvy}h_hKv(MfEO8X7aEtL4WX3}xo&k9iCTClQguAViKC9|SwkU<*b?`R zzFQzciImKE3aUo^<^c5AlwV^^nm^phLRucs|X9#)b$!R~QHk7Wi{D>n|aA?2u`Q+On`zBVNRt=2ICVS$y2 z5&LH`e4*fR1mcqacrj^IdRST1UWY$kr%_S@_nN?OS|N8lNk4NfzTd=exOEYu5BLDH6{dl4eFg#fn?$l!Gy8W#H1b&a z4u{WA@i0vpV?ri+o!`)8i|-nw1IkvcX~%^{me{91cTr{EuMviygYrwtD*cyxF+N>P zedA8=Nk`ps!>%|hzf{+(PKZHsx>re8Ire;r9%uXd}A=2;84-!2#FkQ zWccI0L%v+H_L{QC)mM!!uqY9&6@G`+dJTJhN%9P)L?I&uHJtD5IU|E0r2?|f#9vy< zf+SHbrshK?7M&Zg(;s@rUpO2c>@AKLaoW<6GC(VsZ2w8T+JFvICbtpLCdQj}{x+oS za$uGVs1)oxkpYFm+Fi$%SRwfilMOz4lVz>*l^A7N9O@2tLvPulOqH^&UZ%w8a`+(xi=U9|QHF_nkwnmQ z45f|`mA>~-4d)&>z$j|4T;!0|4D0nA;W`^au{stK9S5@<~>ySw&q-c3{XspFATvw-zVa7aE_SY#-N$fYXV<= zdb79^Qa@nUm7&!nj?We-7>vD}7;h%(Z|;gN*O{=Z6_Y1QtHV6yxkbgoo&P-irrlo< zYPd1QTT*BrR%Bf3cpgsR;kdclobn;>W*x~o7x z&~oC~UMC<}@EIwuo`}xUC&JiTl_Ok3`7s-{)vbLiFu&0o&H~_2{174 z=p!Spii%3l^c9sF@K6-eGb?hQM!j@tsD0QXffFH7z5c9M%Ze+fQ8f>tjZ3MXZ62A| zuMO!#jg$w*KVA6cS&H&%*wl>ttwH6JA{UK`2NL4rU(3ogMIwLJPDK|9+e(|8N)3dE zjhUo*$?KZ1Mi;0KQ(R7Dba+|v(UFn!bp9x38J0_dTjF?*nDBbbYZA#8*g_sbwt4s7Y~iw_kobIO9l=e*Zb-bk){c>wwbVzIt?PFIlXZuWK0uDtk@{ zN(wtK39Tv6YD|bu9PzRDsGWcX$yVV{>P1C94DNDqJ|S-75kaz_j@|N;DCxL8?X>OB z?v7YQFW9J&BE6hf>(q~R&N<{amkfPld86}F?j?HaQw&vG$@^+~X~sb73yyAojzP~z0`?QVO8Rd`HXU3ZNnz`4dNS&-UB-0U*cc zFV-xx3SYy%T7IO*4|IwiUWyGZS0R!~#A)7Uf?}y(8qVyZ6rTYhfR6LmKjX_SSGboy z9uRS>STc{%yHA&||G@{JS&>>Y?2M~Uf6S#Ddv`*E`3RPsl1D)dv01&JavzUwba+Yr zp_gKDKyOiQ!1Q#qXhzmU6T?sKv&cbWhXS?kAvT{06s-Dp zs{nZVTyG(_WDM3)q@8wGW+xi~_Kvx!J+!7GAMOTqi~oi_tHk`Ehs$RThW|HYsSnl$ z&9Ji^9bo-GnCD;6QYRX)^;9qHk74{T6#DmV02>o+pP9FC&gxlXKiDd&3IFjN>$tS82FfOYj?wyi~A1=D}O z#6}EQUGwZHzzN%8ApJwThQAU~8DlFj^isz!3EpZYz~Ww~>vx zap|f^Mt$<@Yi~O`X6H*hw}lPjOx;M7(P7-Ox)8>6UzaBqBKhEBY)P>vAsC;5E=xKv zWb^e_T1uDZv$=eMsi3OOM{>`|+u$$e$^RGge8?4M3y=fR5=D#orPsIIN(~@Z5`6h5 z`W>Jbz1p$bW8d^g_m^=@O=g;i4>XwSzK!RxYxGjsL6K9)p8DwKm(dUm-=d{)d!ugU zXekDEdOjA`bp`9K9Qu_!D`GP9AeWBfC@A%JQdQgD;_#s-_I1|!UdgdgI&VO>s_#u| zcYwGckJFJn7+qZkV)ENE;ufvHa+?CgMG)>ilmo%;cj*=pQ$+Fxv7;nHb{lfKVwpDo_enMhN2`ew#EzBA0{^v59{_7cocmDPSd zlgZ?8CZpe7x^FpLDY4g8T2T(p?xU1cScXWF3XjZi$lxbJS%xDzi>9~ydJ+igK4L9n zA=#Ti65$B$tAtr(mAVcEh$mg{5xojf1gpy~lkYjMH^k=CM|391{1JVK;xT!N;hAoH zzp|{^uoU!kVfYEU^{NdUSb9I$u$=FuCQvw1RsGl+(Ou+n(wL0c0y=R|R4i+K$K~ed zeCVK$Swni`YbC-W{hZ>K=$hD6c8_mJC`+w=vrB7{aQVQJlZpwizrah#I$|}QOZKLb zmTkR>ZGum+Px}C_*B)y*z?HuC4$_hwLXWE18?o8Ew3?UadYzG9MK3BB7-JJ7M`^oV zq0UZ;E6a7ham}0Y3s|G>9X|}*)HsJX%674GxG;8IzCaGhS{lV6vYY{u6gS$!>gC3h zLtK7vj1!o_JXvCTBybVYKMhG4={I{}Wf|huReO+XIr`jMN^ds;rtt^WNPAQ`KrWD~ zdz4GsNx0mW%7!920H~YA6}`EU->oF2S$2fP*!F4V!pL?;Bu5J(HD{h<^Jz)O9PbFj zbAt3o6o16U=oUNF7?0xF{gSdAb5PJy^oHAk@=(=hOxh(R?Fk(E6Htl6-fMeLyu1ZA zyR4-gRo%4`RC2>yYuLsgbPsF!0qTE8Gr((|q2SD95dF9uDN;BIA=je<}t` zzD`N9TH-=E3rSvU7|oMPe*V$7@Pf^%Y<-tYJ3uoZC2*O7h&zd>SvXJ;rQB~WE{MNKu0V0OhcDgrwr z)fSWbd$|z>yHbqgruIAtLh8CeO3mi-=C$K{@S^Q}pZJD34Lr@?&__a|`dcu+Y+O8K zVg4vh=M_|;2_p5!V={xarC{sO*a#X5sT@5|P=$e`D5*Q z40!#S(DYurc$mynCE8opL{(*rTVTf}K{h11BI~)gAX7?^FRTy@4ogD{3y0dJ^;~U2 zH`eP2E4Y?(Ugf-9*!DN7mw5joWfr{D`N>dS)Fmv{u4;h z;f32*f@-}}^GfP5%%w-+ML@OCBuMDTaP@lOFegVdzs-BY3rDZ7p~ZEQeyAf+mecJS z+rZ%ap4xM)X-#z}CZd3}JI+98^5M1ewK^26;uWF<+Sk>^G7z~t#)WGx3OYz-I-rQoL}Ppn}8}uIet3!e3$?*&DZ1PgaRVF zu>g7`$nd5C+uvj1TK%(6ENT;=}dIUfI#){h_p zwyR*r5r(J;n89$}eI?uZ`+0-Qp4R<~5hV#mPbIHr^L?EoZJ%YJ#$-tH7@XkM2_p`V zd`u=wPNmp$QQ64BN{c_udo!~}K&_kY2`lp{S*tu z1(iLa&)Ck3AOoi2<7#9%?ZJ?r1Yl#~g>GXWB*v?Mb8*7lNS1)HVKST2aGg5TIQv@U z-u!4ZxFm!gTz$pX;9mBBGfVd+!oqRQ6$4b>L2t`{T0g(JFGzJ|9FAFeNg5!z_!y8D z2-`axLEw&37H~~4#GkEEqRt9O;wss19X%vufwY7S^^_{J+$KMKi8`ClTLU_h%&V%0 zUM9Fi#2}wWP1wxiwYB_Po{ul@<|zPnj;u+AvgaWD^go`ZR7WaR9;W-c{^4ouR6)GI z0(DubxksShv7w-lcum>VTEPe-bz=BXbAQwFM@~Xc3!iTXyU!WLdl6!7VMVu?ozRRx zh9lKS{gKh*!#h7;hk#w3+QjydMd9+GW}w3eAJZJa8XP4Ft$kUWHrU$@g_fK{L@^8( z#8pu2#^Yx9f@5r5ZAImNftQg!riSeZ^vdJ#^ao1}Ur(!)NV@`G^MKq>o$uuqh;(U1 zz&*&~uDgmt@Vt`Xv7@)!zh?BZDR)X#1Ct-T~zR1P6*M)OqBdV3omU~JX}hEQCNfX>GQugN)%I=Hf87}gwzCdD zRQ?yC*C0(6rwdpMZEsDgZzJ+-B06ajZim10?yaO|9CGf83}$*LCGj3%c+l;c(?7z(ucrF;KM6ULcO zKh4-!kh(dk?L$lq>`<$8de{Tb8a>9_*x$`Sn@rK2n|HhxlnA{6Y|CX`}0a zm9^eUxhW00RC41svVdgSwG|NUc~R@I`An^023AA7GxJ*!HZKp*7a(O3DHpwT)FNRK zDp`+iFp-2c!sQC$>GO=;mmI54*_vLReChT4^Vzwr417Q1Ja_*RxaM*hAy^7JxffO) zlCuIdu$G&ez*&g8#l1byX)tfJpC6eTA4*)=nls_)Vu&3_>-u=ZaddJWs_>Q6hlXQ3 zb*@~S@JKYiD8p2g8(-&v;ogLz#`8-sh_59m*LYS%P1G6ddM{QsC5ok0_|MLO^x`s7 z__L`mmuZ6C#Ppb$n-y;I$pURWrcx(9Hm{i~+8qt&lDBoQyH3?T79s8#4)xCE$yB6R z7kWId|D_zIwVGBCNw%P3H6#!uf^W%fa zU;=;t;)Zz5dwem{z7?FKHY;`%v0Hna6T;9EHBcp1>ga2QIH=~8q1b&(UN)~c;puXq z8dKM*ODblI0SS6xgKWRH5I76^6d4>2ZR_2%{y>S`4rL1Q&l}w^2O>oY&=P90^0Q|C z?a`y0ph_~Qc=E}xu)z2#&2Qc{SGCtJ%@U0581zSl{i9*llY?yH51+@r*)uI@EdxnMO?Bh#Q>5IrdC#WO zuaOFNv7-=FP)-nM{{mW8z%6dH1zLs26AFjhKH;{nQQCOpU_3oV3*)}j`P*0kPuqYV z4c8jyEhgI+mIrWlhzrtHkHF&w1+H3T;EW2iJoU9>EB>}u;7!z&*vm|a3uCH5lc?iU z7rjzfl$dy#0{PA_YE3BcnOUogDr3^6+7-%T zh43ogKwpSNGER3}qG)>>+i2ME!-^IQ9m1VuZhNm_q4D736AX}AcXsTx$(qJyogSfD zR#`U(Q5yqrVjhoSJ}nEy1@u`fdv+RJlr22p)jBioi1G}Ej-{8Y4e~Y9zv`Aee*eog zgD=o?ZW23cM8q&_M&~i}Bx6#euXGtZgHT#lG6_j)zoR(6ayi)RSQ)?KJbdm%<@|-j zqA_y;n^i^Qc_;ndl6=r>fZ#nb_!Nk?d~T?@@TWC-WDW>09WQzraInY4OuV#4`GU*m zF(qA9H$5)YjYH21^G_()Z#%R_LYA&>Yiz{SiwJ1c?HsJml4dl*ug!PN{=Jyu|G2fm4&f#j#eJjC({WEKQ zpvct3)3tqFOg+k6VnCrG`5-^9DflsCR>vNT>>Fc2yUr>Lb!%5ldFWW222XlIxPiSr z3u*+`LImROm0(2_C3Pv$Z(dnDCs|$l1m1Q}YiLA%cRH2e(vb@DAc50>7DCS~IoYEv z31iZm42MS=LnA}?9ZU?I@H;c*yu&KA(>d@Aj(rYNbU79;BHDZ-Q6Z(JS@tMD%L!T* z{QO1P<=QV)mG=a0mN&iGFunNSUbz#fwxfemr!3YjUUc9PwHQ1XTW0RP zSG9&Gs)s&PVC0&#!hFfFzbl(1un~w>br7*NM1M3(@;xY&`Y~tVk70!MTB9`u-}^Oc zg*hjFd^AkTx;Mi5a}V>S9_=qOKN=zF%WVktSu(G?6$_}!3b$r+yK^^Ek^2b?vU=Q# z*UY@*O?ql6S_qOPHqf*q2SFd-o2YmBmo;P^BK>L0WA|z}?9K%4? za{gf6!J?4hke=|{+fk_yAV^DG4mS|$tF+}auDmbdJ zEoR>=Tx*E0$?!t5&PqD`-hR2abo+k4$Nc_aP>>815|az7Z8OFky%ItG{o?ab8FzP= zL?VdG)5GXRcbxF7>{mTqu^;7nqd>Gb%jij1_&EqE%LZsDG0S3DN1xQd24gvM@c~Ji z5LZI;ubEpOV|O#tAU>;Q3;=e7ndRrVNjEcGp{S?`%-@~Ek=+|v+ZokU9R;Lm89`S> z_%b1?l|lwpeCmDO0$*hsq)f0hhk4He zfm2800$o_eCJVtk&*h-IHUf!zHpaYMoE{|Et_5dliQ`cCSb~|@gjLS}fj&;)jcc>W z8{i^+Q`lq}Zv$M`R~!h8;8tniH~j}zRowS~A91#R6o4^{J-vym}!Kbh5L zUfKtFQnFP0uRN8E_^SMa^!jOh;y&`{#~1zr3T z@!t^u=RXR7QU)p-y!}5h5cT`)fsp?ZVf@c0fIMV4;KhP30PyI4m7IVW0sycGSYjFe z|NcP$u)4$X|DCiA!HzjwepeH^N1;1VP_AQWSu4I&YpyMdii#$9OYiRoU%D{l-^!{lo3KR7K<9s-O z)-ZDd=RNlDRj^e0(xhr({U!YFiV}|2D8_zVTa|5E2pn-}N|{8J=J}M1@1v!Ss^ZJc z(pLV|pSPs=fbnx8?J@0oXV%`HK^;HRpiK3Zng$AH8{(5$SBo9<>F zz_ciD>q*v;$w^Uj-Elt|96oQjt!%5F(*@i9X+em16?hDF@ngJf17{RBSo3SPb;TTK z5Z}+YH-*fKJkoepk4LKaht`9*8ObFoshc&kFF&eApjQ`Ae9s$anjA#`p*ouP3)|{K zB@2`F_3CJDnmM${`ea9++5Y>YGJdHCU;p{CyCgyWwtOqb4Elp9QI# z&p;@J9Ucd)JB8gmft8|c%k4Y5BOZ7st1NS@AFBj8{iVp@GNjuHmmhGSZdXoe4N_j8 zn`g`RZHP(+kSuCbZhIT9h(}lJs!3lgXBjtx7?w3(J&dr~Uzned*E~MmZLPRpUI(f= z6{L)1ih`G^VF;U_%pK=N-eZnGK5yFJ?CJ!-YkQbJ(-A)5yS3v*#3z+ z8QN_z_LTIQoyG2k`+5(hzT5;!R z&{}}<@aW9ys6&fp0$1hm*)`$Zr$ijSa4A!PARSpxqWtq(3*^tjFvpp|Ba8YD=wUS z5cKM_N+?oleCbT`!3A0&Q89FE%;H$0;<#%2n|gvY*TVpku_=tAp!)$iJ4ZYC?!yzG zzorF@rxKf1T>%UAf}M*qD1|^~GS}`7oS7%qX&H6iJm${M0Vt%UZEfJCh zC_g`a7&mxHSrL{PH7Bn1!G^o-2ORUQgu_#k1eYW&ckdKeyD{pj_35&rIQ#~+4hi7rx+=Z!SaYKQRYsFc|BgS z=bgm4XtzStvFsHt{SY2lFTUO#`_9pPCEf9Unu+;2*=;afswSlg+0}bFKB<87sx|Gg zj>-Dfma6Ke`R`Xf8bx#Lq zlNUK9w={i<}rv#R(Wx^+zEWxQ za$qZCYS~XfExi)q+H7L znQTT3`mc|xG)_nO-_u#qqEGyx*v>!R^3!eB`Kx^3>P@Hm?lm15tq*m)YLEK-J{OD$ zhBu^B34gxI@NKC$VA=8|PajEh*T6es&;y9*z zPTSMbo-5AWyw@ZNBBT9?Xi6R=K!e=ZOZ|((#DwxU8y9ybsbK+O9me*TNEEw`Yz6{2n~`?Uro&$Vj9P+@u3w zd2KCO)5EBGtFFl!fA{!O$Xjwul8tCOm92aR%Zf#QcPfzYgA-UrZT|3h*IEE98ew!Er* zBSIHRe@$(dp)$}2+4ke_Ic8(46^yq7Sv$KA;5Oqz8%R_I~-2pqs z3o`f3XR2jeza^t1BGjHCp28{-+38VL?k`B52oq9VAZlx&Kd4%|s!bEoi>FfvZ5j@2 zOMY_@l>WrXKd3yoFt=3L;lJvLDx$vZbENMMyfux0B(qP`R0y;?{d%+gK=FN_8((&M z=AnO$YI89*{zX#Uk#i;Bc;epi`GVx|^_4^Otn1>=Md~uF{3JgNBhMF(H(Ffdap6;; z)re~>aFG~MSB8l8NB#M6)DHB=pHUEAQofXmhR)a7@{Z*}6uveomG9{e=fT0&On-$5 zcA_Bs@#U)mf;acE8if8Ti-oem$c&tRC-}yH`j;!sz%*`pzII%xQ3%WVBFAy&W;Yj4 zR(IR2ho*SjOJHkGS1@koZ}Eww9C2Ss)3q`6KJF5io8A!V(31LOp1!Fn6LAUCyzMu7 z#qY)+{TT3;6r|*AC0Lu&zIor$8;p{`nzGu8sb_^NS;DS8>(R?#AQFqK#*va5+rB)9 zbyj0@I9rh5^oh##v!9;p9_}aIBpx{6aI31Z1x)HrZ?t3N^ZlY$Y2w_idQ&HwU&t`* zVR}70duKCQp?VMBz3rMmB)%K(Z`s>9x@q?$7t(cu9rUbI6lH&uPK_;W6AisK@HelZ z2_D$QI*f72nxyQ*EC0P+PR}1Yw2JfTvUGHb(?HU_n{E1y;Q9EX8If;~Z{Sr+Si3TG zlr*3M13q41etOlUuW5QKN3zi`OY(yy|ywC=0hUn|>=&imFmUfNw-roFfB@JZxIj)i#CtZa1@mQt;Bptr&x zTjA4`mUJ~MJk|s`R&+r81!mpxWL~WC$6C|_nzY!|KExaoj;eYWS2p70=zp{~SqXpX z;qB1I3|4TG!orOtT>Xq*5PZv@Qr9F;{p^Brev9!B4MvaH+HuH;Ar8q5MS-`{2w1aq zoL#*YCdw8>cUGoN7QL1pQq|hTJ!SrJhvIv93@#-7BdP(`!<|{-K9}rY)ug{X((}NN zd3m(AA4m5i76q~G&B%6Vf~Y(eWi*uG^tpuT5gD0lJ$c8VI=V!3oC?&e0wOULPRZ@l zfBx(z!ii_ChvSo&CK~huM_YIbMfx@dF;Hsiz;$#qEhMKP$?46&L+i-~2Mb9BD5fm& zX=2dl^r65l2yaYIMh8$(YBAG$sQsvBkv1BA-`2IMmdoCTNa_H*l7#!W&RY0nl_?|rcnb=L~;Wiz?$?-CT%5CNvw0pVx^GsMc zFy#lp`dR3Qz4b}g*jPZy?}*ZXxnSP%+WTW1tCd>l3?2A_YP`O~{wIaf5THO?JRzNZ zlz}ktw9iy@5O0|plILFqnCG3R1|lSu-%KS1&_#zcuNOurDT6t*;k4>dCp2q7gzn1H zI4nq8O;O@--yr&jCWq#0JMHak88)elc{1{Jit-uA)$iNgBG6q_z_WfuGU=OZQ5GQU zZS>AS-!bT~{(0^n)}O==Z?yUQf`QDnh1mXhP>gWoOXzlct3*&RIS}Gr zsL)Vsnnxj5aQZyNcTvyz$A& zU^Y#+FLejD#=daRYKb^uEW!r^A1v8iMXx2)C$N8Yn=ycVMfR{G$LwcVF9U_zUiG{V zcJ~h-s$)7TF}R*UNE}}|Zv2iO4gYABgrk$I>S<+%+Ug{zdmd_HJdzbU2o3k+`^e?ayU;tZ@z#~57`718WbLP z=zsF_=Wa@NU(qA1I^CAYQpUkG@u}`?U+~(gmpLUEk!ZsZnOR=N&JhY_#VduU8X z5cURnzO-NF?Q-gS#Ba_XlH%&VnO5K4VL>#O-<0&lsU%3?3+8cdNi*}%*R0&Ts@@%-ZiQw%GzC%G!MtwxVlXtRG z0hN!b2xJ8JEDc!TEYWI4;)r}CBGVS>t5xX9#JWSuP551*ep7D6$!-PJ8HlTikReon z=;pBAj^|$&!~5;l%c@XoE<^%0BgAt0wSOxe#E|lP3YpY2s^E{{@b0N|t76m?ztU`>He>4%ld8Ykr>ZY=`p4u|=qy8AOLW zmOSuUPj~^_{TQ{DE5^+`-U^W|La6RCMSr+%%N55Jq4PixFs0!L< zv~#)4vZO`4?QY%>9;@1fP%UP-xv_zDQl7HqIr4XOd8hb{v`N#GTW7xZFH|b%i>6}> ziQAN+MiUTjo9C+!dpsNAi`pQV$L?{f6&NN8Br(nhWaLXde;*^YL+RbKoE;AnE~S^x zZhI2R1Q@ayi>nPSsb6cTpfg)?5R+V@y*{2q!}e2A7N&&dW%nAB@8!GnAJ%-|HS~Vm zrmEWVjeeWn+~NHqZ@Svg7rTJiF5VQ5-6SzkZzl;-bgjwuapUo3v7Q4%=vOnCAG2Kc zbpy4r`QcaTT68>H$HuV&t#;x9%oM2jA*S)i7lQ4wG0>G0CTp!3dI;@ZUs75b zO}0vvT7RzdNMMd?te-D>t=GYk9yYfQ@dA`;&v`&JXmhkTgOLhYyTNMI8YMJi>?b}7 zB!a-xChv|uaSv9;c$U=yVtMhs$YQ$$Y5K}83H7(+ikORk6d>2R6g}CJrIf({3$Wue z>hklDQI2+{)-YTXPVz=5OX()asv)so##*OIe-1 z8>xJdv_|wUi&{F?PhlG;9!y$Y&?JD;{jWn62^jc&DIY-SGj4(w>-zcw$ ztRuv#PV^wm78f%IQG>hn>=L}bDXq{MR&-W?yWI3Ck+KxsZ1F)u$Q94ek~fOFDr{lX zS?55CaPpRCDZBhNG4)rR`d39g(>5$bB)gNZ%JEE)u|-+ayxc7-sF*$5fWN&IZf<=2 zk6`=R0Yr)BH(5uP=X=CqvdZNFef6GiszL?p%Xm%iG0V^IWvicesRv&h#N+>9+&!^^ zgk)sF9ZEdM$Eeshx1M_IxCy>V-v87|*VRbd1y>`VVIAr`e_5A*IG8+A;KMRH$)JDT%YP-D z|12#@3c#i))z$?5^Xk8vax#F1M)eO6OmVnk2pe!Yadf|VL!MhchkH-$?+dI4vysqe;obI+& z=~ulj|AN~uA>2)zC@^OnCmU4+5L59_&gwf~a~00-@f=>P_!k+^UiFHR%7utNWErd1 zA2hQRsbRGjizGC@xPiv$*yoLI5=2O)%Qk0*v89_+4BPeeTkP%n0Ok%bcZ&|ni+Kmr z-iIJSe*HV)AaYnR{>Rd}LzcyFZHKeVcTIKAvEF;?YrN1&u$1=^Yi!(XiRZSs5t3x+_d%Nc#0STZ!02TfgM3dje- zw8}&rxzZU-*TTfz+|QH=i?^h*($kR}BkOSbVlxRtKC3y}Seha1Ef|3?C zEeMr+u;yz{(aHX)0N(>eHD!xVkAB>J|qk4${2XP-tw;lxf3V6(cJqD6U=`uhrng{08-RF!H;PP%{ zH~f3K+k~;IQ-Yo&wcFrm1qf98O^|%5_vWJ)iq`?#zSPjo4P1ZzdXr&cemH#wt7Kmn zErvYfWl-uPDc(HG^_5DkOD-hIVV>?R$l5s@$>D)*h}R?X0wHJDKnlHywd5PhmhQcXib4fZcH&{ zzR{sS>V%omY=|_8`STw18Yc6#w{9G%$%YQyiW}yMN^hHtPhi@|2q}@mB`0Ss(4c;) zpu-cbgQpv1@6g77*Fl%R2DtFhT8I-tUcXIu;Nfk`Cu#I$ zgPOtpfJo)0EQ-24)o3b2c@Q){_72s z!Gau`mys4w}aMwOc~7OB*F$XM1O6E8vJOkfWUD6 zd1a=~Nd%1oh9-*vF*8OKHEVin_>Zxdty-a z?$N3VO%^i&((KO{Q5e2CLwp0ftE%JK)K^58&TS3}hsj(H+|*4F;I7RU%nmT$^EQqB z4h!X^+i#)x2YE(*^|w8RXT^4pul0s6n|$rT*K=hzt?sY+ih-{smp0LXkaYdca=HgT ztggrnq|fX$X<-1ZYoWWouS_-z_F@)0htmn!JlLzbUVjeQL8YN{YLny34RggPN_50s zbs|d5`KmCkXRcP<$JAQ0h8uf?G4sDF&-V_nuJl1h0?lFr7=}(;Je`$xUSy9ZL2kX)fA5jZ|Lt$gpmQz=VbmsL-ztrqCmk+R>yYK2YNt0bpG209&Y z5Qw+67AMFp>Scjdm6oU)!TU@|a#skwv*So{4q(@o^AM93s-imgIHVM0JGxd(StF|1 ziRhu*mD)%*RPbK3-p4;-fUJ$~uQtFj9&N={nvhl(Tn7%L+vA86^4*t!OVn3#DwL3U zqFVqDXmP~U98f$I%lni6L)AA2Rvx)zw{H)oZQ2_9`}q(CD zFLIp?-taBrMTHy{!}>dWp{a#&iT$N)cNxd>bp#;t&8Va08RW^~n+EGgL8CCvVx%b) zzwx(Wp)^y}xG_7IPg#d5O1vk|rA`OR3lAl+tR&X!lXY&R;&6gLiC=qS&@(QEHJWe(R~h|^)vAbH=FTC3wOi5mV6P5LJOj0TzC}l|CTgRZ_Uj$Q z;hKFJ|LOQxA>81UZ{S4L+&1Q@qgu{YnuR9unrv(T4z6Cc0`&6uyLNi~Zi2ei7=y?=OwwiRf6+UQ50&G(b(H<|uaQg}?HRdlYcFAgwy zU|_KRfUPj1dOoVRCA-yn4K5I$oIA_mxH1lxCn#{LlD|?yr0a-!bt6kql(TZ+#ft2? zd=@2X{QdRPfaY8$w^&40B~Rg#GvOUQ$@N%{Yd}~%xwz1orWv-nP&mz&hp|=-nHfCW zI9`MSF>2>PqIk(%lMjXIa8S5Cng=~sz_g!_d_!UB$MwDA6tyNS7e4byX)WrT%Uv;C z5G==b3zu7a-_`vi;JcjY{(Il9n&T5_<2kHfSx{81v~$(ToZ-Cd=n(%mOLY98@-|(@ zD9}V|HY1^OAB>>4MP*GxWGClZ|J+{$lr0h}oG$!P{r#mb3{?8&baA2ofv2y8k)IBF z6*8%XRWy)n_}#!#Qn{!+IH*63HhB>PFY*qEepi7L)51OA^Mw83XV0_cC8tSdfzg!Z z@sDrg-XV%6=1OaQwj83Y{Btj^(m%GuuD zw0+%xtk!nHp?4gxg8JwBbl3-?%>2fKYAmWK+=2$?kDSTfR8_hff0DEn*3jhw{rX+*LMW1-)oGKke(A z9?~y27u0Q%d}O3Jg7^^xpF5aFO^Y36B_N}@#egE4yx!1vJGS}$;Ag|(AFfz0g$psp zq%qmeVN<+4)xUZBlaFr`=%gMsniTqKk9OZ*+b{2>$Dz3k5e6JsuiUjHW_PczG7!Am zGZGHnUt9?Ku<>|(fUCghC@hK+;{+BDNfAFhc|yj_Lt#6 zCD>If;Z3vB!ZYho>GT`N*|GHYF}K;@C9);S-HJX^59}%Vz}}?a5b}$mqryT-6+vcj(glBfbbFGR!OY56GI^8&o228~Smc5HPvneq%hw>mk~HIDmo{ zEugJYaKG}FvB6f`4#hfg0o)IBG^KR=DJTtNE*EQqCo(W2o>%E-V|6Hb(`9$dkHuc# zPrcKV>pF^EWq8>Gs_EkLFpLr-Vm-4{2%S5R{?H>ikquJ%<#Yy4VAncm>waSxScY={ z0Ee?4^AQfU{*z?d(e6GY$hYyX&&wYp_O<`nF!KQf6buIr_da)c1f|4t!sNaso%4VS zzmA$A%Y!;7Frm?F!L zXby+{>`{Vepa9gdUm7fi#SY-5HeGB2LdftN&KQ9_qci$LGs^VKs_#%F)y)++e2*!m zsn~iOVW}A^EqSLD;i%a!oZ^z(V+}|7 zyA--SOHC)G-UJNkR2FDjFu7mEpctXYqJ|Ur+*6M7_z;T7u}PGH63LMy_P zN@{8Yk5HzFzUbD1px-3|M1J1&qE6-stEhDl*_!?=rOgY8p~=*Y)KTL%Xr;-FJ^vfv zxe`XA$W88)`5aqnBPPgZ1A3$0GP^=t^Fr|3({jMS86nCu4fE*>?q#4a|M~j>*5bCs zjN6TjYThcun%jlQ?NR{-D>1z6)wCEd`_71t5(1UCsZtFc0Dm!*x!$uRkTkYQ79C*y zd}R=@W1f;N^*Web|A66>!4bT|-gmUTyJaH*pWSQfT*LItjacN*X!7tgHGB!}^z$S< z5gBZE_*Qz~e85rrc{mYW!A&c1Uy?{!nI;j1^S5qED%RXcWT}M3UeesmzW?u%=*~WI zv_G*kGj;wxjE4B31jdFx9XQeXCT`2#(kF9;L>A>Wm)*l1lo@k941e3wt0UY+JWuxGWfjCP5=U^1wSnoj0aRsdoWZxMx@^E;!3{6&5(d#ofgH?s!AQI-{B zX}lC}Va~RzN`l2P2QrY?(8U@N(ng9`Zfa@Cq7$Ada|CZfh|{V2#2Xqiy3A#b;fAkycoQCLkJ61g_-$YDVQFJhw#u7r= zOXn)q)L2DFP3<_whH}H zQAYAT*@S5=320(iC~j{+#a?9x5~4at1^4__9&+Rj1a2*>K)#!3RB8BZf@mO^- z>AneXaD~A`nMK0x|OFk;U`E-VIN}1>l%dkhuQi0p0YM{JNm*a!NNRS7+PAs zom`@7!$TbnDssfZB)Cz%xK(mE!|6EwYO`5CgN2GWk6Zp09`8BWVz5vKMD!{i5_`Si z`sPIjR~I@)@CbNj=%6w#VD2WHzGX{zK7E;ys`uTj1?AJec`fK^xr;65QVi)i8yvuJKQ5i|Iiv5ugFn~8 zMAkP&OVw2WY6xSQyX~XW(!Rd+*z!oPxsuaxN5G>(cQOoTXl(Nv1!9ARlHg4mV99e2 zNM=Z03!IZEi;vJUua`>aZCy@mK^pO%|M$(l?btBa!_9(vdh<>4U?oHD*wx;L;ngs% z^d?3AK&Qy?$1K9^$Pjw;mRG-}2WOtb6qD%VC2t^NWa96pK_yuWpq5$A`hG-j#@jfe zFCsv&^>VX>DB^Zi`A(2(T=_x#hw~}s(YE)TWq!Cay<%?!OzNZbEn~Qv5~?y6N6OA} zTJzh9!gvu{E=BFj!YN3>J%2n+U(dGcwrY`vU}m&+^CG!i;2z>Z6?|S$8|f(H08fYC zIXFE<7klj;N&k;=J)5f%{va)wI|qMvJYXqe;JGGdr43?ZT3Q6XAN0Bjb1gg(w@&_p zrfO3B3%V}pf1$yCml*=rMHB2t=M~KQ&CbCzd@HTRYI%r*aw#H~hvs^7e_Gsrb76@; zSap}gz)nrE_JEvXQK-(sDA4Qf98CcK7|Si}#2lQ8BS^i|jsEa!=6I&J(qaQ&yl@`y z`%=ap(CzOGoU(H?aO3D}s>Y;A;oft@2?&5%;&5qmP{8RzC$4E#v%NvAH1g*1g+x*@TZqMY*A0tE4FQtzq(NEDW`e0~lRj=-t>{ z_zvzNvh{GV(<9s>=CKVCmr5{^T+S#84%&tHT(BAJ**3_c#h3hOIqH0&8JW8!6y=m^ z9;j6+rJ8&=+?=@{Y}LY`7m4lM-=7Q)yNm#N=24_l2l{V&o7GkiM#c)vn($BT=Af?D zaN59>p$I`zkC*D~UHSB%JXI*ZviOn6PTDxk54r2hyrWdOWexpV%rAN5ExNDw_yH5D zeKAOnc-rFQenDj5ZD*p{^kLb4ra&=%L}`@U7uQJ>1ZRL>_Gi#zP`L(TLF7 zB#I%|4|xPKnhu~Yu#YV$4mCD+j3l!Y!Hl)DQ1$Nm=kQo|XXKP)N(7VB?re$jP>y$A zv=25|Wsf7hi>+Xe&d8)?$d-_VCkzNq`so`F1X|a6xJYbQzr&R2qm>479P2;VcgN6o zSbX2nNDqkGluP`k*4h1R8WQqqZ!P?L#;f$r9w+W*%)|1RTDT0oNW-M!<&+xFE;x#3s-MlXgs>o?7g;_!~<| zMic>bz#_(JDW`IVXT!@Wj#0Ls`1mlnJnrOvrC?I8B0)RG(4q(nOM%Dj6`9SKQjfA@ zhfH+JB0wwFcuWJWg(?q43BL~SlmAKM4K-gm8$*0XiH0nX3ojvz^);v8ZUQmO3d{_>l87rZ;>4-T^d~^h@{q?cMc=p z-NGc0$AJvVC@J93DnB41H;}&snj@7Y=y!8OgeBh z?_OD}RzvSx%t-$OlR8vfJ&H<3r~6HtHpm**Nrnjl`5kYC?0|t7I(f$j88I4XM}ftD z*dxFBGK?%#LPCWPu7=X742`#H3C*OC@I;$Onr5_HqRP#Ez&!|64l%Z>Sr%XFCuGZ< z;YM8I+&7i}j5O0&MK5|Jq}>>yO>}5zsUyYrpiMGmYogXo$D>Y~)cPZ?+heZql27|f z!&u_|smIZ{3x#bI1rK-)I@$agJy_Q{de-QC8 zlcNK9gidAuX8cbHlc$ug6t84g*8TdA^=;hKYWqvbeB;n;*^6e2PG?3hhWA@TuB^+1 zw-g&VHZ-vtQYPVDe8k8RgU)W z&^{htM?daC7;6D(BvV;1;> z92mvelt7&1T?Ey^2tk~3_X$#LbC$AIr-X9&51O}JhQVL9UH>6v`O>=C(WA^mYeL-K zKvIMx1&cP;ID;2xHT3_SMN;r+*ri${`2Bg3xI13L(+-vutCdipiYG^R$>X$7zdO`n zfYhPNy*$cB@l>4@ZR^Iv;4__fougks{ioYow~$|SYH~bMM5il!Bfy=C2BV zYSK^ExPk6mOsX`24C2yg<d`ypW$cd8<+2+U)XbCCF>e{Tj=IC3nY5DXs&4xFh3VNo`t_t{g3#27+9 zZxUEF5?IoPF1t`{zeTC3eW^1S{@GemgmXC2K3mk9@=FTmuSWD;R}tAubQwwV$Dxr@ z6;}h{AH<*7T4oR+bH95dkVHR&46Z=25Uy^;We-d6iHzS{5X|J?dFY&I)2kWX;2qpG z7^am1I7s0D6Ip(9{v4qt_+HMv-uF4CR+W3DR+0+nkUt4A0?&f1t~axxZ^ScO)JaSUkkxvrQ&;Zwn8Xs4zB}pqtEx`skk^ z-ObZ#dMkH0^G<{ccdIS19oEfp>TlO37Vl~T9Qko5WE{lJ!m`Ix4I#f?uGP zSdr4hWVYZeJ6Zg!DqQlpCXGQ0uo(xQa(~N`2yF>kQF0BiQ6wl#+RfAw(O$7#528_= zTcz!S6>nI9t#KYBds4278uTuVi9sQ>dM8qPd{AoKMxMeNvgxE^e3Fi^SwwP9b?LwQ zSl?2}Vd5!W=s$zqhLR~p>Do?1&vSu2@g%uCqYpQcYa3 z4dXq;Ct$vyyC%f}vqj%d1Qmd%#{M^v-^Y!2CTl2`sfRn3DO{y2gd zk8C_cgs6WZg7PW zD8l3@&aOX#Rvt7S@-4WBSt!(|+iJM8h0^|IkvcWIR)BxrwND#=Nj21lny{XfUX#=V zrYa8+0&zP0aSA3A^WzfM$}m&D62_m!lB)Nieft`Qu9MrHh<$LEpwyiv=8Fi|tRjbh z5Q}huqF6yf2{w3)Z<*=;LT{4WZSE}?QV^GQAWDBr)nA$tw5yvMtl5g>aCm2Jf3xTj z0Kh{y$v4?UvlOexu8b-#WF!hF$^2kq5;vxJ&8fW!5nYxD^#weKtMnF_2s_!XB0b7o z_M;#{o8UP!SRNRu5#r4jPKsKDj-)KBtB3e}e4Au;nl6$vJ+gPGcE8qI3mqFA8}nrJ zxzI4O)YPBK{$4$eWbm9W3DNNe75CW0=&FrTf8JhW@r=ZBQxZ|zXe?nj8k;B@QCnQ4 zWj$A^oI#WEt1<^xqroQf$9@IDSRw_dNqmDc;YTBVT99*2k-|jV_ZR2OUuVe>5%{cl z^vEMXDb%^`<`By@uqvqU+vg?$d&PhymnWda@qrG@#h7#y0Jm!v_0-#(WCC7*!rtJ+ z2?LP6r$3{qnk7vp6I7GoF(1K2jcr~`X1uT8$S4MK0Llux>#gW!l(YVs7ObTTt+<}{ zqd|Hl{z`-~VN;FSIa*+_m6-8(69(@&eaJr7m&SnLPrXdF-o69NqYH&f)cOAwWqv3> zTe2_E88;j>9xNv!hwMU!C+J%EHTykVQi6i>I0A1Da)fe^XkPq$g>~ytQ*xxz-;~#F zDP_-oFMBrIT#d<=4G6o1iZ&ARSn@}cOI8UqW3Hh$hIxFNMmTs13G}2+mjD1ySkF8c z)C@zf`$(4|+k0Ke^@6+-?pST^lnp9Pl}6|jt^u+0kf8QB+1A<@10%V6d>t&5WcZ?M zoTdQH@J}i~ykg-Wp|o%45~3qt@^{XMpi4gn)u@TSLcN(l`((a+-g{<{Pf`y}QZL?2B$r{<>x6E$e%HkAqWo^^DOiDzDN9DJ9Tn#pksTHHeY}2fHH%HC zy&l@yG431g&TLMXDqDhNdA{k-f9{58Wk%lZX(5qwF)$Jc_HFm|s}Wi`#3p1a$ZN=C z9IQ`g8f0our?`-@(H+g_F3CH3uK09hVmj66QAgEo|b)d{x?WU*4G|Gwf4#+BwdGP_L*dz6Z6dxr z8c^f)LNLs{VW@ht*T{u*6{*As(?YpxYi4D%hdWiPk(Fv?_vE$=W4R3ZOHbebwk;!HnT}T}x*_Nd zRji-^&dL{=Z9VC?^Z+-ggIi5sCSKXbo7q++Kt%W2S z-6T}^!$cs3aLHv0D9Sb+`g1o8LEQ1+Zj8gw?h!4dvjufw_aywKQ47pS<< zx>Qy?X}PT*DsPQW$S;khp|6JzTNZJQ5A`=HX5LC^w3nTL+c%KO?`nk`?4u6ciN+>*{tL<J(OTrsCq-Z4g`SzhD^BN-INY}?wtMHJJ7Vn({e~!uyOMCettKvw+~cU zs@lCBF(xaQN8Sw9qrj@dv%XvQjVxfKP(lvxWA{|KeB;s5+BPZ>R~TAmd7OmY4SodE z&G=GbCM>St;3ZNMGIRQ=JZ<;PjDG+4m{%y#!X(Kz=}8Y<%1@cqFB%JSZV9_8JN8RY z5E*8PNOa~_E7gix8C)<^Yr`U`S1}M%VUxB>pDGkZ=2%HVx4#cV>|}+A#RP&mn)ORE zjO~lh%{!dH0cOVi?#5MUrq)D;qR?bef<-l74&baa?d=sAA_bKE;LfRoj$H+F; z(~UUW!10Pgv9}MccC85e=1XsOtk_!Z-7W3Q7snQZPZ2%5O3)e1X2DifjR{FbUPE8T%pHbx;PgUq(w1usFF$2{l zL$PUt@k+so{^;$5&;CfH-CxIRN-H z5`R;+p+UHoho8sI3oj3)gN|-I3&-KMk!6G${9RK92{YT@R z0P^7+p66d0q1CKPpD|RnL}KTjYL_!mLY>$|i6;2oF-w203;n$D`sZcnpYNZqrB^@_ zD65vk2feq(2(bKrMcLh5!dr@8ZbT}dU+F3uU)6B_XW6p__{$;)-gQI`@NeDtlj~m~ z#HF2S#s7gO`VhaM5I~zzdEdXGkT>ox$@iOUbDhfn39%_0Lx1uN?_L&Q}QEW~o80KmQu5zXn7XF>2=wCGZliC*$z)BSYb<`+i6u8=`z7 zPFk%u#T?A2u0)waIz*G7o0TYsa_M4?XP;G~%2Ezme4bFpalW>DpPR%c|6!@SA-piZ z-_wtMkhbiHcza;4KH!Kc8F1mGQ{@`IvQ@K9_w?0qGg|EYOsJRv13dfz9Q;;#1$Cc| z+l@&e6vRAbLa^i1m5(!iSAdjo+^MmDw=qJ;{pAN6>}>tLa%Je!Zwqynzy&{A_hnAN zVb3`t8OuL@`;?GEBbo&CRP|CoWrHzO0NdvtJI6nO6oqw@*Bj&zPmF4^^!n-Qp$goV z-P4Y<)J1o_Bk&5^HGBcWbCSzUeYRq$rvGrUM*mavX|5_fJ;BUs9S=@F7Kf+q24kiK zkuGZqxJ4QDsg)ZgJ+j#zY1)99<+cJs-2|sG2v;~fidRp`iojuw!{wX$0V<+=i4NhC6v6TaGPs?<*i&It1f>ndJ)kE)g+{mdNN}>yV!&Vyh7&w)pLU) z1Rt`(68%Y5j_k-H8E3EXL$=Lp)K>h)AL)?(oAK9dQ?sA)6LS)B%Nr$E865Vvk~IqJ z9G(cCvf8S6@%cjr=dJe?=MVf{{2+iTUTPJU?KPrhCXP=TVtT`nTM)ImaWS&Rvs4>!7hZock{U$hI3=o^c z$J;x%y;+UPVUO5+MSvxf+R)vA?0HQ=1&Qj7om-+S`m@8qsEB-LFIL#?iR-%skaoS< zES!x?VY7o3JKaXqcaau|P1Hbk<@TwmS*V=0Xo2-|94?ke00k$Q{;RA|-ycZYw8ic^ z>LNR4CQO-Bw*KR4JrW2S#(F905N0HVAMm$F`7le(Zh6|X8dH0yi)*=mjLwK3JxN-) zVQFxtDrFRtk1Yl1;)!lHlmj!`fgmGCn)IQclxe_AD1eLLF8bDg*G#d}hO;GXj>>}* zpbfzTh}8`s@KwFfINiXQ{0M>CT!R|?f^P~FA(-uULWaqc{EpA!sXQH1;5s&iBPaCW04Zf;M*V!MRC^xHCK?4`u%lKoXSH{n`VM#l? zW%0{)(qT1OL9;ssZN>LV^8yR%gHN?2)z!jbqtiQGEUC1tmpB7Eo$x^9I9udcZn%46 zn{sV#=beufq;Pm%vYNm((*Mab4rx4J71j;VbUVYgwy(F^wu%YRq~BH89Dl=Z=q>&4 zyYormFA%eMLK(7*`}YLFXdTU4nhOmA({+`N-a>kdoz)t=wS{s|>^L;z$)hzxldGS` zAa}b@7EovRH?F98g0^<-x`mAX1KKpicP=QXk`d??_yFGRE9xRYUBLZd2~tNMS;U+cL!){+?Uys z8TY|uM>z4T>-Nd`Yw=Pxy9aCeFT#$O?cQ6CWUOOiN`2x=-b_D!-(xKXJPBq7Z#8-l zU61eF+}Qm?R=x2cm`RbBA>P=B1~)qddwX#xWUkNqzcGv3?HCSVjsY=J(cOz%!Zav` zvlF~ao!-n^5&28CK9rSw6TiT8!9qFEi6rUZGTNf2ti$!olY&z6UWkrFKq6#hVq?=o z@)q$3k0wjpJeV+q2WfuUxZ|fUcVPyT%&7lF93Btrix}Bupg&`4A5nvFFriO=p?;}a zjtr)I@kK@k=jr4!y5Wg82-Uc&Seq`<0mb2ULDaqpGz_J|KMUK%qxetHi zJqDb4LDu|nMf$~jATgDt{mmxeCv>wbqOF6&_(@?3uW=)XWoE4A*tczPxb0s9zJoJi z_QK(Pe6tO6TxE=)i8-?azdD6H}ReBasXrN*q#PpxF)HKW*GP`tVL>Y(^Ea zcfDT4=O#QzF_0M+)Z+3)u{@0E-8B^a0)QZF8o0O4TI!M)njn#MH+uXVdO*D#%*#IS z_M7c2@VzpmuTL(_(_3D_n&i%VFBB2YrmSJ^4-rgYt%6>Q`s|+-X#P~->RB4Yp|h)+ zT`Aq3+`K+zz5EFosEQw&RSHqKW5zoq%I(^?Dj;h2dB%~;@{9EScf z>S@B(==QpEu3DL>{NGK?6*Iz<37R-HyYH9Ooq&S^qOI>?cO*j!gVzWl;_(b1so;RZ zp=WFmu_mtI_I5=%hgWhCj;X08&^`9s!{Vw`0e|1BLf=-(AVwrnB(93e3|um{+~R!KbFft|jY0hBN=ZGHio^^@VMR{bS6h)-gA0%qk#hn$Km zr|iWWwCbZ>3=)21Iu54+Qth@howMi;W`ZDe*AF#B7tnQJU_|*YPTnH`!N-CO3AE$3 zS{ojgvO=hy=*QG5JOL@8LY}OnT1sj*9;n(|%{Bzl@s}&kWaP-A$o^=eB+N-pt0||J zhYKqFWbA_Z8YxmDbe8 zI(TyEE6+`CI_4z|02}ptA{<|Iaq1`AM`PNF(4D;cL-2j4V!=T5Nx6f2#prkpX0 zzh8A*Lu6Oufq5bcGJ+t37#t={9`JbnlHOdzcN!=*;v%^@>WF4Itan&b@UL_-gZ_{K z<{Nof77a(CnKHa#MpWL++!vHD_c*^?wV@hTe=DU(#+2^!`al;x&oTxP zif2+Qx3=$-@Ig#8_&^T4__~sSMPPTF1~fB!yS}WGNye71GOwXq9h>{k`3F-?J!opZYOy!13d! zQm?|pHs~i48<$CPgBhq6ElYTK+l)o5l>POfH;3}MaO`|3%!|5r<*?<=CEt$!efz3%+w|m^;&0Egs<3c#%1h2v zPIvCQ1i}&>o+DoQe1E*}$Cy&If@941!Va|y?Y!if<@7r`E^-wQ-XjUnG$)my4vk@K z#tbXj24CHb_Q&m~s68&M&9$P@ zyh^*vx88b+^p>k{I`iJ)doOb)$HWoQVZj9*`$bT-j{p5a`BVVf9NeKm7uJq1$4j}a z_PPcl)$ohrTY-NArEM=qaBrcohC9O`HZlZMF@5}Nr(A_)saW(FQ?M}kcutLL?nYb zfy3>nRpYUR5|h!4fDJd%1BQfaD}|kQn-!AAy!i&r9;knmO}KRfa%H`O9Ph!F%Vr>Sse@1V+l|DR&+F(shbPZ#fahjUvCAd4YOGsRA z2kS0hf9)ch98=y&dLd`yO)RI@mfyMWiUd2Dh7&i5#TK{EXabp`)PF#2uh=9Z#Akay z5*Hw=RJ^B;)&%Np=D%O;j}hD)3#+M|`oXc5L)C`YeO7hc{<1vrczvQj>oB4h1?lf- z+Syo8DO+f@Y?yDW2uMNs<>QmO{b2b@tWN%eLXk9h!c&)?g zs@04d?qdB0d+pT?lKc%>4@jEe_b-v`7*C>3aZ%Bpp&@eLIVn8odb}%0JP{GX_#|SF zmn1)a+86X91LZKKK>o0xAaBZq>*}EddXF8wdf?y(3(2;SI(w(Df*~zV_`2ijk%BU# z1i^R&7iZhh=-|}DW|8_<4q#gCUfa^AXv~Vp_U{YYY8+FA2>k3KVgubH>)3P;cV^f( zFM*paqNj+8+dJog8)NV6!dBb0X4r-j*isd$==@|eBdPK&)xiv&}Jppx%-6U%4dp9P<0*E{o2M^f-TXWwh_qMRqC?I=+$)okF zAp|ohRssTqiMwPxds>sZJa9J@RPpHDYazRAeBS(YC0iFR4`)lj?Dn!+y9F&y0%W!8 zwcvojW_yXkC^q&z#CCMvNchRGZU?{m;939Fvi?ZRMZMhvc;8DPYDwK%_)pJe5E--! z@%|j?zOgzl+%kcuD;-4J|~E7sq`$K#(Wb!M`YN|UEjv5Q0W^smRb^WG0?L3joo6v9hab5twP-l=bRC0{(%bdZ z`R(TPw;9q~RH#@iHt)BNbHI~kuFA3-06TueNkNv{_kKE6^k%cdc!ximNZeZxIOaH3}f48U#7YKdeIIyj+ zvF~vXc&@r;{A>RF)1o_#G^P#wd0eBXf%$)@eEpi>21J7+<%M43_rIp!kSQ=h)HsqN zeDtXQGYS6=BEJJ!ZAWvh-4Dj}{{v(jA?R(ZF}?Uz{Oi92Ldm;(Yx-Rgv7bsIAW~Uy zxSWu}RG+vv7^8*kKU|*5m}18$w?S-lIU_q>A9n9xFK&@Glu1{3)X#X=Ik@c3P-c|f zyUvtyBTaUb1>pSHKfc*rK$2aJLYOhvcw94ykevEH(EGMHYj}k`%ttWn4=t-Tm3{X* zi@eqKIo^c^u6oE40@&0@wQ8R#4AgMee-no2J>xO}Gz9J{~1!@e=5S`b`> z8`raaeiJt>An6w{P=|tRoJ>;BMvBDg`lyGIo2he}Mj|E#S%4Azy#T9c{$lY`Ea3e) z8K3y62{Gw`pA*%lqWaS%3Fo!Rc5og*X|{C0%Tj^(%lCyXvqEW0f5}>eT6(%&M>#+= zrIy2Lqa9V@C#g_zd9d<3?4+U0~006OV_JIlSjA&Dpv$h=)V40fH7a zNDp7sU0ER($@FBgPOA?-Y-(`#hR~*IYi$&6ca2m|wcSlGIDR{Tsq$r0QFZtW!MN&*4AVvX894UL1;Z)>O;Es z0cG~qx4Kkq4PPsbbNvC(MmRdor~a9pb>D91gb$a|1v{CY`V=#mY8|zHOliMVYmc0( z$ZEE64LpZxJ;o@KH!8&P9(cOG#KK~!gDRPz_zo$t^#>sj!-3L$Qmsr`riEd%8(?AA-O=cQ+WyKNl?AeuEqC!GL|RfnZ+fy2S_T=R{e!XUHzefkVB2)@dSgn;+~@idtYhY zk0Qc~Z0VjRVennCI7X_c7K4dLUA?iASI)({8jk}t=~rj`AK+j_oOcW=mwB5%RZVx^ zQXDxlZe8Dwe560@xrh|+emY!+Js;iZ%fa9>15ej#xl?X!wYsZS@52C5s{}oafZkm^ zei^+r46BfAr%m$r;kJNMfxGD~4CgF4omBP=_QIp6F?f*h9ZjPfnp6lh5-PBncypt8 z+Da)@2w|3|vBG@~XhmBpnmcHg7athKJ~+H1XXnu;rp*xbQR*+@Q?#w2{Z|C71}dT` z+kR6+Uq6azR`s6|7(5vIMw$b)1q_aJ=u0jSnVoD%&W^rMP}*&egMrUu*;9KW)W*qI z;}U@d$vGAS1ZO&Yu$vQ;hLv*nXQ!9YzTM#Ke1q)qCIlnDWCGGuZ`HE6+;L{f<@)wR zjHm>5>>i14BcmP_gOjqdJK^o4TXyrj=Bxv^RtzJ!p$az?q}i9lJX zd{R$la}7m6BV{F-Dn{)M>T3(cFO){~*>sSPF)e{zgaOxxM6f^gXRusB(LcV_4BABV zqQ6V2p0ZQVd=pXoTt(U*GItE@`+WgH6}ez16Q4+iv(loFd>5eh@tsgP2Wigyx;N>Z zJ4mU3FGVGd&}y|^GlufcIg7JV+zp2w70yas%6I~eZaorF396;q@El72q&!IIXjeq| zS;INW?)eE%A8EPEV%CRBS63|QuBQ65-pK@}cRk86vZ&#yW zE*m@zVkJ-8kPEGp6smN-dZ}b4=di2Us?$KV6rn8HJOsFNJ_3@$LI`fQawMO0E=>-{ z)7TuCNGe4Rn%WQh)~nOe%^+6GMab7^UG)A9d6VHl+*4I3{x2I3*-e@$@fVhgLParyy@h3vLjB9ILbsK~fzk~d zA)0^%s0t4s;k-R}L24sPl->s3(?Aavy8cSE0HT}vsp@BOA?aW~*PRaJxnLb3AXc;8 z-pjyA^3TdsXdt3DR?B<_qXiE^VZ)PM7uq1;oaBd7pZz~)&$-|3y|2swUAfrwt;LwwNL3FIR~vnzi_xGGVN6D1ASC+T4oe_k71$t;86IKsHLE6fKf zHNo$B9W9TP2&)sJ@fUL-Bu5@>KQ7ce6FT7xt*f?``k!o~5m=E*j|U7B5&b!W{x=iH zkj*k(##tZxR4?$^AB(vQT?5M)lIoo|SLd?@>%?P(V>eK#84l}3*NqV1 zkg4_isq8(D9Zl-JCEO?KjNtN&al~qw7#`P2r3n}=cWj~xB06&lfgiPh1}q$ zx=)XwQe&l4Vks%9J}1{I-l9V^mc;UE3Z`?g0zXu$7cK&&BnW6rv6Ou+>hGms$}^9M z*?WLn2XwEsfgPct)zi?m;$jlhKte-#D`VZl`b$M=Be7Vi zV!P6B2#7X9VPaZvaI2q|F*G^|-j~?`?ty}FC`Czfi5k}K*j8&nV9VSQ%{~^mYP2k%WcA;f!zxR2YoqQa$IMR*Wcaos_4+XYl=m0@B%g(mkPUyh}u2 zVEQ%m$b6U^)&x7twIICKNQ_V}-^+YLmUQtm;(l$ZXJ``TLdas;X&1ddtBhQdTYZ0C zMk$`3wgik1bgU;K-yaQkbO&(K;j)IB%A<2?;boLs%cc!Sz3MHsq177cduj76o?eHloqKbN znn3w9uF=7qEA$bdl4LiJne7;;mS~fMb7S(KhDvMf?EQ4ufQwXJ7-EgSN%*~q2nj@< z%8##wSd&-hmbGy{*lL8Ur}?Rr%!ha&eQlV9$@XSKfUHw4pon0+FV3E+Y4?z21%9yB z3}%Am>RIZ{A)p9ny-{AV-Ev!p0)r&^}~$hK1oREoeXjd~X(!C%jYrgi0()N`d zaOWx}5#5^xuO@2OIvkKi94`RJHcjqsrw*0q(!m-Fr4blUV&HHr!1dUe5JWRpZYh5g zY@NBmH}PPn%k)LXeiXO>P)C#CS%2Tnv7tY{z3kA&a4(7a3%s|Et7Cy5Ry_~;7SckF z(omRJ8fi)M7_I1IEV~jHIB+sWO6q@1-O10a!j4J3yprBM(<7QZ zv)m80xaf4JY<}}}>0D4C#b`Q<(eb{oA4>enTvzAZJF`9x^sr0i4VC9ADwBnk#=4Nn z|2Y7{%a`plwOKA`d8*v(dP33pdP&Vuy%vOZzuM=%`6WQKI8XWolq3Z_*TjcHNyNmB zkeLhyQKD2`3LB_#e|^6%e@}szI37vidql*}&Udg)6UAF3o&Goe#vBcXP(i5Cdf~SQ z8(6r^7MTk+f5}Qgw)arMb@9F3^Ac_{bc%-X0;5JO21h?RO3QWFZ~1@3(H-97^+RXoTE?GHg1zX0!q*9&8Idyetb!q{6PFDOwKQ|MIC?Dhwysc^IkSI z31+iS>p9rwO=%z3?p=qyJCf<2WlgucsliOEw+eUI9MZQi2%w_n>#Z^4Oq$I8DhBUe zUkJ-^v=q>@ybRMvHrYJ|?LClilB`f`g3MAShaGV2XmO?j>qrjMd*w|tK<6k{Z?S=6 z6MPxxTCdjz#cjD#h)*?d>u+u8J+Lehe%m$+o{N$UM50!c$X z^_UO)dB~RorJulSP!W|({q_usBSq?Ztw8_D{C-QFuk%VO64|r#Z4_ja#y9zyerG~+ zyn)$wNuWE<1#+z|sjta!uzS=_iP10rmrlV+dBQY_ewLlvDvYC&QX1TZwu~Isx5|LH zQ6^UAz|=&_gq1vbDw1iLB6X~)n2Ht!v?P-nN4jv^VXrgbhc!Z(wDi-1&53bZeM4iB z)JpkZZXWYmoiNE&Ji;53J;$3w80imLf|29IC0kX(Pk}iN)jT8HTNjhh&MISYo8(=J+R7^R8<#irKJ8?}K8xtbow2eAqwRH%Z6Tj< zR}qJ|>+GgT$VK@s&Zc7QH`HPb>;u*#lklomenukRHx83kkG_RXV7e@u>lv`_3E9uU zV%ZMKl#V-){tJ@bV`Y;;!t+ocg-&<)l$6hGKTbC>f4}}N0v?Et5dKfTfHYl&ciOh& zkN!7Cb~wSkeYogdPR8s~rW_OK@nU{Pih{>li(1g1IA%Dp0_V3Q3@>x0oDDW$>tDwN zGZxq!Xv6L2Oit*{P%$LO4;GgvP|4^m{<>2^yD39u&0JfoiN* z?jD%KT&e1@E0`+AI9-D7ec&e+)U`d9q6Q}ephnkI*{z=@ZOay9*WW-Smu%puS8z35 zQ=rh*)NYM9`$iWBYtW==q>1dmC61~tcfN8|dXikBZ3yG7kEL7KEYu@LUAm6U_qu__kb$s3OpijOGwuJ>RT)i<8q#Di=Q^H<@lMi>0l z8Q*2=Ep|s%BV^<;^jp_YreN14Q=3|wJw4)}sr)Wgx zNBt~s0IBKv@ns3lrl*QI_ISNYM;1tQzc7wKs1$t^?Gjaf0+z%5flZRLt$Sf1FrWmM zCd-Q^tRKZ+ETX@!@5~NR7kgryeA~f^iOba!p3Ouw1SXmQ9&RJ^laZoU|9uOC*_Dwj z3O`uaTRS93@Wp(il{TGdcO8gA+G+$&je~o&Z*Z{8=P}sDe0w{}IwS#YLYSqe!vUhA zV9EYMFt4gB`eFsJ4W{?5EBlQXh+8e!;it;+GAkRED$Zz1-KHJlS+dNNVbL=(*0$Ow z%IsOJ)UldPpL`7&BR^xc@C{yqBUUV$FKlzT$I}{pjgMFgt_TK%RFO7-5dB& zJXsP}+zLHAQ(oD}U7)B9$9qQ3F1x};y||6~k@5wd-2L>+1roY7LRY`pajc-_(nl27 z1=kr!6caCEI-6~XKj2=L}sX?jl|sJ>r;2H7$Uo3 zVo)^h&R2Alezo}$TD^W6!SL3`h{59k#mCcxi&jKtmwQ64ME`ytVP@(j!*i zy@dj)$Vw)i4NWe&r`&)#2-_T_@9Ew>w+5?mV*X^eto)lWT02?v1B1Pu-|NmnG?fZ= zyEwaWqOxB7N|cQ+h0lwk@;i2vwT>4IGNE2jowk} z%T>?}!5)3P*m;p>`;htEF6T2^p*)yH4#K=r-tLR9oqsKbegnTXg2lSUl&N;@y{2=W z?h<29pgRk%NI+qEeX`Un)>x8{AtJUjJ$Y(`hC3 z5cL8$w*bl##-i@7Iy5WCbv)`&y$qAx#Op8U>E0K78d{L4Jm;;Ghpd^$SQk&?nCp+}46Ou9{OHnDW zGq+d7NOngwX}s8udmN-RE0odNlfh1JXX_D%B)-nT5wS`4Xbw(>nIFe5u^3>U$WX4Hg@WCtNHq)iNSit zHnRFlAP?y=tIx9QBHQxLa(^8!wejO_BiUCwdwtK( z%iG=~c1Pv_WQBA5}Xm)E>b&Q+WapV~DuOLfEW0+b5zAN_@U(C&fjS`d2Z1 z-_m8E@0mko;J{_rvZjx82vc(FtI5Z2Wg5cnO=rMhwR0Lm7F8E(>WBGjsr4p9yyTZ& zP_`Z|;;Ktun3!3~D706x50#4)V`X(pFiJ?!t{OQ6c^g zJ2YNtN=W;hA&{P-CG5r|_v-B9m2^G_4lKCTh@yiL!zASXPTAPvqpZ_STB~zCAsXd= za;_IhUXBeQ9XvYF7eSozksC&mB9!ajH-Q~0S*{BCiUP7(0^lANZMjB3OcvRik0`>P z1+}FuA9n;%=?TL9{p5YKMcW+cuM}LTkP);OI}%F!EuKWvDH@D--&0udf`B5&uq+Te zLhF>>IyFnm%_5d(jtJAw!$PBiR)^zWCi8uQIWs{lonS7iTwH?7e{sOwC!O)}+y8!+ zQ0f~SLv~?v%f=^#JUuSKNZDUDNn_UOzsbxXVqYe~ONkZx{&pT}NJ&QrM`;e#JdF-- z3eNprWwPsLci^POX6wt?advc2I8~i6sNgl#7}|n*0legaAp%FaU+J9l$qaQKMOH)^ z$^uuAdR=6%mt5`L&jZPFSy&%G6#OBF@XEywgFg)Gmb@rrZJPAQyoFSwiO@kmjp zs23^jh>LgD>}K(M3hSANd%d*RlnEr`HRwc0yFCap+Eq_|3-d9f*yo2u-!ix9^^)%x z$4@i#!^-u6-W$-@==d%#01B$hZ&>9E*=`6@ug+wQ@vy15J$5yiD>K1EKaUMAIQOfI zJ|)T;9X~&!sqGSxUsXqi(PaL}Ze&H{qmIqQxM*(DhW6s9mSUUJy|w+R8H1zE9p$$X zU*<8t6J2CdF)2BQ$Op(bbQcfN@EQk7m+b0bAXW-WvuPQ&tH)J5Rs88XLY5-z;jRXRsnfTxko$VGns>30rT7)w@50m!bS|EmaAmi3) zCz#>;7Z`jUh4>@OZhwODYFTglkjYm~EN4GVv^Em0er`GbgX3?wxVxN~fbPrgzd08F zgAsqket{nH7Vd}q1N7|%5e8_+4d)-282}TbgaaNl(BBpQ12We8>oJ)B|JWaZ{Qtc| zx|w?wh++lG7tpAg$qoJi@xLJdXoE0g!NS1CMqe#44ag4+1l@OV%MIm>`=ej!?F-VF z%*@D&3YwJkbo5p6PyxbE)S0!En3yj!cN8W_Tb%zjPwg8Z!g7WtCS(~I8G6RXDrSb+ zh*2%236iABRH#|Ai7m;`ng7*1Es>su1(f~avzu%mQQ+S9e&6)o|Do&r%apF6Ab5Ta z-k>Ioi2Qd)fdE=TXJF>2%pYUwJ|pPvy>XU5U`qWL;pgub(2(?Yp3q#V*hBy5x!NCK zcT0kaV@Cg|r2D(L4?u+F(sh!56CnUr*$(%YKZC2##sBelTS9<4+3FWN#Q$O7*IaM z&=eLO+MddXx1;63#ENVj9{>3-&;7SIZeI{=ADRVT5_*+r*TPg(R6toL1n`7`YK1|D zN?>$8ku>6YeL;oNqN@KA^@kz=w!saNVE?PHwN^%1NdmBo%ySnTmOpqbIvD^HKsO!a{BsIr|1uU?7jE{nKL$uI3+O1Ec@O800sSBc zP*{GFw`F2a{9~`a=m56L%|NO>+@BJ~|7EJVHm;7YLlKAi zP&SRA#Lp3bbjJxIt%DxA3QM)q1_#u-3)%p-bj8IAU3 zU3+@MaHz!XF~ZE5C2CM({Xb=N_7Y^ zprAD^``9M~k0H$I3RLFV556@pV#qSJN1+@sMjjWmqH796f0eeW-DrP(anHtjZ&H)t z8n$DNHOgW-ps}QLfi?-TkHpp%r2i;qyGv}l!3{?Q1nD4F)-gM(PL3+lF^X@n&^zQJ9s-EiIgCc7B8$Is;|CMpk2VoEZ^&oz5ws5M25 zl}?z$PkY2tDp;HgD~4uEl?q*FEQP_rI7-XoJqc&H^ph{h1F^0Mx zH6@Uol!$0$?RRN?);r6AQ!d9J5L^xZ(o_v$Dg{}k72W-Sj3}-GZRcXw)BUiy{F5(m z^Q--HKdLZ96@_JbD2gGsQ`v#<@=&dM?JFHtfbzGoutEf{M@H4Fj$oF`FCLbbES5L{ zF?#3(-vjU4s+-yW$!1fhjnRptgqSskiBdXeU}dHe+I>B zhB^md<_OZ_r+mlg8JGiq9r>!UBI{p{=&I@?` z>zn-45R^&PB*vYBN=oYKL}OiT{}TR!Pn$X>$LOlZ`Kf!F*?&ijMm;#6$`B0f)xn3 z+o7raRf8Xna>D&ayXA7<^^O2{Agj$G&WvD5V{F1@hGb%-IhO=aowg7wqg<1GYm&2o z1>DlAuAb7fC=6Vw3XK}7Tg%!|o+!9Air4xxi?4I}^Dg+hX>>3Wf|3t&8Y&!YX?n%T z!X!M_6U$(uIf-FHI=I%5@{P5D_@~a<5<;fN+dq3|yCCrAY2}BYD4LbkxxRF7rR!Ad z@XsIULg+Zb8Ome1HOlKRg``O~EwE=n4f0!5Utv2p1I1FYwy(;$gy|AJKY$WJ&Roui z<}+{XC=LP|VflM5ak+2CVlkSNLWSD~w71U;;R;8g_g0&8HL|ztf&-Qtl^8jmEa8>! zn1wJF^q^2*ZsSb?vklU*cr;5N^!yaAB#dx`s2q}k+=AGD_W_RN^+E=NkzKGsL3pHbWN2ntyqCh^6jxy${{V+g! zu;L5j&ErVG=%H8k?d)n0qD`aPYJI2aJXJ*=lGPfR-(Z}oFZ7588*cT0*QMnNh4DRy zpXyI-p_MlrV_lyhNRRL>RdUsnOTe+YjGi`^u?2YrQh3a!3?u$y^~G|t?yvWYsFB(no`)Q{+whtTDULL*urdQjzV8#08H&xB$H;D* zMgzUVwFV?&fyTsy^77_~7RG{FA@f^D<2yGv44|wnE_F)?^8$gXZ7$n>Qu^yA!7!ew ztnPHEDB~sITmsKHgI~ECWlR=He!X}srSk}@Um8!b@Qp66Ol#S5;D7e9Cv?cS@FsUi zuEmF;jWtGj%EaN>l5O!b1V?{i14wLUAjv=<6eP5O`vZUkLap^|a(Ba8a zSWBTLMK*he45J8G#~@uFy_72q3M6SO#;u){&MZdtCC0v1NRzV2WXqVky{lIAav=UE z7PwYLm~Jc7QBooit@l`-0n?d$I9@G86lHVish?0YpA%(k%y0`BoG8l+N@t~skeSYN zlvyt7-MD|x4$7in9+Rq`7g~*4F)=Y+A!CiCMQ`(S zH*yF|sktZIuLN z+YC+$8QIBF8&q`UrWs9y+ww!V2dd${BE*yfY2V@ryv?7blrTmou(FagR5{3RdsmL~fK(I!x8=fXVL zE!<_dX^v6dlMb^>`V*qBpgwkApM{7?#Ctg$G?fN3dx}Feotta7BTA9txFsyEfc%){ zVfzkRvE2T8+4HSh*KpoS8FLQpqT822^ZM}<=G8TGVARKjnD;|+AyY8z( zJzJgA<(z{~862l7PION|ZRQLm7wl;SOhDmj@%F&(BA#2o0DjZA${@-o`@v0MR-83g zyl{VRy)F6?6QIixrU)H5yr&j&P3ucp7w z1>lCi2FV<+1hoiOh-%bOrGX`O&nzGwWK1B6wiPSY-3doN30R1Mb1Ppr2=(`=lN80~ z6!c2#MdS#$nkgQHgvjL^`-|3dYm~QSSMe{Qx#1aNf(S~+_@FynBT63F!2&eRtem-98*zT=mzcoiLpOC zK=^Dl;JTtLWU$k$vlu@b0DN(aU5Nh#aDWWU7fEj0t@oy(%AV%NXj$ODelkD?FuJXj zGh+U;D(C@Uu>k_~E0<5lvp=#Mc`HDCA08Qo{0AR=dxH!ROmAFz+wlI!Q@cUJWPAXe z5e55ie{|Ho_E&_x_C8f<`cKjzQ2Q6hoQ+C$_&*a3;P$5jVt&`Q&zAc?LV1e6BpMJM zfc!(CYa4**Pd1PIO<8})J^+xYcDaOu@DG7DtpTEMU3?rZ{u3Wb=PZLrXGu_+eMx*v zHH-R3p#YQn0S-};D8i;lBtPuU%ESa^bD8y=|CI7aFVfq%gapaOy|cYAY;0^(^-q}5 z`NF^{>6rZSG5_o~e-Y<5y@1IENx;G(+6QYP7;yf`M1aWTV*o?-<9Yfsvyje#_)E`U z`eyzZz8)~>Uz!^BpZd2${3~?|1LV^GztR5(owg(HlqeYQu7?`_K%u8LgUx32jgu50 z1)tr!s~DRE4rreP77J4+pzaV*o*`EbQAZ125)!}!7rp?CBhrz3)}s2$$hL8buQdbn zX%6{RtJQ<>O~(gYXB`XF8MLCy;!lU?~+QzWLb}yv72oNRg0{W3vj6l0UKV(Z31ALy}m5?>@#;%%Q|>ljiNR1G9I|nJS9_ zn_>fn?gXL;8`2s;geLX{YEhF&dO>k40mYD`I^3uT$MTk)s{z1FYN2w^7O(Ln;N-PA z^<}UE9g2Q01L8J#if2a^2%M;Yy^}$a+y*QcLu&AON=KI|EPLb?+8tPXUD#2NFJ8X= zG_-GJ0XeU))-!v14-EA46>5l-dJ7dhX7erRm;GI`ZZEry7QYm)HLfO~uFF#?CTV$3 z7G+tW%zazd=sJEFUunX@x`EL7 zj0KE-21KainIR` z%15)-KRN)GkmZflPqu$rrxlNw*xomSC&tK^%mj-O$KMte2F7Bwj#?{*-QvK<%*ee2t*OCz)NKhlwSb7J9+7NB`1EN=1LF=J#RvW-$XPuss zszcH4sd~gsn|-*G<#x~SAyU*8H*mSSC#rH#CMV-|x-Z{Dq$d1=y-DlK@B{K+A9od$ zd6Y+kmw$P{kE)eL-xlH`Vte5RPih5A!UBpEW5AX_38G;~(XPo6QBy<1S7i>-k;8ub zc8V|s{ews;SGG^ou7SCCA>Q;_xJs}iM%1p6(VvD6rwu+UNYOBW>OMVBHHKZL(!&1! zoN_s#((j9F@TVz!^7Q#ld1=?1uZ7eX2{uUoMFASOd&%H^3~69Vcl;WvHa@|gL$opCqyX zM~=iP0+lrDCT5cR^Wv;pg^Wq#moB&(hQdG2ODo|^G9-OUQxboWAi}?X;VP~ZhR+~f zAP=K6##)$3;IJs2jCW7RS(|mn_RQ3@h&Br+^B=X8xS$AnO3W%Y|5>(G1gm$Hp(e`& zsTB-m^wl#Nm6KEni@TQJY*bg$dNhyHv=-LogQU*!bm^#wZ7wc(U&yGKTfWS<6Q)-C z)@)Ze)QJ7JvBv)Irj)5pHhFzM$XWP%&#dogU4L&y-H!hXY?OrmbKVhG?!62<>8djd z3?HU9fZl_6d8&^9=_-50N7a)oM{7;a-t5Meqtl)+=Nq||2(V@f(}2VW}16Crs42`?+H*qXa0e$C&ibyc1IaQT3RNiay;mlH>}m>Z*G(O z@^D`Elb|GIss`MU2E%XD?Hal%sy4xMYv@GJ5BBvQwg?;5n|+&^j@PdNKq(#fTxm;{ z5q(2bgv}cGR$1H#`C#z9o0jnUme!%aSwP*RG9pWbxOclxPo6AZEYz3V1oC70T7D{> zzwMbWrydROSYh&AtYQZD#ceB&0*#DNKJDKTF1MRt7MMFZ77*kX2n|*XLI+D7e_RlW zOG^`)&68kNFnH}?C3w{#W=pT6%t1-os)4;ee)^K?{vM2y$4s)_3{^$7x^_#4TYe;f zK7&688FBhK?TN5kC%vO*9I@{Nxp=`3ocx+T?S2izk!T)}AFSF-HzX~zW82qre@Q|k z4g)gNhCw6>Y)JlH~P_^HLJugAZbuAtAiv* zqMD~VjUQSJW+_fl2t0H)%LvH|jkH~R=EFVr+dy8(fb^bf`xO~snzLBeeJ+lpSyJtt9mx>hmzvjib!oVWW}9rGx}Gd|PvoEqY#a-SFJByC=0}Twb#3kxnP;xx zA>|9fG>Yp*su4V7dwGH!0iuQI1IbmMEgp-B6|3^*lE5G_SCwS%4}xt$zZ)&7921mK za`_lw@-$$TsCYX^lJCP`$?FLiMR{IA>h`aSQg4*&29dO4HjhCAuw`26{M=9$g01xj*o;q?)}cvh`&E`9|QgNY!Y2 zmlxB7{v9^)E3n=5)WEXqGf=BHFRrdjRj4l6&p_)g`kvyBwVm!*L;11^#>b`m5~D<@ z(p5S+7DfBflKX{r^gK{(%X6pTT!C*6?&Y%d@+1{ytcqIFG^ncwO zY|Ez$T@*9@92~}^u5s&At;l9Y@$b#jdoG$Ydt3p5IZ;M89?magXCJcZI01&0hqNTaMpm7XiWsT} z3yF_p$;T=|ku?WR^~ryKA+;iAU=QO4W_rr&gPI%{;#_Epro6{X#UCcDwb3GQZ6wJa z3PRSfl(NfwrRbOO0y7wD)3PowWPusleBlmn^qZ$FIY7TMFd^G2EbnoMS|Y_$6t6=* z4UDWU1BU{(VJKl$Nm+bZTB(;&;Jw3LuKGB$gDIf7iSTtm`q_He^^(sgz#9ZoDX^3G zd8~K^Y`w749#`huGqD9)Px}UXbJv#=##rIc=y{i2rRWsLzcU73wQoL`Al2jvl^a0Z zu5Y1o9!>%I=4`UK>%M$|ia{1E8)ncTkT_P>xrOG{1y92LGh_&i%`Z-^9x(!tpc){keFJ+o|D{nFaq6rWx|0jWkxJF^J{L{-Qa zU3UK=rR3USbi>~mx3GLx8yJFKmmFRXd6!8pS8sgM89cb`28yw+AVMr{K+mr=xs7Iku&=!lZoz16hH`l8Qoqv`%Sq<~Sr$**Vtv zr6wXL1k^W6JDHOxfl)d$y-SJrakdrLX>BCY95GZ| zF2cnsZ#CDqr}~cKiiITg44T*<+3wT%QWtsRs3)9F%2>MGCYtauLsXaEuigt9MlQEI z1{TfdK0|RR$018LED?be&rKp+s87Oo6)R3^Pb-_abt>Ghs^Hmw;U?%30%9xHVTLlt zIZxgrUZH5qN56DeZC?7hZ|{TdCO<$xsDHkg`f^8bU(Oe%`*%{hy?}&pl~7G;#=27D zOdkx%e5n6eMQiqb0(^8IBZK%#+gE^nRWKVisDT7|FX;DR%JX;mTpjS=R=TAEb-l86 z1@MdopI<80ToEJN9>luf7H;EaIubrkdYPI0T+GAl_aDb4Vn1boxzF0MyM((^5aH;u zC@#*zTO+?4>TPB1@6-zq$qnjV#A%e^@B8|H_ZG|U$w!DULa%Q{n!u?wx&V8-z9(l$ z;_dY--7=i5>mrqbc+6UC3%C3YCf(cR*Y%Du9Ei^C zGim1c83|IwtJ!KQh-_MgUTG4`;Re-dai*=e)aDR(J=@)$pE(q_5?>yyAbt!+Loww_ zEz8AfXN~eeH1jAsqM&ZSo~-nR&Z$8G|AYfG@ckz0@Uu0T-4C+qY#zYczvAk zq7d9~-p_;9H?ZR}5{;Wo<*QXb*Kt~O(TgWuOh5bb0;6nb-cp~uUfUly%iu#2a3 zMVKmKS@N6>C+S#l)d?UtnjO)V**?@;9YEP^R-$k%kNVcwvK^>m_^yPp-VxyONc#${ zSe>lYF>majZTgGQ?3!{oYn(7AGuuLj>=jcqxq8#W62&&V7kR;JEhg{@bVnV9!rh&R zQ;7u#)$>fdKAw)>*^YmE$tAcj^rd=!N1(jvZO=x}Q;30%%MvE0B}v}xU0It}=g1p-#f`~K18!iOqOs)` z+ZgGPIx<^*Mx=dY3d*O2#5Lx6xOl_R#11Q-RL649@%ds>z3>bG5cemCIP z(|fU5dv|)d-SGZc)KD41e1WQOauvW7T;ZoAQ~7lALzGGmQFk4o*b@D+&KA;VkZQ3Q z`LX0Svm^NZGBiwO%s;SJ#QbU>wv4#l&^{>(v`53RD0zER6`&9O9` zvn>c+NViHqOgFssbRx>AAfK({UfxBM=l?vY%`u2EqO-ve?Lg#YC%j zl-gW3;uj6pd3pzbo(ZB44FwkN0O zUAJ%@-JG!l?&Vrj)=loGHb2yYHogFgi2 zw}GFV|B|yYo3BOYNw(=NANo-(OFU54?_gd0dgb+nsa!4?EEM(|Dy0H+Kv zd~GzC*~NV_?!+_)CY5|v7G8p5CFp1lTI4JuU}k}VY8^`JDyoKUap{V6jh6E{e*BG1 z79)^19L!@U2Q5}NUP^j@08io~PM`|0m~J74$A4gu7WW<-01s);2}wAo`C_?S6i(gDj-=oo-c=_06!uTn)rlR8y= zX>f)tDQLgPO3t8?Xho`*g)t^>PJn@pvR#XD(Gnw+QPQInospRD;WMiu|jizoE0{cZu`f5>MHLt;BN#|K>JcJuC#tCFR$WDd8Pl3GP$V(=Lc( zCDadhPmY7!n^+lV-nXd5I>io^(k*jF4)DL<%~GUwTdt|ldc8(RHTzpOT@nk)q)We# z<;x}#XzJ4Y60zc$RtL=(UQ6b?xZ%@aup?m8o9221zsQ@GpWJYH*fX*~^&M3W4zc1O zyig!|394)#?f9gGe1`~^4)P8xqqsN~h|Y8iFf^^|u1i2aRih0*WB;~;tB&b)DeJ_n zvhy~w_ZynE^!gfFv`-WDWCYiq!+n=VxyZ$Au^uSzn=OGq+u?i_VnE9E_h$4&zb{m4 zLy0_?oPyIvJGU)&At4e0mGR)#dZnP`*p3cQBX-ZTq&%`QJuCSvSPSK#QvMDLrcs_h z(35S~33E;Sa;RA42ey=438`5Thvc#YBT%#su$&RjFGAwQqKOm;1v9~5hq2wT9RTG- zDzPf=nuNEye4*JdXCjKfrm`QQ&TNvbo#rbVUv2Qz+;T6Be~p90n<`iE&Bn z2ZIFlKq5k71adnxI+AFQL7(hIgX~vyr%%C!q_J1Kj?YBd$`B@uq+K!RB2zFGeipg< zK1zuc6-^N(T-NLYPZH8E!}cun!i%J;+$r$TTu*RUf&&YQKJ!ZEzMtUq{-XR+^HcmG zCI=ax8M(n{RRFjNXMvhebBIz5B%HhSV1MP8rDSjPIogyKG za@iIntjB+fJ7*EHkq~8mJ5J-qHH{U={bo&GtC2K1iC-^Sx5tneFV3cj8Pzi{i~9sP z$tiDPu(z7jth81UQvLJ;Z$>+f;*dD@Vm;#YW<@VcdZe6ZvThF+@wemnzT&L~b@+R`;h}KD8S@KkbuQcHBCH z{V(}rEzQ^FkbrD!iN*x$ze!KNL=Zf39C78_6JW$Is8RHtm?0WcpWmY4+h)+cke@)+ z2KG6jr_~xn|wtxCJzB1f&}m`7(C$c0pLd0U$kO#RV*8m|7MYOcZuf+ zd*IwTGO#j_d5;cBZ*LG2RMpN|r@1|*P12hD3vTg^lk-vYOMc@sfc^tPkth1=3@747 zx9A^CtepM75TV=G5ZXWJW&)vqK@GxAU$J}sfNB7Z1{#iB5jXw`9+CP1KndX0dVc>4g+53F literal 0 HcmV?d00001 diff --git a/apps/marketing/public/blog/eu-validate-2.png b/apps/marketing/public/blog/eu-validate-2.png new file mode 100644 index 0000000000000000000000000000000000000000..68c957f7660075ac58e929f52e27c4b56b275887 GIT binary patch literal 220213 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
t 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

|-j50sUm4W9c*;Z6M%)j7#(A5Oz2Gg7j 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 FV1htFmIeR- literal 0 HcmV?d00001 From 33f3565715e6ef64237488648e3709909b535774 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Fri, 5 Apr 2024 17:21:49 +0200 Subject: [PATCH 075/174] feat: blog article building documenso part 2 --- .../content/blog/building-documenso-pt2.mdx | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 apps/marketing/content/blog/building-documenso-pt2.mdx diff --git a/apps/marketing/content/blog/building-documenso-pt2.mdx b/apps/marketing/content/blog/building-documenso-pt2.mdx new file mode 100644 index 000000000..e7a2605f7 --- /dev/null +++ b/apps/marketing/content/blog/building-documenso-pt2.mdx @@ -0,0 +1,113 @@ +--- +title: 'Building Documenso — Part 2: Signature Validity' +description: It's a signature valid? And what does that mean? It's a suprisingly complex question, let's take a look. +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2024-04-05 +tags: + - Document Signature + - Certificates + - Signing +--- + +

+ + +
+ If a tree does not comply with the EU trust list, does it make a sound when validating? +
+
+ +> TLDR; Signatures can be valid and compliant for different signature levels, even if some validators show errors for higher levels. Not all useful security measures are mandated by law. + +# A valid question + +A few days ago, an early adopter brought up this question in our [Discord](https://documen.so/discord): + +
+ + +
+ You can check out the validator here: [https://documen.so/eu-validator](https://documen.so/eu-validator) +
+
+ +For those unfamiliar with the tool, he used the validator tool of the EU's Digital Signature Service (DSS) Framework to check the signature of a document signed with Documenso. The EU provides this tool to help users and providers check the validity level of their signatures. + +A short refresher from [Building Documenso — Part 1: Certificates](https://documen.so/certs): + +> Documenso inserts all visual signatures into the document and then seals it using the "Documenso Inc." corporate certificate. This makes the resulting PDF document tamper-proof and guarantees it hasn’t changed since signing. + +Before we answer if the document was signed correctly, we need to understand what the goal was. + +There are 3 signatures level in the europeas eIDAS regulation: + +1. **Simple Electronic Signatures (Level 1/ SES):** Just a visual signature or even a checkbox on a document. + +2. **Advanded Electronic Signatures (Level 1/ SES)**: An actual crypographic signature (not just a seal on the whole document, but a specific signature), using a certificate linked to the identification data of the signer. + +3. **Qualified Electronic Signatures (Level 1/ SES):** Same as 2. but done by a government certified entity on certified hardware and after identifying the signer with an official ID document (e.g. passport) + +> 💡 Side Note: Number 2 is how most people imagine digital signatures. But most of the market uses 1. plus a seal on the whole document under the name of the signing provider (e.g. Documenso). The signers data is only inserted visually, not in the actual signature. Why? One of the reasons is, that it's much easier and without a readily availible open source framework to draw from it is quite tricky to build. This is something we aim to build (which many have done) and open source (which no one has done). + +From the perspective of eIDAS, Documenso offers Level 1/ SES signatures, since it does not adhere to all of the requirements of AES. This means that, technically, there is no legal need to seal the document to achieve this level of validity. We do it anyway since it improves the level of confidence users can have in the signed document. Sealing the document, even though not legally required, is a great example of Documenso’s approach to signatures. First we aim to provide all legal requirements for a given use case. Then we add any protection that can be added without unwarranted friction to the creation of the signature. + +## Not if valid, but how valid + +**Q: So, is the signature in the image invalid?** + +A: No, it isn’t + +**Q: Then why does it say "Unable to build a certificate chain up to a trusted list"** + +A: The certificate we use to seal the document after inserting the signatures is not on the EU Trust list + +**Q: Does that mean it is less secure?** + +A: No, it means the provider (Wisekey) is not on a list maintained by the EU. The cryptographic signature is just as strong as any other + +For someone who does not deal with this stuff daily, this can be hard to comprehend. Whether you use a certificate you generated yourself, one generated by a Certificate Authority (CA) like Wisekey, or one by another on the EU trust list (e.g., Bundesdruckerei), the cryptographic security guaranteeing that the document has not been tampered with is always the same. Many providers like Documenso, DocuSign, PandaDoc, and Digisigner all use this method for their regular plans. The mean, if you were to run a document signed by them through the validator above, the result would be the same (The sigaure format may vary though). The interesting question is why? + +## Certificate Infrastructure is broken + +While there are some actual expenses involved in providing AES and QES, that blunt reality is, it's just good business to charge for them per signature, almost no one has the ressources to set this up themselves. While this initial process of becoming an QES certified is really expensive, selling the certificates afterward is very lucrative. This leads less innovation in the space and only big player providing these high-compliances services. Even certificates only used to seal documents without being QES certified are sold for a big range of prices, while they cost almost nothing to produce. + +## Why Though? + +**Q: Is the cryptographic security the same, why do people buy a certificate for money and not just generate one themselves** + +A: Self-generated certificates are not recognized for higher-level compliance signatures like QES + +**Q: So if you don’t need higher-level signatures, you could just generate one yourself?** + +A: Yes, you could. Since eIDAS Level 1 does not require a cert, you could use your own + +**Q: Why don’t more people?** + +A: One reason is that apart from the EU trust list, there are others, like the Adobe trust list. While not legally required, being on that one (like Wisekey) gives you a green checkmark in Adobe PDF, which is how most people check signature validity. + +**Q: Not a question, but all of this sounds weird** + +A: It’s is. This is one of the reasons why Documenso exists. We plan to make this easier. + +**Q: How?** + +A: By explaining and providing easy-to-use tools and eventually free, highly compliant signature certificates for everyone. + +Eventually, we plan to start a free certificate authority called Let's Sign, named after another instituion that broke the paid certificate paradigm to the benefit of the internet: [Let's Encrypt](https://letsencrypt.org/). + +As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments. + +Best from Hamburg\ +Timur From 08b693ff95a7ddb62db649e9474696f318f0cc1a Mon Sep 17 00:00:00 2001 From: Mythie Date: Mon, 8 Apr 2024 17:01:11 +0700 Subject: [PATCH 076/174] feat: add prefilling pdf form fields via api --- packages/api/v1/implementation.ts | 29 ++++++++++ packages/api/v1/schema.ts | 2 + .../server-only/document/create-document.ts | 3 ++ .../server-only/document/send-document.tsx | 36 +++++++++++++ .../pdf/insert-form-values-in-pdf.ts | 54 +++++++++++++++++++ .../template/create-document-from-template.ts | 1 + .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + 8 files changed, 128 insertions(+) create mode 100644 packages/lib/server-only/pdf/insert-form-values-in-pdf.ts create mode 100644 packages/prisma/migrations/20240408083413_add_form_values_column/migration.sql diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 675c3b532..d9bc1a6d7 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -13,6 +13,7 @@ import { createField } from '@documenso/lib/server-only/field/create-field'; import { deleteField } from '@documenso/lib/server-only/field/delete-field'; import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id'; import { updateField } from '@documenso/lib/server-only/field/update-field'; +import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf'; import { deleteRecipient } from '@documenso/lib/server-only/recipient/delete-recipient'; import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; @@ -20,6 +21,8 @@ import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/s import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient'; import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { putFile } from '@documenso/lib/universal/upload/put-file'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client'; @@ -156,6 +159,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { title: body.title, userId: user.id, teamId: team?.id, + formValues: body.formValues, documentDataId: documentData.id, requestMetadata: extractNextApiRequestMetadata(args.req), }); @@ -217,12 +221,37 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { recipients: body.recipients, }); + let documentDataId = document.documentDataId; + + if (body.formValues) { + const pdf = await getFile(document.documentData); + + const prefilled = await insertFormValuesInPdf({ + pdf: Buffer.from(pdf), + formValues: body.formValues, + }); + + const newDocumentData = await putFile({ + name: fileName, + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(prefilled), + }); + + documentDataId = newDocumentData.id; + } + await updateDocument({ documentId: document.id, userId: user.id, teamId: team?.id, data: { title: fileName, + formValues: body.formValues, + documentData: { + connect: { + id: documentDataId, + }, + }, }, }); diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index fbe3ba5c1..01f6e2d58 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -73,6 +73,7 @@ export const ZCreateDocumentMutationSchema = z.object({ redirectUrl: z.string(), }) .partial(), + formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), }); export type TCreateDocumentMutationSchema = z.infer; @@ -112,6 +113,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({ }) .partial() .optional(), + formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), }); export type TCreateDocumentFromTemplateMutationSchema = z.infer< diff --git a/packages/lib/server-only/document/create-document.ts b/packages/lib/server-only/document/create-document.ts index ce1f16670..1d145a60d 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -14,6 +14,7 @@ export type CreateDocumentOptions = { userId: number; teamId?: number; documentDataId: string; + formValues?: Record; requestMetadata?: RequestMetadata; }; @@ -22,6 +23,7 @@ export const createDocument = async ({ title, documentDataId, teamId, + formValues, requestMetadata, }: CreateDocumentOptions) => { const user = await prisma.user.findFirstOrThrow({ @@ -51,6 +53,7 @@ export const createDocument = async ({ documentDataId, userId, teamId, + formValues, }, }); diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 7c928f9a9..acbcc499f 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -17,6 +17,9 @@ import { RECIPIENT_ROLES_DESCRIPTION, RECIPIENT_ROLE_TO_EMAIL_TYPE, } from '../../constants/recipient-roles'; +import { getFile } from '../../universal/upload/get-file'; +import { putFile } from '../../universal/upload/put-file'; +import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type SendDocumentOptions = { @@ -65,6 +68,7 @@ export const sendDocument = async ({ include: { Recipient: true, documentMeta: true, + documentData: true, }, }); @@ -82,6 +86,38 @@ export const sendDocument = async ({ throw new Error('Can not send completed document'); } + const { documentData } = document; + + if (!documentData.data) { + throw new Error('Document data not found'); + } + + if (document.formValues) { + const file = await getFile(documentData); + + const prefilled = await insertFormValuesInPdf({ + pdf: Buffer.from(file), + formValues: document.formValues as Record, + }); + + const newDocumentData = await putFile({ + name: document.title, + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(prefilled), + }); + + const result = await prisma.document.update({ + where: { + id: document.id, + }, + data: { + documentDataId: newDocumentData.id, + }, + }); + + Object.assign(document, result); + } + await Promise.all( document.Recipient.map(async (recipient) => { if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) { diff --git a/packages/lib/server-only/pdf/insert-form-values-in-pdf.ts b/packages/lib/server-only/pdf/insert-form-values-in-pdf.ts new file mode 100644 index 000000000..a3c311895 --- /dev/null +++ b/packages/lib/server-only/pdf/insert-form-values-in-pdf.ts @@ -0,0 +1,54 @@ +import { PDFCheckBox, PDFDocument, PDFDropdown, PDFRadioGroup, PDFTextField } from 'pdf-lib'; + +export type InsertFormValuesInPdfOptions = { + pdf: Buffer; + formValues: Record; +}; + +export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValuesInPdfOptions) => { + const doc = await PDFDocument.load(pdf); + + const form = doc.getForm(); + + if (!form) { + return pdf; + } + + for (const [key, value] of Object.entries(formValues)) { + try { + const field = form.getField(key); + + if (!field) { + continue; + } + + if (typeof value === 'boolean' && field instanceof PDFCheckBox) { + if (value) { + field.check(); + } else { + field.uncheck(); + } + } + + if (field instanceof PDFTextField) { + field.setText(value.toString()); + } + + if (field instanceof PDFDropdown) { + field.select(value.toString()); + } + + if (field instanceof PDFRadioGroup) { + field.select(value.toString()); + } + } catch (err) { + if (err instanceof Error) { + console.error(`Error setting value for field ${key}: ${err.message}`); + } else { + console.error(`Error setting value for field ${key}`); + } + } + } + + return await doc.save().then((buf) => Buffer.from(buf)); +}; 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 55519a30e..8ae5fecaf 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -79,6 +79,7 @@ export const createDocumentFromTemplate = async ({ id: 'asc', }, }, + documentData: true, }, }); diff --git a/packages/prisma/migrations/20240408083413_add_form_values_column/migration.sql b/packages/prisma/migrations/20240408083413_add_form_values_column/migration.sql new file mode 100644 index 000000000..fbf67b637 --- /dev/null +++ b/packages/prisma/migrations/20240408083413_add_form_values_column/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "formValues" JSONB; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 868b8d8e1..35d429779 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -257,6 +257,7 @@ model Document { userId Int User User @relation(fields: [userId], references: [id], onDelete: Cascade) authOptions Json? + formValues Json? title String status DocumentStatus @default(DRAFT) Recipient Recipient[] From 627265f0169272d553e98096da38d9f60ba4beb6 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Mon, 8 Apr 2024 15:28:50 +0300 Subject: [PATCH 077/174] fix: return updated doc (#1089) ## Description Fetch the updated version of the document after sealing it and return it. Previously, the `document.documentData.data` wasn't up to date. Now it is. ## Related Issue Fixes #1088. ## Testing Performed * Added console.logs in the code to make sure it returns the proper data * Set up a webhook and tested that the webhook receives the updated data ## Checklist - [x] I have tested these changes locally and they work as expected. - [ ] I have added/updated tests that prove the effectiveness of these changes. - [ ] I have updated the documentation to reflect these changes, if applicable. - [x] I have followed the project's coding style guidelines. - [ ] I have addressed the code review feedback from the previous submission, if applicable. --- packages/lib/server-only/document/seal-document.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 58480a7bd..ec5f93539 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -153,9 +153,19 @@ export const sealDocument = async ({ await sendCompletedEmail({ documentId, requestMetadata }); } + const updatedDocument = await prisma.document.findFirstOrThrow({ + where: { + id: document.id, + }, + include: { + documentData: true, + Recipient: true, + }, + }); + await triggerWebhook({ event: WebhookTriggerEvents.DOCUMENT_COMPLETED, - data: document, + data: updatedDocument, userId: document.userId, teamId: document.teamId ?? undefined, }); From 1400c335a5291a272ea82b1d7c113632c029d6da Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 9 Apr 2024 11:31:53 +0700 Subject: [PATCH 078/174] fix: improve document loading ui consistency (#1082) ## Description General UI updates ## Changes Made - Add consistent spacing between document edit/view/log pages - Add document status to document audit log page - Update document loading page to reserve space for the document status below the title - Update the document audit log page to show full dates in the correct locale --- .../[id]/edit/document-edit-page-view.tsx | 2 +- .../(dashboard)/documents/[id]/loading.tsx | 9 ++++- .../[id]/logs/document-logs-page-view.tsx | 39 +++++++++++++++---- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx index cab17c841..8a78ca9aa 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx @@ -100,7 +100,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie @@ -13,7 +15,12 @@ export default function Loading() {

Loading Document...

-
+ +
+ +
+ +
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx index 019ced57e..33d6cb8fe 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx @@ -2,16 +2,21 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; import { ChevronLeft, DownloadIcon } from 'lucide-react'; +import { DateTime } from 'luxon'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { getLocale } from '@documenso/lib/server-only/headers/get-locale'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import type { Recipient, Team } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; import { Card } from '@documenso/ui/primitives/card'; -import { FRIENDLY_STATUS_MAP } from '~/components/formatter/document-status'; +import { + DocumentStatus as DocumentStatusComponent, + FRIENDLY_STATUS_MAP, +} from '~/components/formatter/document-status'; import { DocumentLogsDataTable } from './document-logs-data-table'; @@ -23,6 +28,8 @@ export type DocumentLogsPageViewProps = { }; export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => { + const locale = getLocale(); + const { id } = params; const documentId = Number(id); @@ -67,15 +74,21 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie }, { description: 'Created by', - value: document.User.name ?? document.User.email, + value: document.User.name + ? `${document.User.name} (${document.User.email})` + : document.User.email, }, { description: 'Date created', - value: document.createdAt.toISOString(), + value: DateTime.fromJSDate(document.createdAt) + .setLocale(locale) + .toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS), }, { description: 'Last updated', - value: document.updatedAt.toISOString(), + value: DateTime.fromJSDate(document.updatedAt) + .setLocale(locale) + .toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS), }, { description: 'Time zone', @@ -90,7 +103,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie text = `${recipient.name} (${recipient.email})`; } - return `${text} - ${recipient.role}`; + return `[${recipient.role}] ${text}`; }; return ( @@ -104,9 +117,19 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
-

- {document.title} -

+
+

+ {document.title} +

+ +
+ +
+
- + - +
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx new file mode 100644 index 000000000..fce4d4855 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { DownloadIcon } from 'lucide-react'; + +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type DownloadAuditLogButtonProps = { + className?: string; + documentId: number; +}; + +export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => { + const { mutateAsync: downloadAuditLogs, isLoading } = + trpc.document.downloadAuditLogs.useMutation(); + + const onDownloadAuditLogsClick = async () => { + const { url } = await downloadAuditLogs({ documentId }); + + const iframe = Object.assign(document.createElement('iframe'), { + src: url, + }); + + Object.assign(iframe.style, { + position: 'fixed', + top: '0', + left: '0', + width: '0', + height: '0', + }); + + const onLoaded = () => { + if (iframe.contentDocument?.readyState === 'complete') { + iframe.contentWindow?.print(); + + iframe.contentWindow?.addEventListener('afterprint', () => { + document.body.removeChild(iframe); + }); + } + }; + + // When the iframe has loaded, print the iframe and remove it from the dom + iframe.addEventListener('load', onLoaded); + + document.body.appendChild(iframe); + + onLoaded(); + }; + + return ( + + ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx new file mode 100644 index 000000000..e0ae395b4 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { DownloadIcon } from 'lucide-react'; + +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type DownloadCertificateButtonProps = { + className?: string; + documentId: number; +}; + +export const DownloadCertificateButton = ({ + className, + documentId, +}: DownloadCertificateButtonProps) => { + const { mutateAsync: downloadCertificate, isLoading } = + trpc.document.downloadCertificate.useMutation(); + + const onDownloadCertificatesClick = async () => { + const { url } = await downloadCertificate({ documentId }); + + const iframe = Object.assign(document.createElement('iframe'), { + src: url, + }); + + Object.assign(iframe.style, { + position: 'fixed', + top: '0', + left: '0', + width: '0', + height: '0', + }); + + const onLoaded = () => { + if (iframe.contentDocument?.readyState === 'complete') { + iframe.contentWindow?.print(); + + iframe.contentWindow?.addEventListener('afterprint', () => { + document.body.removeChild(iframe); + }); + } + }; + + // When the iframe has loaded, print the iframe and remove it from the dom + iframe.addEventListener('load', onLoaded); + + document.body.appendChild(iframe); + + onLoaded(); + }; + + return ( + + ); +}; diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/data-table.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/data-table.tsx new file mode 100644 index 000000000..016a64fbb --- /dev/null +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/data-table.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { DateTime } from 'luxon'; +import type { DateTimeFormatOptions } from 'luxon'; +import { UAParser } from 'ua-parser-js'; + +import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs'; +import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@documenso/ui/primitives/table'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +export type AuditLogDataTableProps = { + logs: TDocumentAuditLog[]; +}; + +const dateFormat: DateTimeFormatOptions = { + ...DateTime.DATETIME_SHORT, + hourCycle: 'h12', +}; + +export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => { + const parser = new UAParser(); + + const uppercaseFistLetter = (text: string) => { + return text.charAt(0).toUpperCase() + text.slice(1); + }; + + return ( + + + + Time + User + Action + IP Address + Browser + + + + + {logs.map((log, i) => ( + + + + + + + {log.name || log.email ? ( +
+ {log.name && ( +

+ {log.name} +

+ )} + + {log.email && ( +

+ {log.email} +

+ )} +
+ ) : ( +

N/A

+ )} +
+ + + {uppercaseFistLetter(formatDocumentAuditLogAction(log).description)} + + + {log.ipAddress} + + + {log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'} + +
+ ))} +
+
+ ); +}; diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx new file mode 100644 index 000000000..c3bc94789 --- /dev/null +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx @@ -0,0 +1,141 @@ +import React from 'react'; + +import { redirect } from 'next/navigation'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; +import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; +import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +import { Logo } from '~/components/branding/logo'; +import { LocaleDate } from '~/components/formatter/locale-date'; + +import { AuditLogDataTable } from './data-table'; + +type AuditLogProps = { + searchParams: { + d: string; + }; +}; + +export default async function AuditLog({ searchParams }: AuditLogProps) { + const { d } = searchParams; + + if (typeof d !== 'string' || !d) { + // return redirect('/'); + } + + let rawDocumentId = decryptSecondaryData(d); + + if (!rawDocumentId || isNaN(Number(rawDocumentId))) { + // return redirect('/'); + + rawDocumentId = '31'; + } + + const documentId = Number(rawDocumentId); + + const document = await getEntireDocument({ + id: documentId, + }).catch(() => null); + + if (!document) { + return redirect('/'); + } + + const { data: auditLogs } = await findDocumentAuditLogs({ + documentId: documentId, + userId: document.userId, + perPage: 100_000, + }); + + return ( +
+
+

Version History

+
+ + + +

+ Document ID + + {document.id} +

+ +

+ Enclosed Document + + {document.title} +

+ +

+ Status + + {document.deletedAt ? 'DELETED' : document.status} +

+ +

+ Owner + + + {document.User.name} ({document.User.email}) + +

+ +

+ Created At + + + + +

+ +

+ Last Updated + + + + +

+ +

+ Time Zone + + + {document.documentMeta?.timezone ?? 'N/A'} + +

+ +
+

Recipients

+ +
    + {document.Recipient.map((recipient) => ( +
  • + + [{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}] + {' '} + {recipient.name} ({recipient.email}) +
  • + ))} +
+
+
+
+ + + + + + + +
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx new file mode 100644 index 000000000..33675f325 --- /dev/null +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -0,0 +1,315 @@ +import React from 'react'; + +import { redirect } from 'next/navigation'; + +import { UAParser } from 'ua-parser-js'; + +import { + RECIPIENT_ROLES_DESCRIPTION, + RECIPIENT_ROLE_SIGNING_REASONS, +} from '@documenso/lib/constants/recipient-roles'; +import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; +import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; +import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { + ZDocumentAuthOptionsSchema, + ZRecipientAuthOptionsSchema, +} from '@documenso/lib/types/document-auth'; +import { FieldType } from '@documenso/prisma/client'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@documenso/ui/primitives/table'; + +import { Logo } from '~/components/branding/logo'; +import { LocaleDate } from '~/components/formatter/locale-date'; + +type SigningCertificateProps = { + searchParams: { + d: string; + }; +}; + +const FRIENDLY_SIGNING_REASONS = { + ['__OWNER__']: 'I am the owner of this document', + ...RECIPIENT_ROLE_SIGNING_REASONS, +}; + +export default async function SigningCertificate({ searchParams }: SigningCertificateProps) { + const { d } = searchParams; + + if (typeof d !== 'string' || !d) { + // return redirect('/'); + } + + let rawDocumentId = decryptSecondaryData(d); + + if (!rawDocumentId || isNaN(Number(rawDocumentId))) { + // return redirect('/'); + + rawDocumentId = '31'; + } + + const documentId = Number(rawDocumentId); + + const document = await getEntireDocument({ + id: documentId, + }).catch(() => null); + + if (!document) { + return redirect('/'); + } + + const auditLogs = await getDocumentCertificateAuditLogs({ + id: documentId, + }); + + const isOwner = (email: string) => { + return email.toLowerCase() === document.User.email.toLowerCase(); + }; + + const getDevice = (userAgent?: string | null) => { + if (!userAgent) { + return 'Unknown'; + } + + const parser = new UAParser(userAgent); + + parser.setUA(userAgent); + + const result = parser.getResult(); + + return `${result.os.name} - ${result.browser.name} ${result.browser.version}`; + }; + + const getAuthenticationLevel = (recipientId: number) => { + const recipient = document.Recipient.find((recipient) => recipient.id === recipientId); + + if (!recipient) { + return 'Unknown'; + } + + const documentAuthOptions = ZDocumentAuthOptionsSchema.parse(document.authOptions); + const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); + + let authLevel = 'Email'; + + if ( + documentAuthOptions.globalAccessAuth === 'ACCOUNT' || + recipientAuthOptions.accessAuth === 'ACCOUNT' + ) { + authLevel = 'Account Authentication'; + } + + if ( + documentAuthOptions.globalActionAuth === 'ACCOUNT' || + recipientAuthOptions.actionAuth === 'ACCOUNT' + ) { + authLevel = 'Account Re-Authentication'; + } + + if ( + documentAuthOptions.globalActionAuth === 'TWO_FACTOR_AUTH' || + recipientAuthOptions.actionAuth === 'TWO_FACTOR_AUTH' + ) { + authLevel = 'Two Factor Re-Authentication'; + } + + if ( + documentAuthOptions.globalActionAuth === 'PASSKEY' || + recipientAuthOptions.actionAuth === 'PASSKEY' + ) { + authLevel = 'Passkey Re-Authentication'; + } + + if (recipientAuthOptions.actionAuth === 'EXPLICIT_NONE') { + authLevel = 'Email'; + } + + return authLevel; + }; + + const getRecipientAuditLogs = (recipientId: number) => { + return { + [DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT].filter( + (log) => + log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.data.recipientId === recipientId, + ), + [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs[ + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED + ].filter( + (log) => + log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED && + log.data.recipientId === recipientId, + ), + [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs[ + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED + ].filter( + (log) => + log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED && + log.data.recipientId === recipientId, + ), + }; + }; + + const getRecipientSignatureField = (recipientId: number) => { + return document.Recipient.find((recipient) => recipient.id === recipientId)?.Field.find( + (field) => field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE, + ); + }; + + return ( +
+
+

Signing Certificate

+
+ + + + + + + Signer Events + Signature + Details + {/* Security */} + + + + + {document.Recipient.map((recipient, i) => { + const logs = getRecipientAuditLogs(recipient.id); + const signature = getRecipientSignatureField(recipient.id); + + return ( + + +
{recipient.name}
+
{recipient.email}
+

+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

+ +

+ Authentication Level:{' '} + {getAuthenticationLevel(recipient.id)} +

+
+ + + {signature ? ( + <> +
+ Signature +
+ +

+ Signature ID:{' '} + + {signature.secondaryId} + +

+ +

+ IP Address:{' '} + + {logs.DOCUMENT_RECIPIENT_COMPLETED[0].ipAddress} + +

+ +

+ Device:{' '} + + {getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0].userAgent)} + +

+ + ) : ( +

N/A

+ )} +
+ + +
+

+ Sent:{' '} + + + +

+ +

+ Viewed:{' '} + + + +

+ +

+ Signed:{' '} + + + +

+ +

+ Reason:{' '} + + {isOwner(recipient.email) + ? FRIENDLY_SIGNING_REASONS['__OWNER__'] + : FRIENDLY_SIGNING_REASONS[recipient.role]} + +

+
+
+ + {/* +

+ Authentication: {''} +

+

IP: {''}

+
*/} +
+ ); + })} +
+
+
+
+ +
+
+

+ Signing certificate provided by: +

+ + +
+
+
+ ); +} diff --git a/package-lock.json b/package-lock.json index 9eb4d3818..e305355ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24926,6 +24926,7 @@ "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", + "playwright": "^1.43.0", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -24936,6 +24937,19 @@ "@types/luxon": "^3.3.1" } }, + "packages/lib/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "packages/lib/node_modules/nanoid": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", @@ -24953,6 +24967,34 @@ "node": "^14 || ^16 || >=18" } }, + "packages/lib/node_modules/playwright": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", + "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==", + "dependencies": { + "playwright-core": "1.43.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "packages/lib/node_modules/playwright-core": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", + "integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "packages/prettier-config": { "name": "@documenso/prettier-config", "version": "0.0.0", diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts index ce1037dd9..59af9b3b5 100644 --- a/packages/lib/constants/recipient-roles.ts +++ b/packages/lib/constants/recipient-roles.ts @@ -32,3 +32,10 @@ export const RECIPIENT_ROLE_TO_EMAIL_TYPE = { [RecipientRole.VIEWER]: 'VIEW_REQUEST', [RecipientRole.APPROVER]: 'APPROVE_REQUEST', } as const; + +export const RECIPIENT_ROLE_SIGNING_REASONS = { + [RecipientRole.SIGNER]: 'I am a signer of this document', + [RecipientRole.APPROVER]: 'I am an approver of this document', + [RecipientRole.CC]: 'I am required to recieve a copy of this document', + [RecipientRole.VIEWER]: 'I am a viewer of this document', +} satisfies Record; diff --git a/packages/lib/package.json b/packages/lib/package.json index 7a32b3058..616e391d0 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -39,6 +39,7 @@ "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", + "playwright": "^1.43.0", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -48,4 +49,4 @@ "devDependencies": { "@types/luxon": "^3.3.1" } -} +} \ No newline at end of file diff --git a/packages/lib/server-only/admin/get-entire-document.ts b/packages/lib/server-only/admin/get-entire-document.ts index e74ee4c7b..8b7650d7b 100644 --- a/packages/lib/server-only/admin/get-entire-document.ts +++ b/packages/lib/server-only/admin/get-entire-document.ts @@ -10,6 +10,14 @@ export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => { id, }, include: { + documentMeta: true, + User: { + select: { + id: true, + name: true, + email: true, + }, + }, Recipient: { include: { Field: { diff --git a/packages/lib/server-only/document/get-document-certificate-audit-logs.ts b/packages/lib/server-only/document/get-document-certificate-audit-logs.ts new file mode 100644 index 000000000..e517a4608 --- /dev/null +++ b/packages/lib/server-only/document/get-document-certificate-audit-logs.ts @@ -0,0 +1,43 @@ +import { prisma } from '@documenso/prisma'; + +import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../types/document-audit-logs'; +import { parseDocumentAuditLogData } from '../../utils/document-audit-logs'; + +export type GetDocumentCertificateAuditLogsOptions = { + id: number; +}; + +export const getDocumentCertificateAuditLogs = async ({ + id, +}: GetDocumentCertificateAuditLogsOptions) => { + const rawAuditLogs = await prisma.documentAuditLog.findMany({ + where: { + documentId: id, + type: { + in: [ + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, + DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, + ], + }, + }, + }); + + const auditLogs = rawAuditLogs.map((log) => parseDocumentAuditLogData(log)); + + const groupedAuditLogs = { + [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter( + (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, + ), + [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter( + (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, + ), + [DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter( + (log) => + log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && + log.data.emailType !== DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED, + ), + } as const; + + return groupedAuditLogs; +}; diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index ec5f93539..3e366dc81 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -15,6 +15,7 @@ import { signPdf } from '@documenso/signing'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { getFile } from '../../universal/upload/get-file'; import { putFile } from '../../universal/upload/put-file'; +import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf'; import { flattenAnnotations } from '../pdf/flatten-annotations'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances'; @@ -91,6 +92,10 @@ export const sealDocument = async ({ // !: Need to write the fields onto the document as a hard copy const pdfData = await getFile(documentData); + const certificate = await getCertificatePdf({ documentId }).then(async (doc) => + PDFDocument.load(doc), + ); + const doc = await PDFDocument.load(pdfData); // Normalize and flatten layers that could cause issues with the signature @@ -98,6 +103,12 @@ export const sealDocument = async ({ doc.getForm().flatten(); flattenAnnotations(doc); + const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices()); + + certificatePages.forEach((page) => { + doc.addPage(page); + }); + for (const field of fields) { await insertFieldInPDF(doc, field); } diff --git a/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts new file mode 100644 index 000000000..a7182410e --- /dev/null +++ b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts @@ -0,0 +1,45 @@ +import { DateTime } from 'luxon'; +import type { Browser } from 'playwright'; +import { chromium } from 'playwright'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import { encryptSecondaryData } from '../crypto/encrypt'; + +export type GetCertificatePdfOptions = { + documentId: number; +}; + +export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions) => { + const encryptedId = encryptSecondaryData({ + data: documentId.toString(), + expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), + }); + + let browser: Browser; + + if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) { + browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL); + } else { + browser = await chromium.launch(); + } + + if (!browser) { + throw new Error( + 'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed', + ); + } + + const page = await browser.newPage(); + + await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, { + waitUntil: 'networkidle', + }); + + const result = await page.pdf({ + format: 'A4', + }); + + void browser.close(); + + return result; +}; diff --git a/packages/tailwind-config/index.cjs b/packages/tailwind-config/index.cjs index 92222462f..01e7296d3 100644 --- a/packages/tailwind-config/index.cjs +++ b/packages/tailwind-config/index.cjs @@ -7,6 +7,9 @@ module.exports = { content: ['src/**/*.{ts,tsx}'], theme: { extend: { + screens: { + print: { raw: 'print' }, + }, fontFamily: { sans: ['var(--font-sans)', ...fontFamily.sans], signature: ['var(--font-signature)'], diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 6e7e8764f..3cc61bef2 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -1,7 +1,10 @@ import { TRPCError } from '@trpc/server'; +import { DateTime } from 'luxon'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; @@ -22,6 +25,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc'; import { ZCreateDocumentMutationSchema, ZDeleteDraftDocumentMutationSchema as ZDeleteDocumentMutationSchema, + ZDownloadAuditLogsMutationSchema, ZFindDocumentAuditLogsQuerySchema, ZGetDocumentByIdQuerySchema, ZGetDocumentByTokenQuerySchema, @@ -364,4 +368,66 @@ export const documentRouter = router({ }); } }), + + downloadAuditLogs: authenticatedProcedure + .input(ZDownloadAuditLogsMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, teamId } = input; + + const document = await getDocumentById({ + id: documentId, + userId: ctx.user.id, + teamId, + }); + + const encrypted = encryptSecondaryData({ + data: document.id.toString(), + expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), + }); + + return { + url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encrypted}`, + }; + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'We were unable to download the audit logs for this document. Please try again later.', + }); + } + }), + + downloadCertificate: authenticatedProcedure + .input(ZDownloadAuditLogsMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, teamId } = input; + + const document = await getDocumentById({ + id: documentId, + userId: ctx.user.id, + teamId, + }); + + const encrypted = encryptSecondaryData({ + data: document.id.toString(), + expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), + }); + + return { + url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encrypted}`, + }; + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'We were unable to download the audit logs for this document. Please try again later.', + }); + } + }), }); diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 6ed6fcc4d..483d32e50 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -163,3 +163,8 @@ export type TDeleteDraftDocumentMutationSchema = z.infer>( - ({ className, ...props }, ref) => ( -
- - - ), -); +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes & { + overflowHidden?: boolean; + } +>(({ className, overflowHidden, ...props }, ref) => ( +
+
+ +)); Table.displayName = 'Table'; @@ -76,11 +79,17 @@ TableHead.displayName = 'TableHead'; const TableCell = React.forwardRef< HTMLTableCellElement, - React.TdHTMLAttributes ->(({ className, ...props }, ref) => ( + React.TdHTMLAttributes & { + truncate?: boolean; + } +>(({ className, truncate = true, ...props }, ref) => (
)); diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css index cb2d9d5c5..fa9231e5d 100644 --- a/packages/ui/styles/theme.css +++ b/packages/ui/styles/theme.css @@ -97,6 +97,21 @@ } } +/* + * Custom CSS for printing reports + * - Sets page margins to 0.5 inches + * - Hides the header and footer + * - Hides the print button + * - Sets page size to A4 + * - Sets the font size to 12pt + */ +.print-provider { + @page { + margin: 1in; + size: A4; + } +} + .gradient-border-mask::before { mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); diff --git a/turbo.json b/turbo.json index 6579441be..fa89193eb 100644 --- a/turbo.json +++ b/turbo.json @@ -2,8 +2,13 @@ "$schema": "https://turbo.build/schema.json", "pipeline": { "build": { - "dependsOn": ["^build"], - "outputs": [".next/**", "!.next/cache/**"] + "dependsOn": [ + "^build" + ], + "outputs": [ + ".next/**", + "!.next/cache/**" + ] }, "lint": { "cache": false @@ -19,7 +24,9 @@ "persistent": true }, "start": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "cache": false, "persistent": true }, @@ -27,11 +34,15 @@ "cache": false }, "test:e2e": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "cache": false } }, - "globalDependencies": ["**/.env.*local"], + "globalDependencies": [ + "**/.env.*local" + ], "globalEnv": [ "APP_VERSION", "NEXT_PRIVATE_ENCRYPTION_KEY", @@ -93,6 +104,7 @@ "NEXT_PRIVATE_STRIPE_API_KEY", "NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET", "NEXT_PRIVATE_GITHUB_TOKEN", + "NEXT_PRIVATE_BROWSERLESS_URL", "CI", "VERCEL", "VERCEL_ENV", @@ -110,4 +122,4 @@ "E2E_TEST_AUTHENTICATE_USER_EMAIL", "E2E_TEST_AUTHENTICATE_USER_PASSWORD" ] -} +} \ No newline at end of file From c9b4915fc83cd8fea6f12e842685fa7102939ac7 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 10 Apr 2024 15:30:04 +0700 Subject: [PATCH 084/174] fix: remove hardcoded ids --- .../src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx | 8 +++----- .../app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx | 8 +++----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx index c3bc94789..1db089495 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx @@ -23,15 +23,13 @@ export default async function AuditLog({ searchParams }: AuditLogProps) { const { d } = searchParams; if (typeof d !== 'string' || !d) { - // return redirect('/'); + return redirect('/'); } - let rawDocumentId = decryptSecondaryData(d); + const rawDocumentId = decryptSecondaryData(d); if (!rawDocumentId || isNaN(Number(rawDocumentId))) { - // return redirect('/'); - - rawDocumentId = '31'; + return redirect('/'); } const documentId = Number(rawDocumentId); diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx index 33675f325..690f0eb78 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -45,15 +45,13 @@ export default async function SigningCertificate({ searchParams }: SigningCertif const { d } = searchParams; if (typeof d !== 'string' || !d) { - // return redirect('/'); + return redirect('/'); } - let rawDocumentId = decryptSecondaryData(d); + const rawDocumentId = decryptSecondaryData(d); if (!rawDocumentId || isNaN(Number(rawDocumentId))) { - // return redirect('/'); - - rawDocumentId = '31'; + return redirect('/'); } const documentId = Number(rawDocumentId); From 4d4dfd3c5fa4719baa36a693121ca5d6d208017c Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 10 Apr 2024 17:38:34 +0700 Subject: [PATCH 085/174] fix: implement review feedback, resolve build errors --- apps/marketing/next.config.js | 2 +- apps/web/next.config.js | 2 +- .../[id]/logs/download-audit-log-button.tsx | 59 +++++---- .../[id]/logs/download-certificate-button.tsx | 59 +++++---- .../%5F%5Fhtmltopdf/certificate/page.tsx | 42 ++----- package-lock.json | 117 ++++++++++-------- package.json | 3 +- packages/lib/package.json | 3 +- 8 files changed, 155 insertions(+), 132 deletions(-) diff --git a/apps/marketing/next.config.js b/apps/marketing/next.config.js index 0f7b7ad5c..c8c89e45d 100644 --- a/apps/marketing/next.config.js +++ b/apps/marketing/next.config.js @@ -22,7 +22,7 @@ const FONT_CAVEAT_BYTES = fs.readFileSync( const config = { experimental: { outputFileTracingRoot: path.join(__dirname, '../../'), - serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'], + serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'], serverActions: { bodySizeLimit: '50mb', }, diff --git a/apps/web/next.config.js b/apps/web/next.config.js index af82847c0..85d3097ca 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -23,7 +23,7 @@ const config = { output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined, experimental: { outputFileTracingRoot: path.join(__dirname, '../../'), - serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'], + serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'], serverActions: { bodySizeLimit: '50mb', }, diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx index fce4d4855..0847d63fa 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx @@ -5,6 +5,7 @@ import { DownloadIcon } from 'lucide-react'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; export type DownloadAuditLogButtonProps = { className?: string; @@ -12,40 +13,52 @@ export type DownloadAuditLogButtonProps = { }; export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => { + const { toast } = useToast(); + const { mutateAsync: downloadAuditLogs, isLoading } = trpc.document.downloadAuditLogs.useMutation(); const onDownloadAuditLogsClick = async () => { - const { url } = await downloadAuditLogs({ documentId }); + try { + const { url } = await downloadAuditLogs({ documentId }); - const iframe = Object.assign(document.createElement('iframe'), { - src: url, - }); + const iframe = Object.assign(document.createElement('iframe'), { + src: url, + }); - Object.assign(iframe.style, { - position: 'fixed', - top: '0', - left: '0', - width: '0', - height: '0', - }); + Object.assign(iframe.style, { + position: 'fixed', + top: '0', + left: '0', + width: '0', + height: '0', + }); - const onLoaded = () => { - if (iframe.contentDocument?.readyState === 'complete') { - iframe.contentWindow?.print(); + const onLoaded = () => { + if (iframe.contentDocument?.readyState === 'complete') { + iframe.contentWindow?.print(); - iframe.contentWindow?.addEventListener('afterprint', () => { - document.body.removeChild(iframe); - }); - } - }; + iframe.contentWindow?.addEventListener('afterprint', () => { + document.body.removeChild(iframe); + }); + } + }; - // When the iframe has loaded, print the iframe and remove it from the dom - iframe.addEventListener('load', onLoaded); + // When the iframe has loaded, print the iframe and remove it from the dom + iframe.addEventListener('load', onLoaded); - document.body.appendChild(iframe); + document.body.appendChild(iframe); - onLoaded(); + onLoaded(); + } catch (error) { + console.error(error); + + toast({ + title: 'Something went wrong', + description: 'Sorry, we were unable to download the audit logs. Please try again later.', + variant: 'destructive', + }); + } }; return ( diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx index e0ae395b4..49a330b94 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx @@ -5,6 +5,7 @@ import { DownloadIcon } from 'lucide-react'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; export type DownloadCertificateButtonProps = { className?: string; @@ -15,40 +16,52 @@ export const DownloadCertificateButton = ({ className, documentId, }: DownloadCertificateButtonProps) => { + const { toast } = useToast(); + const { mutateAsync: downloadCertificate, isLoading } = trpc.document.downloadCertificate.useMutation(); const onDownloadCertificatesClick = async () => { - const { url } = await downloadCertificate({ documentId }); + try { + const { url } = await downloadCertificate({ documentId }); - const iframe = Object.assign(document.createElement('iframe'), { - src: url, - }); + const iframe = Object.assign(document.createElement('iframe'), { + src: url, + }); - Object.assign(iframe.style, { - position: 'fixed', - top: '0', - left: '0', - width: '0', - height: '0', - }); + Object.assign(iframe.style, { + position: 'fixed', + top: '0', + left: '0', + width: '0', + height: '0', + }); - const onLoaded = () => { - if (iframe.contentDocument?.readyState === 'complete') { - iframe.contentWindow?.print(); + const onLoaded = () => { + if (iframe.contentDocument?.readyState === 'complete') { + iframe.contentWindow?.print(); - iframe.contentWindow?.addEventListener('afterprint', () => { - document.body.removeChild(iframe); - }); - } - }; + iframe.contentWindow?.addEventListener('afterprint', () => { + document.body.removeChild(iframe); + }); + } + }; - // When the iframe has loaded, print the iframe and remove it from the dom - iframe.addEventListener('load', onLoaded); + // When the iframe has loaded, print the iframe and remove it from the dom + iframe.addEventListener('load', onLoaded); - document.body.appendChild(iframe); + document.body.appendChild(iframe); - onLoaded(); + onLoaded(); + } catch (error) { + console.error(error); + + toast({ + title: 'Something went wrong', + description: 'Sorry, we were unable to download the certificate. Please try again later.', + variant: 'destructive', + }); + } }; return ( diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx index 690f0eb78..4924e832b 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -12,10 +12,7 @@ import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-d import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; -import { - ZDocumentAuthOptionsSchema, - ZRecipientAuthOptionsSchema, -} from '@documenso/lib/types/document-auth'; +import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { FieldType } from '@documenso/prisma/client'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { @@ -93,40 +90,30 @@ export default async function SigningCertificate({ searchParams }: SigningCertif return 'Unknown'; } - const documentAuthOptions = ZDocumentAuthOptionsSchema.parse(document.authOptions); - const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); + const extractedAuthMethods = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }); let authLevel = 'Email'; - if ( - documentAuthOptions.globalAccessAuth === 'ACCOUNT' || - recipientAuthOptions.accessAuth === 'ACCOUNT' - ) { + if (extractedAuthMethods.derivedRecipientAccessAuth === 'ACCOUNT') { authLevel = 'Account Authentication'; } - if ( - documentAuthOptions.globalActionAuth === 'ACCOUNT' || - recipientAuthOptions.actionAuth === 'ACCOUNT' - ) { + if (extractedAuthMethods.derivedRecipientActionAuth === 'ACCOUNT') { authLevel = 'Account Re-Authentication'; } - if ( - documentAuthOptions.globalActionAuth === 'TWO_FACTOR_AUTH' || - recipientAuthOptions.actionAuth === 'TWO_FACTOR_AUTH' - ) { - authLevel = 'Two Factor Re-Authentication'; + if (extractedAuthMethods.derivedRecipientActionAuth === 'TWO_FACTOR_AUTH') { + authLevel = 'Two-Factor Re-Authentication'; } - if ( - documentAuthOptions.globalActionAuth === 'PASSKEY' || - recipientAuthOptions.actionAuth === 'PASSKEY' - ) { + if (extractedAuthMethods.derivedRecipientActionAuth === 'PASSKEY') { authLevel = 'Passkey Re-Authentication'; } - if (recipientAuthOptions.actionAuth === 'EXPLICIT_NONE') { + if (extractedAuthMethods.derivedRecipientActionAuth === 'EXPLICIT_NONE') { authLevel = 'Email'; } @@ -284,13 +271,6 @@ export default async function SigningCertificate({ searchParams }: SigningCertif

- - {/* -

- Authentication: {''} -

-

IP: {''}

-
*/} ); })} diff --git a/package-lock.json b/package-lock.json index e305355ef..fb03b3a67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "eslint-config-custom": "*", "husky": "^9.0.11", "lint-staged": "^15.2.2", + "playwright": "^1.43.0", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" @@ -4701,6 +4702,19 @@ "node": ">=14" } }, + "node_modules/@playwright/browser-chromium": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz", + "integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "playwright-core": "1.43.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@playwright/test": { "version": "1.40.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz", @@ -4716,6 +4730,50 @@ "node": ">=16" } }, + "node_modules/@playwright/test/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/@playwright/test/node_modules/playwright": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz", + "integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==", + "dev": true, + "dependencies": { + "playwright-core": "1.40.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/@playwright/test/node_modules/playwright-core": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz", + "integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@prisma/client": { "version": "5.4.2", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.4.2.tgz", @@ -17615,12 +17673,11 @@ } }, "node_modules/playwright": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz", - "integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==", - "dev": true, + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", + "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==", "dependencies": { - "playwright-core": "1.40.0" + "playwright-core": "1.43.0" }, "bin": { "playwright": "cli.js" @@ -17633,10 +17690,9 @@ } }, "node_modules/playwright-core": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz", - "integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==", - "dev": true, + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", + "integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==", "bin": { "playwright-core": "cli.js" }, @@ -17648,7 +17704,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -24934,22 +24989,10 @@ "zod": "^3.22.4" }, "devDependencies": { + "@playwright/browser-chromium": "^1.43.0", "@types/luxon": "^3.3.1" } }, - "packages/lib/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "packages/lib/node_modules/nanoid": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", @@ -24967,34 +25010,6 @@ "node": "^14 || ^16 || >=18" } }, - "packages/lib/node_modules/playwright": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", - "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==", - "dependencies": { - "playwright-core": "1.43.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "packages/lib/node_modules/playwright-core": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", - "integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, "packages/prettier-config": { "name": "@documenso/prettier-config", "version": "0.0.0", diff --git a/package.json b/package.json index bafada07a..396b2ecfd 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "eslint-config-custom": "*", "husky": "^9.0.11", "lint-staged": "^15.2.2", + "playwright": "^1.43.0", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" @@ -59,4 +60,4 @@ "next": "14.0.3" } } -} +} \ No newline at end of file diff --git a/packages/lib/package.json b/packages/lib/package.json index 616e391d0..1aa7e431e 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -47,6 +47,7 @@ "zod": "^3.22.4" }, "devDependencies": { - "@types/luxon": "^3.3.1" + "@types/luxon": "^3.3.1", + "@playwright/browser-chromium": "^1.43.0" } } \ No newline at end of file From 0bc9c590a7b58818f12b965f43bf1980c93b7b8f Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 10 Apr 2024 20:01:27 +0700 Subject: [PATCH 086/174] fix: use ts-match --- .../%5F%5Fhtmltopdf/certificate/page.tsx | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx index 4924e832b..cbdaa451d 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { redirect } from 'next/navigation'; +import { match } from 'ts-pattern'; import { UAParser } from 'ua-parser-js'; import { @@ -95,26 +96,19 @@ export default async function SigningCertificate({ searchParams }: SigningCertif recipientAuth: recipient.authOptions, }); - let authLevel = 'Email'; + let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth) + .with('ACCOUNT', () => 'Account Re-Authentication') + .with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication') + .with('PASSKEY', () => 'Passkey Re-Authentication') + .with('EXPLICIT_NONE', () => 'Email') + .with(null, () => null) + .exhaustive(); - if (extractedAuthMethods.derivedRecipientAccessAuth === 'ACCOUNT') { - authLevel = 'Account Authentication'; - } - - if (extractedAuthMethods.derivedRecipientActionAuth === 'ACCOUNT') { - authLevel = 'Account Re-Authentication'; - } - - if (extractedAuthMethods.derivedRecipientActionAuth === 'TWO_FACTOR_AUTH') { - authLevel = 'Two-Factor Re-Authentication'; - } - - if (extractedAuthMethods.derivedRecipientActionAuth === 'PASSKEY') { - authLevel = 'Passkey Re-Authentication'; - } - - if (extractedAuthMethods.derivedRecipientActionAuth === 'EXPLICIT_NONE') { - authLevel = 'Email'; + if (!authLevel) { + authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth) + .with('ACCOUNT', () => 'Account Authentication') + .with(null, () => 'Email') + .exhaustive(); } return authLevel; From e36763a85dbdd93ba2c9a13b5c2c5ecf016ab3b4 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 10 Apr 2024 16:07:14 +0300 Subject: [PATCH 087/174] feat: update marketing banner (#1095) ![CleanShot 2024-04-10 at 15 51 27](https://github.com/documenso/documenso/assets/25515812/d2ad275c-4e68-42f2-8882-a20129c0b0bd) --- apps/marketing/src/app/(marketing)/layout.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/layout.tsx b/apps/marketing/src/app/(marketing)/layout.tsx index 75c2d177c..97560b80e 100644 --- a/apps/marketing/src/app/(marketing)/layout.tsx +++ b/apps/marketing/src/app/(marketing)/layout.tsx @@ -2,10 +2,8 @@ import React, { useEffect, useState } from 'react'; -import Image from 'next/image'; import { usePathname } from 'next/navigation'; -import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { cn } from '@documenso/ui/lib/utils'; @@ -48,16 +46,8 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) { })} > {showProfilesAnnouncementBar && ( -
-
- Launch Week 2 -
- -
+
+
Claim your documenso public profile username now!{' '} documenso.com/u/yourname
From 12e4bc918dd8638daa515b04313a9ad5690de691 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 10 Apr 2024 16:30:11 +0300 Subject: [PATCH 088/174] fix: marketing header darkmode (#1096) Co-authored-by: Adithya Krishna --- apps/marketing/src/app/(marketing)/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/src/app/(marketing)/layout.tsx b/apps/marketing/src/app/(marketing)/layout.tsx index 97560b80e..461ea0cae 100644 --- a/apps/marketing/src/app/(marketing)/layout.tsx +++ b/apps/marketing/src/app/(marketing)/layout.tsx @@ -47,7 +47,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) { > {showProfilesAnnouncementBar && (
-
+
Claim your documenso public profile username now!{' '} documenso.com/u/yourname
From f7ae3104ea5594829ddb645ae2c38e0b3e5aefd3 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Wed, 10 Apr 2024 17:05:22 +0200 Subject: [PATCH 089/174] fix: status widget rerendering --- .../(marketing)/status-widget-container.tsx | 2 +- .../src/components/(marketing)/status-widget.tsx | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/marketing/src/components/(marketing)/status-widget-container.tsx b/apps/marketing/src/components/(marketing)/status-widget-container.tsx index 025c2df56..71fbec9cb 100644 --- a/apps/marketing/src/components/(marketing)/status-widget-container.tsx +++ b/apps/marketing/src/components/(marketing)/status-widget-container.tsx @@ -6,7 +6,7 @@ import { StatusWidget } from './status-widget'; export function StatusWidgetContainer() { return ( }> - + ); } diff --git a/apps/marketing/src/components/(marketing)/status-widget.tsx b/apps/marketing/src/components/(marketing)/status-widget.tsx index 1c94c0707..0b6b8aaa6 100644 --- a/apps/marketing/src/components/(marketing)/status-widget.tsx +++ b/apps/marketing/src/components/(marketing)/status-widget.tsx @@ -1,7 +1,6 @@ -import { use, useMemo } from 'react'; +import { memo, use } from 'react'; -import type { Status } from '@openstatus/react'; -import { getStatus } from '@openstatus/react'; +import { type Status, getStatus } from '@openstatus/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -45,9 +44,8 @@ const getStatusLevel = (level: Status) => { }[level]; }; -export function StatusWidget() { - const getStatusMemoized = useMemo(async () => getStatus('documenso-status'), []); - const { status } = use(getStatusMemoized); +export const StatusWidget = memo(function StatusWidget({ slug }: { slug: string }) { + const { status } = use(getStatus(slug)); const level = getStatusLevel(status); return ( @@ -72,4 +70,4 @@ export function StatusWidget() { ); -} +}); From 93a149d637c010736e2d46e852518235ecf61ba6 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 10 Apr 2024 22:10:20 +0700 Subject: [PATCH 090/174] fix: handle older cert data --- .../%5F%5Fhtmltopdf/certificate/page.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx index cbdaa451d..d096d1a84 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -207,14 +207,16 @@ export default async function SigningCertificate({ searchParams }: SigningCertif

IP Address:{' '} - {logs.DOCUMENT_RECIPIENT_COMPLETED[0].ipAddress} + {logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'}

Device:{' '} - {getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0].userAgent)} + {getDevice( + logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent ?? 'Unknown', + )}

@@ -229,7 +231,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif Sent:{' '} @@ -238,20 +240,28 @@ export default async function SigningCertificate({ searchParams }: SigningCertif

Viewed:{' '} - + {logs.DOCUMENT_OPENED[0] ? ( + + ) : ( + 'Unknown' + )}

Signed:{' '} - + {logs.DOCUMENT_RECIPIENT_COMPLETED[0] ? ( + + ) : ( + 'Unknown' + )}

From bfff1234bb76942a412055c6fbf87faa6a50b15e Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 10 Apr 2024 22:32:25 +0700 Subject: [PATCH 091/174] fix: handle older cert data --- .../(internal)/%5F%5Fhtmltopdf/certificate/page.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx index d096d1a84..5b233e47b 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -230,10 +230,14 @@ export default async function SigningCertificate({ searchParams }: SigningCertif

Sent:{' '} - + {logs.EMAIL_SENT[0] ? ( + + ) : ( + 'Unknown' + )}

From 6f3cea52e8af7d4668df83e49a7085c3f11b3948 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 10 Apr 2024 22:34:14 +0700 Subject: [PATCH 092/174] fix: handle older cert data --- .../src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx index 5b233e47b..447e4ad72 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -214,9 +214,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif

Device:{' '} - {getDevice( - logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent ?? 'Unknown', - )} + {getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}

From a4967f19e8d8d856a4caac42fb2e7897799cfa09 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 10 Apr 2024 18:22:46 +0200 Subject: [PATCH 093/174] fix: remove status widget for now --- apps/marketing/src/components/(marketing)/footer.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index 8d2e0c1d4..550febfb6 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -13,8 +13,6 @@ import LogoImage from '@documenso/assets/logo.png'; import { cn } from '@documenso/ui/lib/utils'; import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher'; -import { StatusWidgetContainer } from './status-widget-container'; - export type FooterProps = HTMLAttributes; const SOCIAL_LINKS = [ @@ -65,9 +63,9 @@ export const Footer = ({ className, ...props }: FooterProps) => { ))}
-
+ {/*
-
+
*/}
From a82975fd78535f5794856ee36023d53944418725 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 10 Apr 2024 18:24:32 +0200 Subject: [PATCH 094/174] chore: keep import until fix or complete remove --- apps/marketing/src/components/(marketing)/footer.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index 550febfb6..e9a08049c 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -13,6 +13,8 @@ import LogoImage from '@documenso/assets/logo.png'; import { cn } from '@documenso/ui/lib/utils'; import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher'; +// import { StatusWidgetContainer } from './status-widget-container'; + export type FooterProps = HTMLAttributes; const SOCIAL_LINKS = [ From 8b58f10cbe71ad4f311c66803c36c855b83a9485 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Thu, 11 Apr 2024 10:09:04 +0300 Subject: [PATCH 095/174] feat: add cta on complete page (#1028) ![CleanShot 2024-03-18 at 11 45 40](https://github.com/documenso/documenso/assets/25515812/ae3b88de-359d-4019-866a-a76097bbb0fe) ![CleanShot 2024-03-18 at 11 46 25](https://github.com/documenso/documenso/assets/25515812/b5ff7078-623e-476c-8800-17d14bc8efa9) ## Summary by CodeRabbit - **New Features** - Introduced a "Claim Account" feature allowing new users to sign up by providing their name, email, and password. - Enhanced user experience for both logged-in and non-logged-in users with improved UI/UX and additional functionality. - **Enhancements** - Implemented form validation and error handling for a smoother sign-up process. - Integrated analytics to track user actions during account claiming. --------- Co-authored-by: Lucas Smith Co-authored-by: David Nguyen --- .../sign/[token]/complete/claim-account.tsx | 155 +++++++++++++++ .../(signing)/sign/[token]/complete/page.tsx | 186 ++++++++++-------- .../document-flow/stepper-component.spec.ts | 2 +- packages/trpc/server/auth-router/router.ts | 2 +- packages/trpc/server/auth-router/schema.ts | 2 +- 5 files changed, 266 insertions(+), 81 deletions(-) create mode 100644 apps/web/src/app/(signing)/sign/[token]/complete/claim-account.tsx diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/claim-account.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/claim-account.tsx new file mode 100644 index 000000000..b5f7c0ca8 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/complete/claim-account.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; +import { TRPCClientError } from '@documenso/trpc/client'; +import { trpc } from '@documenso/trpc/react'; +import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type ClaimAccountProps = { + defaultName: string; + defaultEmail: string; + trigger?: React.ReactNode; +}; + +export const ZClaimAccountFormSchema = z + .object({ + name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), + email: z.string().email().min(1), + password: ZPasswordSchema, + }) + .refine( + (data) => { + const { name, email, password } = data; + return !password.includes(name) && !password.includes(email.split('@')[0]); + }, + { + message: 'Password should not be common or based on personal information', + path: ['password'], + }, + ); + +export type TClaimAccountFormSchema = z.infer; + +export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) => { + const analytics = useAnalytics(); + const { toast } = useToast(); + const router = useRouter(); + + const { mutateAsync: signup } = trpc.auth.signup.useMutation(); + + const form = useForm({ + values: { + name: defaultName ?? '', + email: defaultEmail, + password: '', + }, + resolver: zodResolver(ZClaimAccountFormSchema), + }); + + const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => { + try { + await signup({ name, email, password }); + + router.push(`/unverified-account`); + + toast({ + title: 'Registration Successful', + description: + 'You have successfully registered. Please verify your account by clicking on the link you received in the email.', + duration: 5000, + }); + + analytics.capture('App: User Claim Account', { + email, + timestamp: new Date().toISOString(), + }); + } catch (error) { + if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') { + toast({ + title: 'An error occurred', + description: error.message, + variant: 'destructive', + }); + } else { + toast({ + title: 'An unknown error occurred', + description: + 'We encountered an unknown error while attempting to sign you up. Please try again later.', + variant: 'destructive', + }); + } + } + }; + + return ( +
+
+ +
+ ( + + Name + + + + + + )} + /> + ( + + Email address + + + + + + )} + /> + ( + + Set a password + + + + + + )} + /> + + +
+
+ +
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index c13d8636b..cfed976e5 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -3,6 +3,7 @@ import { notFound } from 'next/navigation'; import { CheckCircle2, Clock8 } from 'lucide-react'; import { getServerSession } from 'next-auth'; +import { env } from 'next-runtime-env'; import { match } from 'ts-pattern'; import signingCelebration from '@documenso/assets/images/signing-celebration.png'; @@ -16,10 +17,13 @@ import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/clie import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { SigningCard3D } from '@documenso/ui/components/signing-card'; +import { cn } from '@documenso/ui/lib/utils'; +import { Badge } from '@documenso/ui/primitives/badge'; import { truncateTitle } from '~/helpers/truncate-title'; import { SigningAuthPageView } from '../signing-auth-page'; +import { ClaimAccount } from './claim-account'; import { DocumentPreviewButton } from './document-preview-button'; export type CompletedSigningPageProps = { @@ -31,6 +35,8 @@ export type CompletedSigningPageProps = { export default async function CompletedSigningPage({ params: { token }, }: CompletedSigningPageProps) { + const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP'); + if (!token) { return notFound(); } @@ -79,96 +85,120 @@ export default async function CompletedSigningPage({ const sessionData = await getServerSession(); const isLoggedIn = !!sessionData?.user; + const canSignUp = !isLoggedIn && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true'; return ( -
- {/* Card with recipient */} - +
+
+
+ + {truncatedTitle} + -
- {match({ status: document.status, deletedAt: document.deletedAt }) - .with({ status: DocumentStatus.COMPLETED }, () => ( -
- - Everyone has signed -
- )) - .with({ deletedAt: null }, () => ( -
- - Waiting for others to sign -
- )) - .otherwise(() => ( -
- - Document no longer available to sign -
- ))} + {/* Card with recipient */} + -

- You have - {recipient.role === RecipientRole.SIGNER && ' signed '} - {recipient.role === RecipientRole.VIEWER && ' viewed '} - {recipient.role === RecipientRole.APPROVER && ' approved '} - "{truncatedTitle}" -

+

+ Document + {recipient.role === RecipientRole.SIGNER && ' Signed '} + {recipient.role === RecipientRole.VIEWER && ' Viewed '} + {recipient.role === RecipientRole.APPROVER && ' Approved '} +

- {match({ status: document.status, deletedAt: document.deletedAt }) - .with({ status: DocumentStatus.COMPLETED }, () => ( -

- Everyone has signed! You will receive an Email copy of the signed document. -

- )) - .with({ deletedAt: null }, () => ( -

- You will receive an Email copy of the signed document once everyone has signed. -

- )) - .otherwise(() => ( -

- This document has been cancelled by the owner and is no longer available for others to - sign. -

- ))} + {match({ status: document.status, deletedAt: document.deletedAt }) + .with({ status: DocumentStatus.COMPLETED }, () => ( +
+ + Everyone has signed +
+ )) + .with({ deletedAt: null }, () => ( +
+ + Waiting for others to sign +
+ )) + .otherwise(() => ( +
+ + Document no longer available to sign +
+ ))} -
- + {match({ status: document.status, deletedAt: document.deletedAt }) + .with({ status: DocumentStatus.COMPLETED }, () => ( +

+ Everyone has signed! You will receive an Email copy of the signed document. +

+ )) + .with({ deletedAt: null }, () => ( +

+ You will receive an Email copy of the signed document once everyone has signed. +

+ )) + .otherwise(() => ( +

+ This document has been cancelled by the owner and is no longer available for others + to sign. +

+ ))} - {document.status === DocumentStatus.COMPLETED ? ( - - ) : ( - - )} +
+ + + {document.status === DocumentStatus.COMPLETED ? ( + + ) : ( + + )} +
- {isLoggedIn ? ( + {canSignUp && ( +
+

+ Need to sign documents? +

+ +

+ Create your account and start using state-of-the-art document signing. +

+ + +
+ )} + + {isLoggedIn && ( Go Back Home - ) : ( -

- Want to send slick signing links like this one?{' '} - - Check out Documenso. - -

)}
diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts index ee6b160cc..c2ae0618c 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -254,7 +254,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn await page.getByRole('button', { name: 'Sign' }).click(); await page.waitForURL(`/sign/${token}/complete`); - await expect(page.getByText('You have signed')).toBeVisible(); + await expect(page.getByText('Document Signed')).toBeVisible(); // Check if document has been signed const { status: completedStatus } = await getDocumentByToken(token); diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index f9a1795d7..645690905 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -56,7 +56,7 @@ export const authRouter = router({ return user; } catch (err) { - console.log(err); + console.error(err); const error = AppError.parseError(err); diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index b84c5e1c9..71734d734 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -23,7 +23,7 @@ export const ZSignUpMutationSchema = z.object({ name: z.string().min(1), email: z.string().email(), password: ZPasswordSchema, - signature: z.string().min(1, { message: 'A signature is required.' }), + signature: z.string().nullish(), url: z .string() .trim() From 7705dbae0cc448f66bb79c3cf829176c61da50e1 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 11 Apr 2024 15:04:36 +0700 Subject: [PATCH 096/174] feat: add document log page link (#1099) ## Description Adds a link from the document page view to the document page log view image --- .../[id]/document-page-view-dropdown.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx index 3e108aed5..7b6bb8a91 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx @@ -4,7 +4,16 @@ import { useState } from 'react'; import Link from 'next/link'; -import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react'; +import { + Copy, + Download, + Edit, + Loader, + MoreHorizontal, + ScrollTextIcon, + Share, + Trash2, +} from 'lucide-react'; import { useSession } from 'next-auth/react'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; @@ -106,6 +115,13 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro )} + + + + Logs + + + setDuplicateDialogOpen(true)}> Duplicate From 80c758fb6283b7bf3183740033a25436694c82fa Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Fri, 12 Apr 2024 15:37:08 +0200 Subject: [PATCH 097/174] chore: audit log menu item label (#1102) --- .../(dashboard)/documents/[id]/document-page-view-dropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx index 7b6bb8a91..0fb592ea1 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx @@ -118,7 +118,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro - Logs + Audit Log From 0f87dc047b475a65b9c76cf2735dad8c406fde28 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Mon, 15 Apr 2024 10:27:46 +0300 Subject: [PATCH 098/174] fix: swagger documentation authentication (#1037) ## Summary by CodeRabbit - **Refactor** - Enhanced the API specification generation process to include operation IDs, security schemes, and security definitions more efficiently. --------- Co-authored-by: Lucas Smith --- packages/api/v1/openapi.ts | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/api/v1/openapi.ts b/packages/api/v1/openapi.ts index af0582195..55ec4d7fd 100644 --- a/packages/api/v1/openapi.ts +++ b/packages/api/v1/openapi.ts @@ -2,16 +2,34 @@ import { generateOpenApi } from '@ts-rest/open-api'; import { ApiContractV1 } from './contract'; -export const OpenAPIV1 = generateOpenApi( - ApiContractV1, - { - info: { - title: 'Documenso API', - version: '1.0.0', - description: 'The Documenso API for retrieving, creating, updating and deleting documents.', +export const OpenAPIV1 = Object.assign( + generateOpenApi( + ApiContractV1, + { + info: { + title: 'Documenso API', + version: '1.0.0', + description: 'The Documenso API for retrieving, creating, updating and deleting documents.', + }, }, - }, + { + setOperationId: true, + }, + ), { - setOperationId: true, + components: { + securitySchemes: { + authorization: { + type: 'apiKey', + in: 'header', + name: 'Authorization', + }, + }, + }, + security: [ + { + authorization: [], + }, + ], }, ); From c8a09099a372568ed1c2b0ac47acac3ceb2be243 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Mon, 15 Apr 2024 10:29:56 +0300 Subject: [PATCH 099/174] fix: mask recipient token (#1051) The searchDocuments function is used for the shortcuts commands, afaik. The function returns the documents that match the user query (if any), alongside all their recipients. The reason for that is so it can build the path for the document. E.g. if you're the document owner, the document path will be `..../documents/{id}`. But if you're a signer for example, the document path (link) will be `..../sign/{token}`. So instead of doing that on the frontend, I moved it to the backend. At least that's what I understood. If I'm wrong, please correct me. ## Summary by CodeRabbit - **New Features** - Enhanced the `CommandMenu` component to simplify search result generation and improve document link management based on user roles. - **Refactor** - Updated document search logic to include recipient token masking and refined document mapping. - **Style** - Minor formatting improvement in document routing code. --- .../(dashboard)/common/command-menu.tsx | 20 +++-------------- .../document/search-documents-with-keyword.ts | 22 ++++++++++++------- .../trpc/server/document-router/router.ts | 1 + 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index bdc6c2064..812efd4b9 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -5,7 +5,6 @@ import { useCallback, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Loader, Monitor, Moon, Sun } from 'lucide-react'; -import { useSession } from 'next-auth/react'; import { useTheme } from 'next-themes'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -18,7 +17,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META, } from '@documenso/lib/constants/trpc'; -import type { Document, Recipient } from '@documenso/prisma/client'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { CommandDialog, @@ -71,7 +69,6 @@ export type CommandMenuProps = { export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const { setTheme } = useTheme(); - const { data: session } = useSession(); const router = useRouter(); @@ -93,17 +90,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { }, ); - const isOwner = useCallback( - (document: Document) => document.userId === session?.user.id, - [session?.user.id], - ); - - const getSigningLink = useCallback( - (recipients: Recipient[]) => - `/sign/${recipients.find((r) => r.email === session?.user.email)?.token}`, - [session?.user.email], - ); - const searchResults = useMemo(() => { if (!searchDocumentsData) { return []; @@ -111,10 +97,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { return searchDocumentsData.map((document) => ({ label: document.title, - path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient), - value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '), + path: document.path, + value: document.value, })); - }, [searchDocumentsData, isOwner, getSigningLink]); + }, [searchDocumentsData]); const currentPage = pages[pages.length - 1]; diff --git a/packages/lib/server-only/document/search-documents-with-keyword.ts b/packages/lib/server-only/document/search-documents-with-keyword.ts index 8125ae900..a9139f5d3 100644 --- a/packages/lib/server-only/document/search-documents-with-keyword.ts +++ b/packages/lib/server-only/document/search-documents-with-keyword.ts @@ -1,7 +1,6 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@documenso/prisma/client'; - -import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document'; +import type { Document, Recipient, User } from '@documenso/prisma/client'; export type SearchDocumentsWithKeywordOptions = { query: string; @@ -79,12 +78,19 @@ export const searchDocumentsWithKeyword = async ({ take: limit, }); - const maskedDocuments = documents.map((document) => - maskRecipientTokensForDocument({ - document, - user, - }), - ); + const isOwner = (document: Document, user: User) => document.userId === user.id; + const getSigningLink = (recipients: Recipient[], user: User) => + `/sign/${recipients.find((r) => r.email === user.email)?.token}`; + + const maskedDocuments = documents.map((document) => { + const { Recipient, ...documentWithoutRecipient } = document; + + return { + ...documentWithoutRecipient, + path: isOwner(document, user) ? `/documents/${document.id}` : getSigningLink(Recipient, user), + value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '), + }; + }); return maskedDocuments; }; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 3cc61bef2..d12002674 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -358,6 +358,7 @@ export const documentRouter = router({ query, userId: ctx.user.id, }); + return documents; } catch (err) { console.error(err); From aa4b6f1723c43ff6461950598a203222c2c127aa Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 15 Apr 2024 14:22:34 +0530 Subject: [PATCH 100/174] feat: updated mobile header (#1004) **Description:** - Updated mobile header with respect to latest designs ## Summary by CodeRabbit - **New Features** - Added a new `showText` property to the `MenuSwitcher` component to control text visibility. - Added a `textSectionClassName` property to the `AvatarWithText` component for conditional text section styling. - Updated the `CommandDialog` and `DialogContent` components with new positioning and styling properties. - **Style Updates** - Adjusted text size responsiveness in the `Hero` component for various screen sizes. - Modified text truncation and input styling in the `Widget` component. - Changed the width of the `SheetContent` element in `MobileNavigation` and adjusted footer layout. - **Documentation** - Added instructions for certificate placement in `SIGNING.md`. - **Refactor** - Standardized type imports across various components and utilities for improved type checking. --------- Signed-off-by: Adithya Krishna Signed-off-by: Adithya Krishna Co-authored-by: David Nguyen --- SIGNING.md | 3 +- apps/marketing/src/api/claim-plan/fetcher.ts | 3 +- .../src/app/(marketing)/open/bar-metrics.tsx | 1 + .../app/(marketing)/open/funding-raised.tsx | 1 + .../src/app/(marketing)/open/metric-card.tsx | 2 +- .../src/app/(marketing)/open/salary-bands.tsx | 2 +- .../app/(marketing)/oss-friends/container.tsx | 5 +- apps/marketing/src/app/robots.ts | 2 +- apps/marketing/src/app/sitemap.ts | 2 +- .../src/components/(marketing)/hero.tsx | 2 +- .../(marketing)/open-build-template-bento.tsx | 2 +- .../src/components/(marketing)/widget.tsx | 4 +- .../components/form/form-error-message.tsx | 2 +- .../src/components/ui/background.tsx | 2 +- apps/marketing/src/providers/next-theme.tsx | 2 +- .../(dashboard)/layout/menu-switcher.tsx | 5 +- .../(dashboard)/layout/mobile-navigation.tsx | 4 +- packages/ui/primitives/avatar.tsx | 5 +- packages/ui/primitives/command.tsx | 6 ++- packages/ui/primitives/dialog.tsx | 49 +++++++++++-------- 20 files changed, 62 insertions(+), 42 deletions(-) diff --git a/SIGNING.md b/SIGNING.md index d1942ed8a..d8f664cee 100644 --- a/SIGNING.md +++ b/SIGNING.md @@ -17,7 +17,8 @@ For the digital signature of your documents you need a signing certificate in .p `openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt` 4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**) -5. Place the certificate `/apps/web/resources/certificate.p12` + +5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created) ## Docker diff --git a/apps/marketing/src/api/claim-plan/fetcher.ts b/apps/marketing/src/api/claim-plan/fetcher.ts index 0e533be5e..629ab7270 100644 --- a/apps/marketing/src/api/claim-plan/fetcher.ts +++ b/apps/marketing/src/api/claim-plan/fetcher.ts @@ -1,4 +1,5 @@ -import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types'; +import type { TClaimPlanRequestSchema } from './types'; +import { ZClaimPlanResponseSchema } from './types'; export const claimPlan = async ({ name, diff --git a/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx b/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx index fb9c61f11..2d93b2e34 100644 --- a/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx +++ b/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx @@ -55,6 +55,7 @@ export const BarMetric = & { export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => { const formattedData = data.map((item) => ({ amount: Number(item.amount), + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions date: formatMonth(item.date as string), })); diff --git a/apps/marketing/src/app/(marketing)/open/metric-card.tsx b/apps/marketing/src/app/(marketing)/open/metric-card.tsx index 6235f4f5e..f7bf59e62 100644 --- a/apps/marketing/src/app/(marketing)/open/metric-card.tsx +++ b/apps/marketing/src/app/(marketing)/open/metric-card.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import { cn } from '@documenso/ui/lib/utils'; diff --git a/apps/marketing/src/app/(marketing)/open/salary-bands.tsx b/apps/marketing/src/app/(marketing)/open/salary-bands.tsx index 31c254157..41754cff6 100644 --- a/apps/marketing/src/app/(marketing)/open/salary-bands.tsx +++ b/apps/marketing/src/app/(marketing)/open/salary-bands.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import { cn } from '@documenso/ui/lib/utils'; import { diff --git a/apps/marketing/src/app/(marketing)/oss-friends/container.tsx b/apps/marketing/src/app/(marketing)/oss-friends/container.tsx index 0f1f66664..f2ea4e855 100644 --- a/apps/marketing/src/app/(marketing)/oss-friends/container.tsx +++ b/apps/marketing/src/app/(marketing)/oss-friends/container.tsx @@ -2,13 +2,14 @@ import Link from 'next/link'; -import { Variants, motion } from 'framer-motion'; +import type { Variants } from 'framer-motion'; +import { motion } from 'framer-motion'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card'; -import { TOSSFriendsSchema } from './schema'; +import type { TOSSFriendsSchema } from './schema'; const ContainerVariants: Variants = { initial: { diff --git a/apps/marketing/src/app/robots.ts b/apps/marketing/src/app/robots.ts index cc718ff25..a222a892e 100644 --- a/apps/marketing/src/app/robots.ts +++ b/apps/marketing/src/app/robots.ts @@ -1,4 +1,4 @@ -import { MetadataRoute } from 'next'; +import type { MetadataRoute } from 'next'; import { getBaseUrl } from '@documenso/lib/universal/get-base-url'; diff --git a/apps/marketing/src/app/sitemap.ts b/apps/marketing/src/app/sitemap.ts index b9becde3b..4913402f9 100644 --- a/apps/marketing/src/app/sitemap.ts +++ b/apps/marketing/src/app/sitemap.ts @@ -1,4 +1,4 @@ -import { MetadataRoute } from 'next'; +import type { MetadataRoute } from 'next'; import { allBlogPosts, allGenericPages } from 'contentlayer/generated'; diff --git a/apps/marketing/src/components/(marketing)/hero.tsx b/apps/marketing/src/components/(marketing)/hero.tsx index f416cc4ca..5809bd695 100644 --- a/apps/marketing/src/components/(marketing)/hero.tsx +++ b/apps/marketing/src/components/(marketing)/hero.tsx @@ -96,7 +96,7 @@ export const Hero = ({ className, ...props }: HeroProps) => { variants={HeroTitleVariants} initial="initial" animate="animate" - className="text-center text-4xl font-bold leading-tight tracking-tight lg:text-[64px]" + className="text-center text-4xl font-bold leading-tight tracking-tight md:text-[48px] lg:text-[64px]" > Document signing, finally open source. diff --git a/apps/marketing/src/components/(marketing)/open-build-template-bento.tsx b/apps/marketing/src/components/(marketing)/open-build-template-bento.tsx index 3c76c3547..4d4d6ad8a 100644 --- a/apps/marketing/src/components/(marketing)/open-build-template-bento.tsx +++ b/apps/marketing/src/components/(marketing)/open-build-template-bento.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import Image from 'next/image'; diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx index 8b6c3cd8e..c4611746a 100644 --- a/apps/marketing/src/components/(marketing)/widget.tsx +++ b/apps/marketing/src/components/(marketing)/widget.tsx @@ -346,7 +346,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { {signatureText && (

{signatureText} @@ -360,7 +360,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { > , 'viewBox'>; diff --git a/apps/marketing/src/providers/next-theme.tsx b/apps/marketing/src/providers/next-theme.tsx index 6e9122e5a..d15114606 100644 --- a/apps/marketing/src/providers/next-theme.tsx +++ b/apps/marketing/src/providers/next-theme.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ThemeProvider as NextThemesProvider } from 'next-themes'; -import { ThemeProviderProps } from 'next-themes/dist/types'; +import type { ThemeProviderProps } from 'next-themes/dist/types'; export function ThemeProvider({ children, ...props }: ThemeProviderProps) { return {children}; diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx index 95f959ab2..bb8429adc 100644 --- a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx +++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx @@ -93,7 +93,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp diff --git a/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx b/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx index a6009e7b5..ff5428298 100644 --- a/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx +++ b/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx @@ -46,7 +46,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat return ( - +

- © {new Date().getFullYear()} Documenso, Inc. All rights reserved. + © {new Date().getFullYear()} Documenso, Inc.
All rights reserved.

diff --git a/packages/ui/primitives/avatar.tsx b/packages/ui/primitives/avatar.tsx index c80e3a658..aa2f522fe 100644 --- a/packages/ui/primitives/avatar.tsx +++ b/packages/ui/primitives/avatar.tsx @@ -55,6 +55,8 @@ type AvatarWithTextProps = { primaryText: React.ReactNode; secondaryText?: React.ReactNode; rightSideComponent?: React.ReactNode; + // Optional class to hide/show the text beside avatar + textSectionClassName?: string; }; const AvatarWithText = ({ @@ -64,6 +66,7 @@ const AvatarWithText = ({ primaryText, secondaryText, rightSideComponent, + textSectionClassName, }: AvatarWithTextProps) => (
{avatarFallback} -
+
{primaryText} {secondaryText}
diff --git a/packages/ui/primitives/command.tsx b/packages/ui/primitives/command.tsx index 89777d417..89084ac12 100644 --- a/packages/ui/primitives/command.tsx +++ b/packages/ui/primitives/command.tsx @@ -32,7 +32,11 @@ type CommandDialogProps = DialogProps & { const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps) => { return ( - + & { position?: 'start' | 'end' | 'center'; hideClose?: boolean; + /* Below prop is to add additional classes to the overlay */ + overlayClassName?: string; } ->(({ className, children, position = 'start', hideClose = false, ...props }, ref) => ( - - - - {children} - {!hideClose && ( - - - Close - - )} - - -)); +>( + ( + { className, children, overlayClassName, position = 'start', hideClose = false, ...props }, + ref, + ) => ( + + + + {children} + {!hideClose && ( + + + Close + + )} + + + ), +); DialogContent.displayName = DialogPrimitive.Content.displayName; From 0eeccfd64356389e448091612f1bec4a2cf0dc4e Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 16 Apr 2024 11:18:06 +0300 Subject: [PATCH 101/174] feat: add myself as signer (#1091) ## Description This PR introduces the ability to add oneself as a signer by simply clicking a button, rather than filling the details manually. ### "Add Myself" in the document creation flow https://github.com/documenso/documenso/assets/25515812/0de762e3-563a-491f-a742-9078bf1d627d ### "Add Myself" in the document template creation flow https://github.com/documenso/documenso/assets/25515812/775bae01-3f5a-4b24-abbf-a47b14ec594a ## Related Issue Addresses [#113](https://github.com/documenso/backlog-internal/issues/113) ## Changes Made Added a new button that grabs the details of the logged-in user and fills the fields *(email, name, and role)* automatically when clicked. ## Testing Performed Tested the changes locally through the UI. ## Checklist - [x] I have tested these changes locally and they work as expected. - [ ] I have added/updated tests that prove the effectiveness of these changes. - [ ] I have updated the documentation to reflect these changes, if applicable. - [x] I have followed the project's coding style guidelines. - [ ] I have addressed the code review feedback from the previous submission, if applicable. ## Summary by CodeRabbit - **New Features** - Introduced the ability for users to add themselves as signers within documents seamlessly. - **Enhancements** - Improved form handling logic to accommodate new self-signer functionality. - Enhanced user interface elements to support the addition of self as a signer, including a new "Add myself" button and disabling input fields during the process. --- .../primitives/document-flow/add-signers.tsx | 77 ++++++++++++++----- .../add-template-placeholder-recipients.tsx | 33 +++++++- 2 files changed, 87 insertions(+), 23 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 7af4a06bc..27839a453 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -5,6 +5,7 @@ import React, { useId, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { motion } from 'framer-motion'; import { InfoIcon, Plus, Trash } from 'lucide-react'; +import { useSession } from 'next-auth/react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; @@ -60,6 +61,8 @@ export const AddSignersFormPartial = ({ }: AddSignersFormProps) => { const { toast } = useToast(); const { remaining } = useLimits(); + const { data: session } = useSession(); + const user = session?.user; const initialId = useId(); @@ -135,6 +138,16 @@ export const AddSignersFormPartial = ({ ); }; + const onAddSelfSigner = () => { + appendSigner({ + formId: nanoid(12), + name: user?.name ?? '', + email: user?.email ?? '', + role: RecipientRole.SIGNER, + actionAuth: undefined, + }); + }; + const onAddSigner = () => { appendSigner({ formId: nanoid(12), @@ -209,8 +222,12 @@ export const AddSignersFormPartial = ({ @@ -237,8 +254,12 @@ export const AddSignersFormPartial = ({ @@ -403,32 +424,46 @@ export const AddSignersFormPartial = ({ > - - {!alwaysShowAdvancedSettings && isDocumentEnterprise && ( -
- setShowAdvancedSettings(Boolean(value))} - /> - - -
- )} +
+ + {!alwaysShowAdvancedSettings && isDocumentEnterprise && ( +
+ setShowAdvancedSettings(Boolean(value))} + /> + + +
+ )} 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 87ec48ad1..08cfc4957 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -5,6 +5,7 @@ import React, { useId, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { AnimatePresence, motion } from 'framer-motion'; import { Plus, Trash } from 'lucide-react'; +import { useSession } from 'next-auth/react'; import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { nanoid } from '@documenso/lib/universal/id'; @@ -41,6 +42,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ onSubmit, }: AddTemplatePlaceholderRecipientsFormProps) => { const initialId = useId(); + const { data: session } = useSession(); + const user = session?.user; const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() => recipients.length > 1 ? recipients.length + 1 : 2, ); @@ -50,6 +53,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ const { control, handleSubmit, + getValues, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema), @@ -85,6 +89,15 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ name: 'signers', }); + const onAddPlaceholderSelfRecipient = () => { + appendSigner({ + formId: nanoid(12), + name: user?.name ?? '', + email: user?.email ?? '', + role: RecipientRole.SIGNER, + }); + }; + const onAddPlaceholderRecipient = () => { appendSigner({ formId: nanoid(12), @@ -203,11 +216,27 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ error={'signers__root' in errors && errors['signers__root']} /> -
- +
From db9899d29391f3cfaf7fdfd1b08b4d0dc86c1215 Mon Sep 17 00:00:00 2001 From: Deep Golani <54791570+deepgolani4@users.noreply.github.com> Date: Tue, 16 Apr 2024 18:12:28 +0530 Subject: [PATCH 102/174] fix: duplicate modal instances from hotkey activation (#1058) ## Description Currently, when the command menu is opened using the Command+K hotkey, two modals are getting rendered. This is because the modals are mounted in two components: header and desktop-nav. Upon triggering the hotkey, both modals are rendered. ## Related Issue #1032 ## Changes Made The changes I made are in the desktop nav component. If the desktop nav receives the command menu state value and the state setter function, it will trigger only that. If not, it will trigger the state setter that is defined in the desktop nav. This way, we are preventing the modal from mounting two times. ## Testing Performed - Tested behaviour of command menu in the portal - Tested on browsers chrome, arc, safari, chrome, firefox ## Checklist - [x] I have tested these changes locally and they work as expected. - [ ] I have added/updated tests that prove the effectiveness of these changes. - [ ] I have updated the documentation to reflect these changes, if applicable. - [x] I have followed the project's coding style guidelines. - [ ] I have addressed the code review feedback from the previous submission, if applicable. ## Summary by CodeRabbit - **New Features** - Enhanced the navigation experience by integrating command menu state management directly within the `DesktopNav` component, allowing for smoother interactions and control. - **Refactor** - Simplified the handling of the command menu by removing the `CommandMenu` component and managing its functionality within `DesktopNav`. --------- Co-authored-by: David Nguyen --- .../components/(dashboard)/layout/desktop-nav.tsx | 15 +++++---------- .../src/components/(dashboard)/layout/header.tsx | 2 +- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index 262e297d6..975ef7d0d 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -1,5 +1,3 @@ -'use client'; - import type { HTMLAttributes } from 'react'; import { useEffect, useState } from 'react'; @@ -12,8 +10,6 @@ import { getRootHref } from '@documenso/lib/utils/params'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { CommandMenu } from '../common/command-menu'; - const navigationLinks = [ { href: '/documents', @@ -25,13 +21,14 @@ const navigationLinks = [ }, ]; -export type DesktopNavProps = HTMLAttributes; +export type DesktopNavProps = HTMLAttributes & { + setIsCommandMenuOpen: (value: boolean) => void; +}; -export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { +export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: DesktopNavProps) => { const pathname = usePathname(); const params = useParams(); - const [open, setOpen] = useState(false); const [modifierKey, setModifierKey] = useState(() => 'Ctrl'); const rootHref = getRootHref(params, { returnEmptyRootString: true }); @@ -70,12 +67,10 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { ))}
- - -
- {uploadedFile ? ( - - - +
+
+
+
+
-
-
-
-
-
+

+ Uploaded Document +

-

- Uploaded Document -

- - - {uploadedFile.file.name} - - - - ) : ( - - )} -
+ + {uploadedFile.file.name} + + + + ) : ( + + )}
-
- + + + -
- - -
+ + + + ); From 3d3c53db023a5910792740bb52d575e79e841d9c Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 17 Apr 2024 15:36:54 +0300 Subject: [PATCH 106/174] feat: add extra info for recipient roles (#1105) ## Description Add additional information for each role to help document owners understand what each role involves. ## Changes Made ![CleanShot 2024-04-16 at 10 24 19](https://github.com/documenso/documenso/assets/25515812/bac6cd7d-fbe2-4987-ac17-de08db882eda) ![CleanShot 2024-04-16 at 10 24 27](https://github.com/documenso/documenso/assets/25515812/1bd23021-e971-451a-8e36-df5db57687f7) ![CleanShot 2024-04-16 at 10 24 35](https://github.com/documenso/documenso/assets/25515812/e658e86e-7fa1-4a40-9ed9-317964388e61) ## Testing Performed Tested the changes locally. ## Checklist - [x] I have tested these changes locally and they work as expected. - [ ] I have added/updated tests that prove the effectiveness of these changes. - [ ] I have updated the documentation to reflect these changes, if applicable. - [x] I have followed the project's coding style guidelines. - [ ] I have addressed the code review feedback from the previous submission, if applicable. ## Summary by CodeRabbit - **New Features** - Updated recipient role terminology and added tooltips in the `AddSignersFormPartial` component: - "Signer" changed to "Needs to sign" with tooltip - "Receives copy" changed to "Needs to approve" with tooltip - "Approver" changed to "Needs to view" with tooltip - "Viewer" changed to "Receives copy" with tooltip - Enhanced select dropdown options with icons and tooltips for different recipient roles in the `AddTemplatePlaceholderRecipients` component. --------- Co-authored-by: Timur Ercan --- .../primitives/document-flow/add-signers.tsx | 80 +++++++++++++++--- .../add-template-placeholder-recipients.tsx | 81 +++++++++++++++---- 2 files changed, 134 insertions(+), 27 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 27839a453..25169bcec 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -361,29 +361,83 @@ export const AddSignersFormPartial = ({
- {ROLE_ICONS[RecipientRole.SIGNER]} - Signer -
-
- - -
- {ROLE_ICONS[RecipientRole.CC]} - Receives copy +
+ {ROLE_ICONS[RecipientRole.SIGNER]} + Needs to sign +
+ + + + + +

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

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

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

+
+
- {ROLE_ICONS[RecipientRole.VIEWER]} - Viewer +
+ {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. +

+
+
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 08cfc4957..e415f1aac 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -4,7 +4,7 @@ import React, { useId, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { AnimatePresence, motion } from 'framer-motion'; -import { Plus, Trash } from 'lucide-react'; +import { InfoIcon, Plus, Trash } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { Controller, useFieldArray, useForm } from 'react-hook-form'; @@ -25,6 +25,7 @@ import type { DocumentFlowStep } from '../document-flow/types'; import { ROLE_ICONS } from '../recipient-role-icons'; import { Select, SelectContent, SelectItem, SelectTrigger } from '../select'; import { useStep } from '../stepper'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; @@ -159,29 +160,81 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
- {ROLE_ICONS[RecipientRole.SIGNER]} - Signer -
-
- - -
- {ROLE_ICONS[RecipientRole.CC]} - Receives copy +
+ {ROLE_ICONS[RecipientRole.SIGNER]} + Needs to sign +
+ + + + + +

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

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

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

+
+
- {ROLE_ICONS[RecipientRole.VIEWER]} - Viewer +
+ {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. +

+
+
From f8ddb0f9225e5c63a74377f71ab6e65d94ebbbe7 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Thu, 18 Apr 2024 18:12:08 +0530 Subject: [PATCH 107/174] chore: update filename for bulk recipients --- packages/lib/server-only/document/send-completed-email.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lib/server-only/document/send-completed-email.ts b/packages/lib/server-only/document/send-completed-email.ts index f5cef426c..f841aef33 100644 --- a/packages/lib/server-only/document/send-completed-email.ts +++ b/packages/lib/server-only/document/send-completed-email.ts @@ -130,7 +130,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo text: render(template, { plainText: true }), attachments: [ { - filename: document.title, + filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf', content: Buffer.from(completedDocument), }, ], From 6526377f1bd51d19b9e5677bd67c11cd612e5ddb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 18 Apr 2024 21:56:31 +0700 Subject: [PATCH 108/174] feat: add visible completed fields --- .../documents/[id]/document-page-view.tsx | 24 ++- .../src/app/(signing)/sign/[token]/page.tsx | 11 +- .../sign/[token]/signing-page-view.tsx | 12 +- .../avatar/stack-avatars-with-tooltip.tsx | 156 ++++++------------ .../document/document-read-only-fields.tsx | 113 +++++++++++++ .../get-completed-fields-for-document.ts | 29 ++++ .../field/get-completed-fields-for-token.ts | 33 ++++ .../template/create-document-from-template.ts | 6 +- .../template/duplicate-template.ts | 6 +- packages/lib/types/fields.ts | 3 + .../migration.sql | 8 + packages/prisma/schema.prisma | 4 +- packages/ui/components/field/field.tsx | 4 +- packages/ui/primitives/popover.tsx | 64 ++++++- 14 files changed, 356 insertions(+), 117 deletions(-) create mode 100644 apps/web/src/components/document/document-read-only-fields.tsx create mode 100644 packages/lib/server-only/field/get-completed-fields-for-document.ts create mode 100644 packages/lib/server-only/field/get-completed-fields-for-token.ts create mode 100644 packages/lib/types/fields.ts create mode 100644 packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index e20c88a27..3b89f63f5 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -8,6 +8,7 @@ import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; +import { getCompletedFieldsForDocument } from '@documenso/lib/server-only/field/get-completed-fields-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; @@ -19,6 +20,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { DocumentHistorySheet } from '~/components/document/document-history-sheet'; +import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields'; import { DocumentStatus as DocumentStatusComponent, FRIENDLY_STATUS_MAP, @@ -83,11 +85,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) documentMeta.password = securePassword; } - const recipients = await getRecipientsForDocument({ - documentId, - teamId: team?.id, - userId: user.id, - }); + const [recipients, completedFields] = await Promise.all([ + getRecipientsForDocument({ + documentId, + teamId: team?.id, + userId: user.id, + }), + getCompletedFieldsForDocument({ + documentId, + }), + ]); const documentWithRecipients = { ...document, @@ -148,6 +155,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) + {document.status === DocumentStatus.PENDING && ( + + )} +
diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index e83f675ce..95c9b6512 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -6,6 +6,7 @@ import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-c import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; +import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; @@ -37,7 +38,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); - const [document, fields, recipient] = await Promise.all([ + const [document, fields, recipient, completedFields] = await Promise.all([ getDocumentAndSenderByToken({ token, userId: user?.id, @@ -45,6 +46,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp }).catch(() => null), getFieldsForToken({ token }), getRecipientByToken({ token }).catch(() => null), + getCompletedFieldsForToken({ token }), ]); if (!document || !document.documentData || !recipient) { @@ -120,7 +122,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp signature={user?.email === recipient.email ? user.signature : undefined} > - + ); diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx index c04679956..4691d0d4c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx @@ -4,12 +4,14 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; +import type { CompletedField } from '@documenso/lib/types/fields'; import type { Field, Recipient } from '@documenso/prisma/client'; import { FieldType, RecipientRole } from '@documenso/prisma/client'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields'; import { truncateTitle } from '~/helpers/truncate-title'; import { DateField } from './date-field'; @@ -23,9 +25,15 @@ export type SigningPageViewProps = { document: DocumentAndSender; recipient: Recipient; fields: Field[]; + completedFields: CompletedField[]; }; -export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => { +export const SigningPageView = ({ + document, + recipient, + fields, + completedFields, +}: SigningPageViewProps) => { const truncatedTitle = truncateTitle(document.title); const { documentData, documentMeta } = document; @@ -70,6 +78,8 @@ export const SigningPageView = ({ document, recipient, fields }: SigningPageView
+ + {fields.map((field) => match(field.type) diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx index 10f7d1e6a..b6a13b911 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -1,12 +1,10 @@ 'use client'; -import { useRef, useState } from 'react'; - import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Recipient } from '@documenso/prisma/client'; -import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; +import { PopoverHover } from '@documenso/ui/primitives/popover'; import { AvatarWithRecipient } from './avatar-with-recipient'; import { StackAvatar } from './stack-avatar'; @@ -23,11 +21,6 @@ export const StackAvatarsWithTooltip = ({ position, children, }: StackAvatarsWithTooltipProps) => { - const [open, setOpen] = useState(false); - - const isControlled = useRef(false); - const isMouseOverTimeout = useRef(null); - const waitingRecipients = recipients.filter( (recipient) => getRecipientType(recipient) === 'waiting', ); @@ -44,105 +37,62 @@ export const StackAvatarsWithTooltip = ({ (recipient) => getRecipientType(recipient) === 'unsigned', ); - const onMouseEnter = () => { - if (isMouseOverTimeout.current) { - clearTimeout(isMouseOverTimeout.current); - } - - if (isControlled.current) { - return; - } - - isMouseOverTimeout.current = setTimeout(() => { - setOpen((o) => (!o ? true : o)); - }, 200); - }; - - const onMouseLeave = () => { - if (isMouseOverTimeout.current) { - clearTimeout(isMouseOverTimeout.current); - } - - if (isControlled.current) { - return; - } - - setTimeout(() => { - setOpen((o) => (o ? false : o)); - }, 200); - }; - - const onOpenChange = (newOpen: boolean) => { - isControlled.current = newOpen; - - setOpen(newOpen); - }; - return ( - - - {children || } - - - - {completedRecipients.length > 0 && ( -
-

Completed

- {completedRecipients.map((recipient: Recipient) => ( -
- -
-

{recipient.email}

-

- {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} -

-
+ } + contentProps={{ + className: 'flex flex-col gap-y-5 py-2', + side: position, + }} + > + {completedRecipients.length > 0 && ( +
+

Completed

+ {completedRecipients.map((recipient: Recipient) => ( +
+ +
+

{recipient.email}

+

+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

- ))} -
- )} +
+ ))} +
+ )} - {waitingRecipients.length > 0 && ( -
-

Waiting

- {waitingRecipients.map((recipient: Recipient) => ( - - ))} -
- )} + {waitingRecipients.length > 0 && ( +
+

Waiting

+ {waitingRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} - {openedRecipients.length > 0 && ( -
-

Opened

- {openedRecipients.map((recipient: Recipient) => ( - - ))} -
- )} + {openedRecipients.length > 0 && ( +
+

Opened

+ {openedRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} - {uncompletedRecipients.length > 0 && ( -
-

Uncompleted

- {uncompletedRecipients.map((recipient: Recipient) => ( - - ))} -
- )} - - + {uncompletedRecipients.length > 0 && ( +
+

Uncompleted

+ {uncompletedRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} + ); }; diff --git a/apps/web/src/components/document/document-read-only-fields.tsx b/apps/web/src/components/document/document-read-only-fields.tsx new file mode 100644 index 000000000..530066fa8 --- /dev/null +++ b/apps/web/src/components/document/document-read-only-fields.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useState } from 'react'; + +import { P, match } from 'ts-pattern'; + +import { + DEFAULT_DOCUMENT_DATE_FORMAT, + convertToLocalSystemFormat, +} from '@documenso/lib/constants/date-formats'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import type { CompletedField } from '@documenso/lib/types/fields'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import type { DocumentMeta } from '@documenso/prisma/client'; +import { FieldType } from '@documenso/prisma/client'; +import { FieldRootContainer } from '@documenso/ui/components/field/field'; +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types'; +import { ElementVisible } from '@documenso/ui/primitives/element-visible'; +import { PopoverHover } from '@documenso/ui/primitives/popover'; + +export type DocumentReadOnlyFieldsProps = { + fields: CompletedField[]; + documentMeta?: DocumentMeta; +}; + +export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnlyFieldsProps) => { + const [hiddenFieldIds, setHiddenFieldIds] = useState>({}); + + const handleHideField = (fieldId: string) => { + setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true })); + }; + + return ( + + {fields.map( + (field) => + !hiddenFieldIds[field.secondaryId] && ( + +
+ + + {extractInitials(field.Recipient.name || field.Recipient.email)} + + + } + contentProps={{ + className: 'flex w-fit flex-col py-2.5 text-sm', + }} + > +

+ + {field.Recipient.name + ? `${field.Recipient.name} (${field.Recipient.email})` + : field.Recipient.email}{' '} + + inserted a {FRIENDLY_FIELD_TYPE[field.type].toLowerCase()} +

+ + +
+
+ +
+ {match(field) + .with({ type: FieldType.SIGNATURE }, (field) => + field.Signature?.signatureImageAsBase64 ? ( + Signature + ) : ( +

+ {field.Signature?.typedSignature} +

+ ), + ) + .with({ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) }, () => ( +

{field.customText}

+ )) + .with({ type: FieldType.DATE }, () => ( +

+ {convertToLocalSystemFormat( + field.customText, + documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, + documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, + )} +

+ )) + .with({ type: FieldType.FREE_SIGNATURE }, () => null) + .exhaustive()} +
+
+ ), + )} +
+ ); +}; diff --git a/packages/lib/server-only/field/get-completed-fields-for-document.ts b/packages/lib/server-only/field/get-completed-fields-for-document.ts new file mode 100644 index 000000000..304be95ba --- /dev/null +++ b/packages/lib/server-only/field/get-completed-fields-for-document.ts @@ -0,0 +1,29 @@ +import { prisma } from '@documenso/prisma'; +import { SigningStatus } from '@documenso/prisma/client'; + +export type GetCompletedFieldsForDocumentOptions = { + documentId: number; +}; + +export const getCompletedFieldsForDocument = async ({ + documentId, +}: GetCompletedFieldsForDocumentOptions) => { + return await prisma.field.findMany({ + where: { + documentId, + Recipient: { + signingStatus: SigningStatus.SIGNED, + }, + inserted: true, + }, + include: { + Signature: true, + Recipient: { + select: { + name: true, + email: true, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/field/get-completed-fields-for-token.ts b/packages/lib/server-only/field/get-completed-fields-for-token.ts new file mode 100644 index 000000000..d84fa1343 --- /dev/null +++ b/packages/lib/server-only/field/get-completed-fields-for-token.ts @@ -0,0 +1,33 @@ +import { prisma } from '@documenso/prisma'; +import { SigningStatus } from '@documenso/prisma/client'; + +export type GetCompletedFieldsForTokenOptions = { + token: string; +}; + +export const getCompletedFieldsForToken = async ({ token }: GetCompletedFieldsForTokenOptions) => { + return await prisma.field.findMany({ + where: { + Document: { + Recipient: { + some: { + token, + }, + }, + }, + Recipient: { + signingStatus: SigningStatus.SIGNED, + }, + inserted: true, + }, + include: { + Signature: true, + Recipient: { + select: { + name: true, + email: true, + }, + }, + }, + }); +}; 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 8ae5fecaf..79a3f6f25 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -89,6 +89,10 @@ export const createDocumentFromTemplate = async ({ 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, @@ -99,7 +103,7 @@ export const createDocumentFromTemplate = async ({ customText: field.customText, inserted: field.inserted, documentId: document.id, - recipientId: documentRecipient?.id || null, + recipientId: documentRecipient.id, }; }), }); diff --git a/packages/lib/server-only/template/duplicate-template.ts b/packages/lib/server-only/template/duplicate-template.ts index 97b3f0a0b..963d78bde 100644 --- a/packages/lib/server-only/template/duplicate-template.ts +++ b/packages/lib/server-only/template/duplicate-template.ts @@ -81,6 +81,10 @@ export const duplicateTemplate = async ({ (doc) => doc.email === recipient?.email, ); + if (!duplicatedTemplateRecipient) { + throw new Error('Recipient not found.'); + } + return { type: field.type, page: field.page, @@ -91,7 +95,7 @@ export const duplicateTemplate = async ({ customText: field.customText, inserted: field.inserted, templateId: duplicatedTemplate.id, - recipientId: duplicatedTemplateRecipient?.id || null, + recipientId: duplicatedTemplateRecipient.id, }; }), }); diff --git a/packages/lib/types/fields.ts b/packages/lib/types/fields.ts new file mode 100644 index 000000000..1b999310d --- /dev/null +++ b/packages/lib/types/fields.ts @@ -0,0 +1,3 @@ +import type { getCompletedFieldsForToken } from '../server-only/field/get-completed-fields-for-token'; + +export type CompletedField = Awaited>[number]; diff --git a/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql b/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql new file mode 100644 index 000000000..62845de28 --- /dev/null +++ b/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Made the column `recipientId` on table `Field` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Field" ALTER COLUMN "recipientId" SET NOT NULL; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 35d429779..c0e68d53f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -386,7 +386,7 @@ model Field { secondaryId String @unique @default(cuid()) documentId Int? templateId Int? - recipientId Int? + recipientId Int type FieldType page Int positionX Decimal @default(0) @@ -397,7 +397,7 @@ model Field { inserted Boolean Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) - Recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade) + Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade) Signature Signature? @@index([documentId]) diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx index e40b2e3d9..ce62443de 100644 --- a/packages/ui/components/field/field.tsx +++ b/packages/ui/components/field/field.tsx @@ -19,6 +19,7 @@ export type FieldContainerPortalProps = { field: Field; className?: string; children: React.ReactNode; + cardClassName?: string; }; export function FieldContainerPortal({ @@ -44,7 +45,7 @@ export function FieldContainerPortal({ ); } -export function FieldRootContainer({ field, children }: FieldContainerPortalProps) { +export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) { const [isValidating, setIsValidating] = useState(false); const ref = React.useRef(null); @@ -78,6 +79,7 @@ export function FieldRootContainer({ field, children }: FieldContainerPortalProp { 'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating, }, + cardClassName, )} ref={ref} data-inserted={field.inserted ? 'true' : 'false'} diff --git a/packages/ui/primitives/popover.tsx b/packages/ui/primitives/popover.tsx index e84f6cc6d..62462322b 100644 --- a/packages/ui/primitives/popover.tsx +++ b/packages/ui/primitives/popover.tsx @@ -30,4 +30,66 @@ const PopoverContent = React.forwardRef< PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent }; +type PopoverHoverProps = { + trigger: React.ReactNode; + children: React.ReactNode; + contentProps?: React.ComponentPropsWithoutRef; +}; + +const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) => { + const [open, setOpen] = React.useState(false); + + const isControlled = React.useRef(false); + const isMouseOver = React.useRef(false); + + const onMouseEnter = () => { + isMouseOver.current = true; + + if (isControlled.current) { + return; + } + + setOpen(true); + }; + + const onMouseLeave = () => { + isMouseOver.current = false; + + if (isControlled.current) { + return; + } + + setTimeout(() => { + setOpen(isMouseOver.current); + }, 200); + }; + + const onOpenChange = (newOpen: boolean) => { + isControlled.current = newOpen; + + setOpen(newOpen); + }; + + return ( + + + {trigger} + + + + {children} + + + ); +}; + +export { Popover, PopoverTrigger, PopoverContent, PopoverHover }; From 6e09a4700b065d23c3367b5b00321fd7654fe040 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 19 Apr 2024 16:17:32 +0700 Subject: [PATCH 109/174] fix: prevent signing draft documents (#1111) ## Description Currently users can sign and complete draft documents, which will result in a completed document in an invalid state. ## Changes Made - Prevent recipients from inserting or uninserting fields for draft documents - Prevent recipients from completing draft documents - Remove ability to copy signing tokens unless document is pending ## Summary by CodeRabbit - **New Features** - Enhanced document status visibility and control across various components in the application. Users can now see and interact with document statuses more dynamically in views like `DocumentPageView`, `DocumentEditPageView`, and `DocumentsDataTable`. - Improved document signing process with updated status checks, ensuring actions like signing, completing, and removing fields are only available under appropriate document statuses. - **Bug Fixes** - Adjusted document status validation logic in server-side operations to prevent actions on incorrectly stated documents, enhancing the overall security and functionality of document processing. --- .../documents/[id]/document-page-view.tsx | 6 +++- .../[id]/edit/document-edit-page-view.tsx | 6 +++- .../documents/data-table-action-dropdown.tsx | 2 +- .../app/(dashboard)/documents/data-table.tsx | 7 +++- .../src/app/(signing)/sign/[token]/page.tsx | 7 +++- .../avatar/avatar-with-recipient.tsx | 35 ++++++++++--------- .../avatar/stack-avatars-with-tooltip.tsx | 22 +++++++++--- .../document/complete-document-with-token.ts | 4 +-- .../field/remove-signed-field-with-token.ts | 4 +-- .../field/sign-field-with-token.ts | 8 ++--- packages/prisma/seed/documents.ts | 17 ++++----- 11 files changed, 77 insertions(+), 41 deletions(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index e20c88a27..fc1022cfa 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -118,7 +118,11 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
- + {recipients.length} Recipient(s)
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx index 8a78ca9aa..5c2a64870 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx @@ -92,7 +92,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
- + {recipients.length} Recipient(s)
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index a43d37af7..c67890dfe 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -114,7 +114,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr Action - {recipient && recipient?.role !== RecipientRole.CC && ( + {!isDraft && recipient && recipient?.role !== RecipientRole.CC && ( {recipient?.role === RecipientRole.VIEWER && ( diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index ec595641e..c49cdf6ab 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -76,7 +76,12 @@ export const DocumentsDataTable = ({ { header: 'Recipient', accessorKey: 'recipient', - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, { header: 'Status', diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index e83f675ce..bd46898af 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -47,7 +47,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp getRecipientByToken({ token }).catch(() => null), ]); - if (!document || !document.documentData || !recipient) { + if ( + !document || + !document.documentData || + !recipient || + document.status === DocumentStatus.DRAFT + ) { return notFound(); } diff --git a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx index 69dd88d79..cd7cd2305 100644 --- a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx +++ b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx @@ -8,6 +8,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Recipient } from '@documenso/prisma/client'; +import { DocumentStatus } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -15,18 +16,21 @@ import { StackAvatar } from './stack-avatar'; export type AvatarWithRecipientProps = { recipient: Recipient; + documentStatus: DocumentStatus; }; -export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) { +export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) { const [, copy] = useCopyToClipboard(); const { toast } = useToast(); + const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null; + const onRecipientClick = () => { - if (!recipient.token) { + if (!signingToken) { return; } - void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => { + void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => { toast({ title: 'Copied to clipboard', description: 'The signing link has been copied to your clipboard.', @@ -37,10 +41,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) { return (
-
-
-

{recipient.email}

-

- {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} -

-
+ +
+

{recipient.email}

+

+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

); diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx index 10f7d1e6a..7a269d036 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -5,7 +5,7 @@ import { useRef, useState } from 'react'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import type { Recipient } from '@documenso/prisma/client'; +import type { DocumentStatus, Recipient } from '@documenso/prisma/client'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; import { AvatarWithRecipient } from './avatar-with-recipient'; @@ -13,12 +13,14 @@ import { StackAvatar } from './stack-avatar'; import { StackAvatars } from './stack-avatars'; export type StackAvatarsWithTooltipProps = { + documentStatus: DocumentStatus; recipients: Recipient[]; position?: 'top' | 'bottom'; children?: React.ReactNode; }; export const StackAvatarsWithTooltip = ({ + documentStatus, recipients, position, children, @@ -120,7 +122,11 @@ export const StackAvatarsWithTooltip = ({

Waiting

{waitingRecipients.map((recipient: Recipient) => ( - + ))}
)} @@ -129,7 +135,11 @@ export const StackAvatarsWithTooltip = ({

Opened

{openedRecipients.map((recipient: Recipient) => ( - + ))}
)} @@ -138,7 +148,11 @@ export const StackAvatarsWithTooltip = ({

Uncompleted

{uncompletedRecipients.map((recipient: Recipient) => ( - + ))}
)} diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index 8e3b56002..d16b83ea1 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -49,8 +49,8 @@ export const completeDocumentWithToken = async ({ const document = await getDocument({ token, documentId }); - if (document.status === DocumentStatus.COMPLETED) { - throw new Error(`Document ${document.id} has already been completed`); + if (document.status !== DocumentStatus.PENDING) { + throw new Error(`Document ${document.id} must be pending`); } if (document.Recipient.length === 0) { diff --git a/packages/lib/server-only/field/remove-signed-field-with-token.ts b/packages/lib/server-only/field/remove-signed-field-with-token.ts index 6548ae0f1..46d04dd58 100644 --- a/packages/lib/server-only/field/remove-signed-field-with-token.ts +++ b/packages/lib/server-only/field/remove-signed-field-with-token.ts @@ -36,8 +36,8 @@ export const removeSignedFieldWithToken = async ({ throw new Error(`Document not found for field ${field.id}`); } - if (document.status === DocumentStatus.COMPLETED) { - throw new Error(`Document ${document.id} has already been completed`); + if (document.status !== DocumentStatus.PENDING) { + throw new Error(`Document ${document.id} must be pending`); } if (recipient?.signingStatus === SigningStatus.SIGNED) { diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts index b8a5ccf8f..359a5da68 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -58,14 +58,14 @@ export const signFieldWithToken = async ({ throw new Error(`Recipient not found for field ${field.id}`); } - if (document.status === DocumentStatus.COMPLETED) { - throw new Error(`Document ${document.id} has already been completed`); - } - if (document.deletedAt) { throw new Error(`Document ${document.id} has been deleted`); } + if (document.status !== DocumentStatus.PENDING) { + throw new Error(`Document ${document.id} must be pending for signing`); + } + if (recipient?.signingStatus === SigningStatus.SIGNED) { throw new Error(`Recipient ${recipient.id} has already signed`); } diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts index 6c1e698c5..2e6462daa 100644 --- a/packages/prisma/seed/documents.ts +++ b/packages/prisma/seed/documents.ts @@ -342,14 +342,15 @@ export const seedPendingDocumentWithFullFields = async ({ }, }); - const latestDocument = updateDocumentOptions - ? await prisma.document.update({ - where: { - id: document.id, - }, - data: updateDocumentOptions, - }) - : document; + const latestDocument = await prisma.document.update({ + where: { + id: document.id, + }, + data: { + ...updateDocumentOptions, + status: DocumentStatus.PENDING, + }, + }); return { document: latestDocument, From bd40e633920b2304e48dc12585d3999d35e20fe9 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 19 Apr 2024 17:37:38 +0700 Subject: [PATCH 110/174] fix: update document deletion logic (#1100) --- .../app/(marketing)/singleplayer/client.tsx | 1 + .../[id]/document-page-view-dropdown.tsx | 30 +-- .../documents/[id]/document-page-view.tsx | 7 +- .../documents/data-table-action-dropdown.tsx | 41 ++-- .../app/(dashboard)/documents/data-table.tsx | 2 +- .../documents/delete-document-dialog.tsx | 104 ++++++--- .../documents/documents-page-view.tsx | 4 +- .../app/(dashboard)/documents/empty-state.tsx | 5 +- .../e2e/document-flow/signers-step.spec.ts | 4 +- .../document-flow/stepper-component.spec.ts | 2 +- .../e2e/documents/delete-documents.spec.ts | 172 +++++++++++++-- packages/app-tests/e2e/fixtures/documents.ts | 17 ++ .../e2e/teams/team-documents.spec.ts | 157 +++++++++++--- .../template-document-cancel.tsx | 4 + .../server-only/document/delete-document.ts | 197 ++++++++++++------ .../server-only/document/find-documents.ts | 55 ++++- .../lib/server-only/document/get-stats.ts | 11 +- .../migration.sql | 13 ++ packages/prisma/schema.prisma | 35 ++-- .../primitives/document-flow/add-signers.tsx | 4 +- 20 files changed, 651 insertions(+), 214 deletions(-) create mode 100644 packages/app-tests/e2e/fixtures/documents.ts create mode 100644 packages/prisma/migrations/20240408142543_add_recipient_document_delete/migration.sql diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index 3f1c11259..e20b94887 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -158,6 +158,7 @@ export const SinglePlayerClient = () => { expired: null, signedAt: null, readStatus: 'OPENED', + documentDeletedAt: null, signingStatus: 'NOT_SIGNED', sendStatus: 'NOT_SENT', role: 'SIGNER', diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx index 0fb592ea1..35dbaa8f1 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx @@ -19,7 +19,7 @@ import { useSession } from 'next-auth/react'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { DocumentStatus } from '@documenso/prisma/client'; -import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; +import type { Document, Recipient, Team, TeamEmail, User } from '@documenso/prisma/client'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { @@ -41,7 +41,7 @@ export type DocumentPageViewDropdownProps = { Recipient: Recipient[]; team: Pick | null; }; - team?: Pick; + team?: Pick & { teamEmail: TeamEmail | null }; }; export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => { @@ -59,9 +59,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro const isOwner = document.User.id === session.user.id; const isDraft = document.status === DocumentStatus.DRAFT; + const isDeleted = document.deletedAt !== null; const isComplete = document.status === DocumentStatus.COMPLETED; - const isDocumentDeletable = isOwner; const isCurrentTeamDocument = team && document.team?.url === team.url; + const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); const documentsPath = formatDocumentsPath(team?.url); @@ -127,7 +128,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro Duplicate - setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> + setDeleteDialogOpen(true)} + disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted} + > Delete @@ -154,15 +158,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro /> - {isDocumentDeletable && ( - - )} + + {isDuplicateDialogOpen && ( { @@ -127,6 +128,8 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
)} + + {document.deletedAt && Document deleted}
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index c67890dfe..aed95662b 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -15,7 +15,6 @@ import { Pencil, Share, Trash2, - XCircle, } from 'lucide-react'; import { useSession } from 'next-auth/react'; @@ -45,7 +44,7 @@ export type DataTableActionDropdownProps = { Recipient: Recipient[]; team: Pick | null; }; - team?: Pick; + team?: Pick & { teamEmail?: string }; }; export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => { @@ -67,8 +66,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr // const isPending = row.status === DocumentStatus.PENDING; const isComplete = row.status === DocumentStatus.COMPLETED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; - const isDocumentDeletable = isOwner; const isCurrentTeamDocument = team && row.team?.url === team.url; + const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); const documentsPath = formatDocumentsPath(team?.url); @@ -107,7 +106,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr return ( - + @@ -141,7 +140,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr )} - + Edit @@ -158,14 +157,18 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr Duplicate - + {/* No point displaying this if there's no functionality. */} + {/* Void - + */} - setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> + setDeleteDialogOpen(true)} + disabled={Boolean(!canManageDocument && team?.teamEmail)} + > - Delete + {canManageDocument ? 'Delete' : 'Hide'} Share @@ -186,16 +189,16 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr /> - {isDocumentDeletable && ( - - )} + + {isDuplicateDialogOpen && ( ; showSenderColumn?: boolean; - team?: Pick; + team?: Pick & { teamEmail?: string }; }; export const DocumentsDataTable = ({ diff --git a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx index 59fd21e60..558d39558 100644 --- a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx @@ -2,8 +2,11 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; +import { match } from 'ts-pattern'; + import { DocumentStatus } from '@documenso/prisma/client'; import { trpc as trpcReact } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -23,6 +26,7 @@ type DeleteDocumentDialogProps = { status: DocumentStatus; documentTitle: string; teamId?: number; + canManageDocument: boolean; }; export const DeleteDocumentDialog = ({ @@ -32,6 +36,7 @@ export const DeleteDocumentDialog = ({ status, documentTitle, teamId, + canManageDocument, }: DeleteDocumentDialogProps) => { const router = useRouter(); @@ -83,47 +88,82 @@ export const DeleteDocumentDialog = ({ !isLoading && onOpenChange(value)}> - Are you sure you want to delete "{documentTitle}"? + Are you sure? - Please note that this action is irreversible. Once confirmed, your document will be - permanently deleted. + You are about to {canManageDocument ? 'delete' : 'hide'}{' '} + "{documentTitle}" - {status !== DocumentStatus.DRAFT && ( -
- -
+ {canManageDocument ? ( + + {match(status) + .with(DocumentStatus.DRAFT, () => ( + + Please note that this action is irreversible. Once confirmed, + this document will be permanently deleted. + + )) + .with(DocumentStatus.PENDING, () => ( + +

+ Please note that this action is irreversible. +

+ +

Once confirmed, the following will occur:

+ +
    +
  • Document will be permanently deleted
  • +
  • Document signing process will be cancelled
  • +
  • All inserted signatures will be voided
  • +
  • All recipients will be notified
  • +
+
+ )) + .with(DocumentStatus.COMPLETED, () => ( + +

By deleting this document, the following will occur:

+ +
    +
  • The document will be hidden from your account
  • +
  • Recipients will still retain their copy of the document
  • +
+
+ )) + .exhaustive()} +
+ ) : ( + + + Please contact support if you would like to revert this action. + + + )} + + {status !== DocumentStatus.DRAFT && canManageDocument && ( + )} -
- + - -
+
diff --git a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx index 9059b8e88..84f6bfe3f 100644 --- a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx @@ -41,7 +41,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa const page = Number(searchParams.page) || 1; const perPage = Number(searchParams.perPage) || 20; const senderIds = parseToIntegerArray(searchParams.senderIds ?? ''); - const currentTeam = team ? { id: team.id, url: team.url } : undefined; + const currentTeam = team + ? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email } + : undefined; const getStatOptions: GetStatsInput = { user, diff --git a/apps/web/src/app/(dashboard)/documents/empty-state.tsx b/apps/web/src/app/(dashboard)/documents/empty-state.tsx index b6d2f74e2..e1af23bf2 100644 --- a/apps/web/src/app/(dashboard)/documents/empty-state.tsx +++ b/apps/web/src/app/(dashboard)/documents/empty-state.tsx @@ -37,7 +37,10 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => { })); return ( -
+
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 30d6ba11f..8676d05ed 100644 --- a/packages/app-tests/e2e/document-flow/signers-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/signers-step.spec.ts @@ -45,7 +45,7 @@ test.describe('[EE_ONLY]', () => { await page .getByRole('textbox', { name: 'Email', exact: true }) .fill('recipient2@documenso.com'); - await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2'); + await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2'); // Display advanced settings. await page.getByLabel('Show advanced settings').click(); @@ -82,7 +82,7 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => { await page.getByPlaceholder('Name').fill('Recipient 1'); await page.getByRole('button', { name: 'Add Signer' }).click(); await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com'); - await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2'); + 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(); diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts index c2ae0618c..07aee6a30 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -136,7 +136,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie await page.getByPlaceholder('Name').fill('User 1'); await page.getByRole('button', { name: 'Add Signer' }).click(); await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com'); - await page.getByRole('textbox', { name: 'Name', exact: true }).fill('User 2'); + await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2'); await page.getByRole('button', { name: 'Continue' }).click(); diff --git a/packages/app-tests/e2e/documents/delete-documents.spec.ts b/packages/app-tests/e2e/documents/delete-documents.spec.ts index 3658f1bc9..32f385df5 100644 --- a/packages/app-tests/e2e/documents/delete-documents.spec.ts +++ b/packages/app-tests/e2e/documents/delete-documents.spec.ts @@ -8,6 +8,7 @@ import { import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin, apiSignout } from '../fixtures/authentication'; +import { checkDocumentTabCount } from '../fixtures/documents'; test.describe.configure({ mode: 'serial' }); @@ -74,7 +75,7 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip email: sender.email, }); - // open actions menu + // Open document action menu. await page .locator('tr', { hasText: 'Document 1 - Completed' }) .getByRole('cell', { name: 'Download' }) @@ -115,7 +116,7 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients' email: sender.email, }); - // open actions menu + // Open document action menu. await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click(); // delete document @@ -135,20 +136,11 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients' }); await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible(); - - await page.goto(`/sign/${recipient.token}`); - await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible(); - - await page.goto('/documents'); - await page.waitForURL('/documents'); - await apiSignout({ page }); } }); -test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({ - page, -}) => { +test('[DOCUMENTS]: deleting draft documents should permanently remove it', async ({ page }) => { const { sender } = await seedDeleteDocumentsTestRequirements(); await apiSignin({ @@ -156,11 +148,10 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional email: sender.email, }); - // open actions menu + // Open document action menu. await page .locator('tr', { hasText: 'Document 1 - Draft' }) - .getByRole('cell', { name: 'Edit' }) - .getByRole('button') + .getByTestId('document-table-action-btn') .click(); // delete document @@ -169,4 +160,155 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional await page.getByRole('button', { name: 'Delete' }).click(); await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible(); + + // Check document counts. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 1); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 0); + await checkDocumentTabCount(page, 'All', 2); +}); + +test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => { + const { sender } = await seedDeleteDocumentsTestRequirements(); + + await apiSignin({ + page, + email: sender.email, + }); + + // Open document action menu. + await page + .locator('tr', { hasText: 'Document 1 - Pending' }) + .getByTestId('document-table-action-btn') + .click(); + + // Delete document. + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible(); + + // Check document counts. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 0); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'All', 2); +}); + +test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({ + page, +}) => { + const { sender, recipients } = await seedDeleteDocumentsTestRequirements(); + + await apiSignin({ + page, + email: sender.email, + }); + + // Open document action menu. + await page + .locator('tr', { hasText: 'Document 1 - Completed' }) + .getByTestId('document-table-action-btn') + .click(); + + // Delete document. + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); + await page.getByRole('button', { name: 'Delete' }).click(); + + // Check document counts. + await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible(); + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 1); + await checkDocumentTabCount(page, 'Completed', 0); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'All', 2); + + // Sign into the recipient account. + await apiSignout({ page }); + await apiSignin({ + page, + email: recipients[0].email, + }); + + // Check document counts. + await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible(); + await checkDocumentTabCount(page, 'Inbox', 1); + await checkDocumentTabCount(page, 'Pending', 0); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 0); + await checkDocumentTabCount(page, 'All', 2); +}); + +test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({ + page, +}) => { + const { sender, recipients } = await seedDeleteDocumentsTestRequirements(); + const recipientA = recipients[0]; + const recipientB = recipients[1]; + + await apiSignin({ + page, + email: recipientA.email, + }); + + // Open document action menu. + await page + .locator('tr', { hasText: 'Document 1 - Completed' }) + .getByTestId('document-table-action-btn') + .click(); + + // Delete document. + await page.getByRole('menuitem', { name: 'Hide' }).click(); + await page.getByRole('button', { name: 'Hide' }).click(); + + // Open document action menu. + await page + .locator('tr', { hasText: 'Document 1 - Pending' }) + .getByTestId('document-table-action-btn') + .click(); + + // Delete document. + await page.getByRole('menuitem', { name: 'Hide' }).click(); + await page.getByRole('button', { name: 'Hide' }).click(); + + // Check document counts. + await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible(); + await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible(); + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 0); + await checkDocumentTabCount(page, 'Completed', 0); + await checkDocumentTabCount(page, 'Draft', 0); + await checkDocumentTabCount(page, 'All', 0); + + // Sign into the sender account. + await apiSignout({ page }); + await apiSignin({ + page, + email: sender.email, + }); + + // Check document counts for sender. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 1); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'All', 3); + + // Sign into the other recipient account. + await apiSignout({ page }); + await apiSignin({ + page, + email: recipientB.email, + }); + + // Check document counts for other recipient. + await checkDocumentTabCount(page, 'Inbox', 1); + await checkDocumentTabCount(page, 'Pending', 0); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 0); + await checkDocumentTabCount(page, 'All', 2); }); diff --git a/packages/app-tests/e2e/fixtures/documents.ts b/packages/app-tests/e2e/fixtures/documents.ts new file mode 100644 index 000000000..f7e0bd391 --- /dev/null +++ b/packages/app-tests/e2e/fixtures/documents.ts @@ -0,0 +1,17 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => { + await page.getByRole('tab', { name: tabName }).click(); + + if (tabName !== 'All') { + await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString()); + } + + if (count === 0) { + await expect(page.getByTestId('empty-document-state')).toBeVisible(); + return; + } + + await expect(page.getByRole('main')).toContainText(`Showing ${count}`); +}; diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts index 8f70befc8..6cea6445d 100644 --- a/packages/app-tests/e2e/teams/team-documents.spec.ts +++ b/packages/app-tests/e2e/teams/team-documents.spec.ts @@ -1,4 +1,3 @@ -import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { DocumentStatus } from '@documenso/prisma/client'; @@ -7,24 +6,10 @@ import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/se import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin, apiSignout } from '../fixtures/authentication'; +import { checkDocumentTabCount } from '../fixtures/documents'; test.describe.configure({ mode: 'parallel' }); -const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => { - await page.getByRole('tab', { name: tabName }).click(); - - if (tabName !== 'All') { - await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString()); - } - - if (count === 0) { - await expect(page.getByRole('main')).toContainText(`Nothing to do`); - return; - } - - await expect(page.getByRole('main')).toContainText(`Showing ${count}`); -}; - test('[TEAMS]: check team documents count', async ({ page }) => { const { team, teamMember2 } = await seedTeamDocuments(); @@ -245,24 +230,6 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa await unseedTeam(team.url); }); -test('[TEAMS]: delete pending team document', async ({ page }) => { - const { team, teamMember2: currentUser } = await seedTeamDocuments(); - - await apiSignin({ - page, - email: currentUser.email, - redirectPath: `/t/${team.url}/documents?status=PENDING`, - }); - - await page.getByRole('row').getByRole('button').nth(1).click(); - - await page.getByRole('menuitem', { name: 'Delete' }).click(); - await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); - await page.getByRole('button', { name: 'Delete' }).click(); - - await checkDocumentTabCount(page, 'Pending', 1); -}); - test('[TEAMS]: resend pending team document', async ({ page }) => { const { team, teamMember2: currentUser } = await seedTeamDocuments(); @@ -280,3 +247,125 @@ test('[TEAMS]: resend pending team document', async ({ page }) => { await expect(page.getByRole('status')).toContainText('Document re-sent'); }); + +test('[TEAMS]: delete draft team document', async ({ page }) => { + const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments(); + + await apiSignin({ + page, + email: teamMember3.email, + redirectPath: `/t/${team.url}/documents?status=DRAFT`, + }); + + await page.getByRole('row').getByRole('button').nth(1).click(); + + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete' }).click(); + + await checkDocumentTabCount(page, 'Draft', 1); + + // Should be hidden for all team members. + await apiSignout({ page }); + + // Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same. + for (const user of [team.owner, teamEmailMember]) { + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Check document counts. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 2); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'All', 4); + + await apiSignout({ page }); + } + + await unseedTeam(team.url); +}); + +test('[TEAMS]: delete pending team document', async ({ page }) => { + const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments(); + + await apiSignin({ + page, + email: teamMember3.email, + redirectPath: `/t/${team.url}/documents?status=PENDING`, + }); + + await page.getByRole('row').getByRole('button').nth(1).click(); + + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); + await page.getByRole('button', { name: 'Delete' }).click(); + + await checkDocumentTabCount(page, 'Pending', 1); + + // Should be hidden for all team members. + await apiSignout({ page }); + + // Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same. + for (const user of [team.owner, teamEmailMember]) { + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Check document counts. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 1); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 2); + await checkDocumentTabCount(page, 'All', 4); + + await apiSignout({ page }); + } + + await unseedTeam(team.url); +}); + +test('[TEAMS]: delete completed team document', async ({ page }) => { + const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments(); + + await apiSignin({ + page, + email: teamMember3.email, + redirectPath: `/t/${team.url}/documents?status=COMPLETED`, + }); + + await page.getByRole('row').getByRole('button').nth(2).click(); + + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); + await page.getByRole('button', { name: 'Delete' }).click(); + + await checkDocumentTabCount(page, 'Completed', 0); + + // Should be hidden for all team members. + await apiSignout({ page }); + + // Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same. + for (const user of [team.owner, teamEmailMember]) { + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Check document counts. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 2); + await checkDocumentTabCount(page, 'Completed', 0); + await checkDocumentTabCount(page, 'Draft', 2); + await checkDocumentTabCount(page, 'All', 4); + + await apiSignout({ page }); + } + + await unseedTeam(team.url); +}); diff --git a/packages/email/template-components/template-document-cancel.tsx b/packages/email/template-components/template-document-cancel.tsx index 885cb6c80..dff275de2 100644 --- a/packages/email/template-components/template-document-cancel.tsx +++ b/packages/email/template-components/template-document-cancel.tsx @@ -23,6 +23,10 @@ export const TemplateDocumentCancel = ({
"{documentName}" + + All signatures have been voided. + + You don't need to sign it anymore. diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index b0b1ad682..a097d76e9 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -6,6 +6,7 @@ import { mailer } from '@documenso/email/mailer'; import { render } from '@documenso/email/render'; import DocumentCancelTemplate from '@documenso/email/templates/document-cancel'; import { prisma } from '@documenso/prisma'; +import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; @@ -27,110 +28,178 @@ export const deleteDocument = async ({ teamId, requestMetadata, }: DeleteDocumentOptions) => { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (!user) { + throw new Error('User not found'); + } + const document = await prisma.document.findUnique({ where: { id, - ...(teamId - ? { - team: { - id: teamId, - members: { - some: { - userId, - }, - }, - }, - } - : { - userId, - teamId: null, - }), }, include: { Recipient: true, documentMeta: true, - User: true, + team: { + select: { + members: true, + }, + }, }, }); - if (!document) { + if (!document || (teamId !== undefined && teamId !== document.teamId)) { throw new Error('Document not found'); } - const { status, User: user } = document; + const isUserOwner = document.userId === userId; + const isUserTeamMember = document.team?.members.some((member) => member.userId === userId); + const userRecipient = document.Recipient.find((recipient) => recipient.email === user.email); - // if the document is a draft, hard-delete - if (status === DocumentStatus.DRAFT) { + if (!isUserOwner && !isUserTeamMember && !userRecipient) { + throw new Error('Not allowed'); + } + + // Handle hard or soft deleting the actual document if user has permission. + if (isUserOwner || isUserTeamMember) { + await handleDocumentOwnerDelete({ + document, + user, + requestMetadata, + }); + } + + // Continue to hide the document from the user if they are a recipient. + if (userRecipient?.documentDeletedAt === null) { + await prisma.recipient.update({ + where: { + documentId_email: { + documentId: document.id, + email: user.email, + }, + }, + data: { + documentDeletedAt: new Date().toISOString(), + }, + }); + } + + // Return partial document for API v1 response. + return { + id: document.id, + userId: document.userId, + teamId: document.teamId, + title: document.title, + status: document.status, + documentDataId: document.documentDataId, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + completedAt: document.completedAt, + }; +}; + +type HandleDocumentOwnerDeleteOptions = { + document: Document & { + Recipient: Recipient[]; + documentMeta: DocumentMeta | null; + }; + user: User; + requestMetadata?: RequestMetadata; +}; + +const handleDocumentOwnerDelete = async ({ + document, + user, + requestMetadata, +}: HandleDocumentOwnerDeleteOptions) => { + if (document.deletedAt) { + return; + } + + // Soft delete completed documents. + if (document.status === DocumentStatus.COMPLETED) { return await prisma.$transaction(async (tx) => { - // Currently redundant since deleting a document will delete the audit logs. - // However may be useful if we disassociate audit lgos and documents if required. await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ - documentId: id, + documentId: document.id, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, user, requestMetadata, data: { - type: 'HARD', + type: 'SOFT', }, }), }); - return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } }); + return await tx.document.update({ + where: { + id: document.id, + }, + data: { + deletedAt: new Date().toISOString(), + }, + }); }); } - // if the document is pending, send cancellation emails to all recipients - if (status === DocumentStatus.PENDING && document.Recipient.length > 0) { - await Promise.all( - document.Recipient.map(async (recipient) => { - const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; - - const template = createElement(DocumentCancelTemplate, { - documentName: document.title, - inviterName: user.name || undefined, - inviterEmail: user.email, - assetBaseUrl, - }); - - await mailer.sendMail({ - to: { - address: recipient.email, - name: recipient.name, - }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, - subject: 'Document Cancelled', - html: render(template), - text: render(template, { plainText: true }), - }); - }), - ); - } - - // If the document is not a draft, only soft-delete. - return await prisma.$transaction(async (tx) => { + // Hard delete draft and pending documents. + const deletedDocument = await prisma.$transaction(async (tx) => { + // Currently redundant since deleting a document will delete the audit logs. + // However may be useful if we disassociate audit logs and documents if required. await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ - documentId: id, + documentId: document.id, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, user, requestMetadata, data: { - type: 'SOFT', + type: 'HARD', }, }), }); - return await tx.document.update({ + return await tx.document.delete({ where: { - id, - }, - data: { - deletedAt: new Date().toISOString(), + id: document.id, + status: { + not: DocumentStatus.COMPLETED, + }, }, }); }); + + // Send cancellation emails to recipients. + await Promise.all( + document.Recipient.map(async (recipient) => { + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + + const template = createElement(DocumentCancelTemplate, { + documentName: document.title, + inviterName: user.name || undefined, + inviterEmail: user.email, + assetBaseUrl, + }); + + await mailer.sendMail({ + to: { + address: recipient.email, + name: recipient.name, + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: 'Document Cancelled', + html: render(template), + text: render(template, { plainText: true }), + }); + }), + ); + + return deletedDocument; }; diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index f34cc4c2c..c8b06236b 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -94,24 +94,65 @@ export const findDocuments = async ({ }; } - const whereClause: Prisma.DocumentWhereInput = { - ...termFilters, - ...filters, + let deletedFilter: Prisma.DocumentWhereInput = { AND: { OR: [ { - status: ExtendedDocumentStatus.COMPLETED, + userId: user.id, + deletedAt: null, }, { - status: { - not: ExtendedDocumentStatus.COMPLETED, + Recipient: { + some: { + email: user.email, + documentDeletedAt: null, + }, }, - deletedAt: null, }, ], }, }; + if (team) { + deletedFilter = { + AND: { + OR: team.teamEmail + ? [ + { + teamId: team.id, + deletedAt: null, + }, + { + User: { + email: team.teamEmail.email, + }, + deletedAt: null, + }, + { + Recipient: { + some: { + email: team.teamEmail.email, + documentDeletedAt: null, + }, + }, + }, + ] + : [ + { + teamId: team.id, + deletedAt: null, + }, + ], + }, + }; + } + + const whereClause: Prisma.DocumentWhereInput = { + ...termFilters, + ...filters, + ...deletedFilter, + }; + if (period) { const daysAgo = parseInt(period.replace(/d$/, ''), 10); diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index db38fa79d..1afdbcbf2 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -72,6 +72,7 @@ type GetCountsOption = { const getCounts = async ({ user, createdAt }: GetCountsOption) => { return Promise.all([ + // Owner counts. prisma.document.groupBy({ by: ['status'], _count: { @@ -84,6 +85,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { deletedAt: null, }, }), + // Not signed counts. prisma.document.groupBy({ by: ['status'], _count: { @@ -95,12 +97,13 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { some: { email: user.email, signingStatus: SigningStatus.NOT_SIGNED, + documentDeletedAt: null, }, }, createdAt, - deletedAt: null, }, }), + // Has signed counts. prisma.document.groupBy({ by: ['status'], _count: { @@ -120,9 +123,9 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { some: { email: user.email, signingStatus: SigningStatus.SIGNED, + documentDeletedAt: null, }, }, - deletedAt: null, }, { status: ExtendedDocumentStatus.COMPLETED, @@ -130,6 +133,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { some: { email: user.email, signingStatus: SigningStatus.SIGNED, + documentDeletedAt: null, }, }, }, @@ -198,6 +202,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { some: { email: teamEmail, signingStatus: SigningStatus.NOT_SIGNED, + documentDeletedAt: null, }, }, deletedAt: null, @@ -219,6 +224,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { some: { email: teamEmail, signingStatus: SigningStatus.SIGNED, + documentDeletedAt: null, }, }, deletedAt: null, @@ -229,6 +235,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { some: { email: teamEmail, signingStatus: SigningStatus.SIGNED, + documentDeletedAt: null, }, }, deletedAt: null, diff --git a/packages/prisma/migrations/20240408142543_add_recipient_document_delete/migration.sql b/packages/prisma/migrations/20240408142543_add_recipient_document_delete/migration.sql new file mode 100644 index 000000000..6bbb11cd9 --- /dev/null +++ b/packages/prisma/migrations/20240408142543_add_recipient_document_delete/migration.sql @@ -0,0 +1,13 @@ +-- AlterTable +ALTER TABLE "Recipient" ADD COLUMN "documentDeletedAt" TIMESTAMP(3); + +-- Hard delete all PENDING documents that have been soft deleted +DELETE FROM "Document" WHERE "deletedAt" IS NOT NULL AND "status" = 'PENDING'; + +-- Update all recipients who are the owner of the document and where the document has deletedAt set to not null +UPDATE "Recipient" +SET "documentDeletedAt" = "Document"."deletedAt" +FROM "Document", "User" +WHERE "Recipient"."documentId" = "Document"."id" +AND "Recipient"."email" = "User"."email" +AND "Document"."deletedAt" IS NOT NULL; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 35d429779..8971f837f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -347,23 +347,24 @@ enum RecipientRole { } model Recipient { - id Int @id @default(autoincrement()) - documentId Int? - templateId Int? - email String @db.VarChar(255) - name String @default("") @db.VarChar(255) - token String - expired DateTime? - signedAt DateTime? - authOptions Json? - role RecipientRole @default(SIGNER) - readStatus ReadStatus @default(NOT_OPENED) - signingStatus SigningStatus @default(NOT_SIGNED) - sendStatus SendStatus @default(NOT_SENT) - Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) - Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) - Field Field[] - Signature Signature[] + id Int @id @default(autoincrement()) + documentId Int? + templateId Int? + email String @db.VarChar(255) + name String @default("") @db.VarChar(255) + token String + documentDeletedAt DateTime? + expired DateTime? + signedAt DateTime? + authOptions Json? + role RecipientRole @default(SIGNER) + readStatus ReadStatus @default(NOT_OPENED) + signingStatus SigningStatus @default(NOT_SIGNED) + sendStatus SendStatus @default(NOT_SENT) + Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) + Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) + Field Field[] + Signature Signature[] @@unique([documentId, email]) @@unique([templateId, email]) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 25169bcec..b796f4328 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -247,9 +247,7 @@ export const AddSignersFormPartial = ({ 'col-span-4': showAdvancedSettings, })} > - {!showAdvancedSettings && index === 0 && ( - Name - )} + {!showAdvancedSettings && index === 0 && Name} Date: Fri, 19 Apr 2024 13:45:33 +0300 Subject: [PATCH 111/174] feat: update emails for self-signer (#1108) ## Description Updated the email content based on whether the document owner is a recipient or not. If the document owner is a recipient (self-signer): * the email subject will be `Please view/sign/approve your document` * the email header will be `Please view/sign/approve your document ""` * the email content will be `You have initiated the document "" that requires you to view/sign/approve it.` Otherwise: * the email subject will be `Please view/sign/approve this document` * the email header will be ` has invited you to view/sign/approve ""` * the email content will be ` has invited you to view/sign/approve the document "".` ## Related Issue Related to #1091 ## Testing Performed Tested the feature with a different number of recipients (including and excluding the document owner - self-signer). Tested both the sending and resending functionality. ## Checklist - [x] I have tested these changes locally and they work as expected. - [ ] I have added/updated tests that prove the effectiveness of these changes. - [ ] I have updated the documentation to reflect these changes, if applicable. - [x] I have followed the project's coding style guidelines. - [ ] I have addressed the code review feedback from the previous submission, if applicable. ## UI Screenshots ![CleanShot 2024-04-18 at 12 26 11@2x](https://github.com/documenso/documenso/assets/25515812/ca80f625-befb-4cbc-a541-f2186379d2e8) ![CleanShot 2024-04-18 at 12 27 40@2x](https://github.com/documenso/documenso/assets/25515812/8bcbb6fc-ba98-4fa1-8538-2d062febd27b) ![CleanShot 2024-04-18 at 12 27 53@2x](https://github.com/documenso/documenso/assets/25515812/25d77d98-b5ec-4270-8ffa-43774fe70526) ![CleanShot 2024-04-18 at 12 30 00@2x](https://github.com/documenso/documenso/assets/25515812/a90bb8e3-3ea8-42ff-9971-559b3e81ae6f) ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Features** - Enhanced the document invitation components to support scenarios where the recipient is also the sender, providing customized email content and subject lines. - Introduced new properties in email templates to improve clarity and relevance based on the user's role in the document signing process. - **Refactor** - Updated components to use a more flexible `headerContent` property for displaying invitation headers, replacing previous individual inviter details. --- .../template-document-invite.tsx | 17 +++++++++++++++-- packages/email/templates/document-invite.tsx | 7 ++++++- .../server-only/document/resend-document.tsx | 17 +++++++++++++++-- .../lib/server-only/document/send-document.tsx | 17 +++++++++++++++-- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx index b958e9029..b99b1a1b4 100644 --- a/packages/email/template-components/template-document-invite.tsx +++ b/packages/email/template-components/template-document-invite.tsx @@ -11,6 +11,7 @@ export interface TemplateDocumentInviteProps { signDocumentLink: string; assetBaseUrl: string; role: RecipientRole; + selfSigner: boolean; } export const TemplateDocumentInvite = ({ @@ -19,6 +20,7 @@ export const TemplateDocumentInvite = ({ signDocumentLink, assetBaseUrl, role, + selfSigner, }: TemplateDocumentInviteProps) => { const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role]; @@ -28,8 +30,19 @@ export const TemplateDocumentInvite = ({
- {inviterName} has invited you to {actionVerb.toLowerCase()} -
"{documentName}" + {selfSigner ? ( + <> + {`Please ${actionVerb.toLowerCase()} your document`} +
+ {`"${documentName}"`} + + ) : ( + <> + {`${inviterName} has invited you to ${actionVerb.toLowerCase()}`} +
+ {`"${documentName}"`} + + )}
diff --git a/packages/email/templates/document-invite.tsx b/packages/email/templates/document-invite.tsx index d3bceb872..52a40d804 100644 --- a/packages/email/templates/document-invite.tsx +++ b/packages/email/templates/document-invite.tsx @@ -22,6 +22,7 @@ import { TemplateFooter } from '../template-components/template-footer'; export type DocumentInviteEmailTemplateProps = Partial & { customBody?: string; role: RecipientRole; + selfSigner?: boolean; }; export const DocumentInviteEmailTemplate = ({ @@ -32,10 +33,13 @@ export const DocumentInviteEmailTemplate = ({ assetBaseUrl = 'http://localhost:3002', customBody, role, + selfSigner = false, }: DocumentInviteEmailTemplateProps) => { const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase(); - const previewText = `${inviterName} has invited you to ${action} ${documentName}`; + const previewText = selfSigner + ? `Please ${action} your document ${documentName}` + : `${inviterName} has invited you to ${action} ${documentName}`; const getAssetUrl = (path: string) => { return new URL(path, assetBaseUrl).toString(); @@ -71,6 +75,7 @@ export const DocumentInviteEmailTemplate = ({ signDocumentLink={signDocumentLink} assetBaseUrl={assetBaseUrl} role={role} + selfSigner={selfSigner} />
diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx index ebf140007..500c5395a 100644 --- a/packages/lib/server-only/document/resend-document.tsx +++ b/packages/lib/server-only/document/resend-document.tsx @@ -88,6 +88,11 @@ export const resendDocument = async ({ const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; const { email, name } = recipient; + const selfSigner = email === user.email; + + const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[ + recipient.role + ].actionVerb.toLowerCase()} it.`; const customEmailTemplate = { 'signer.name': name, @@ -104,12 +109,20 @@ export const resendDocument = async ({ inviterEmail: user.email, assetBaseUrl, signDocumentLink, - customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate), + customBody: renderCustomEmailTemplate( + selfSigner ? selfSignerCustomEmail : customEmail?.message || '', + customEmailTemplate, + ), role: recipient.role, + selfSigner, }); const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; + const emailSubject = selfSigner + ? `Reminder: Please ${actionVerb.toLowerCase()} your document` + : `Reminder: Please ${actionVerb.toLowerCase()} this document`; + await prisma.$transaction( async (tx) => { await mailer.sendMail({ @@ -123,7 +136,7 @@ export const resendDocument = async ({ }, subject: customEmail?.subject ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) - : `Please ${actionVerb.toLowerCase()} this document`, + : emailSubject, html: render(template), text: render(template, { plainText: true }), }); diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index acbcc499f..5bb7e2352 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -127,6 +127,11 @@ export const sendDocument = async ({ const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; const { email, name } = recipient; + const selfSigner = email === user.email; + + const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[ + recipient.role + ].actionVerb.toLowerCase()} it.`; const customEmailTemplate = { 'signer.name': name, @@ -143,12 +148,20 @@ export const sendDocument = async ({ inviterEmail: user.email, assetBaseUrl, signDocumentLink, - customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate), + customBody: renderCustomEmailTemplate( + selfSigner ? selfSignerCustomEmail : customEmail?.message || '', + customEmailTemplate, + ), role: recipient.role, + selfSigner, }); const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; + const emailSubject = selfSigner + ? `Please ${actionVerb.toLowerCase()} your document` + : `Please ${actionVerb.toLowerCase()} this document`; + await prisma.$transaction( async (tx) => { await mailer.sendMail({ @@ -162,7 +175,7 @@ export const sendDocument = async ({ }, subject: customEmail?.subject ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) - : `Please ${actionVerb.toLowerCase()} this document`, + : emailSubject, html: render(template), text: render(template, { plainText: true }), }); From f6e6dac46c681f6666ec90cd5245482f4030c0a5 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 19 Apr 2024 17:58:32 +0700 Subject: [PATCH 112/174] fix: update migration to drop invalid fields --- .../migration.sql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql b/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql index 62845de28..ee027d90e 100644 --- a/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql +++ b/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql @@ -4,5 +4,8 @@ - Made the column `recipientId` on table `Field` required. This step will fail if there are existing NULL values in that column. */ +-- Drop all Fields where the recipientId is null +DELETE FROM "Field" WHERE "recipientId" IS NULL; + -- AlterTable ALTER TABLE "Field" ALTER COLUMN "recipientId" SET NOT NULL; From 4b90adde6baf5714f9bb27f655af0981c4309d7a Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 19 Apr 2024 14:04:11 +0300 Subject: [PATCH 113/174] feat: download completed docs via api (#1078) ## Description Allow users to download a completed document via API. ## Testing Performed Tested the code locally by trying to download both draft and completed docs. Works as expected. ## Checklist - [x] I have tested these changes locally and they work as expected. - [ ] I have added/updated tests that prove the effectiveness of these changes. - [ ] I have updated the documentation to reflect these changes, if applicable. - [x] I have followed the project's coding style guidelines. - [ ] I have addressed the code review feedback from the previous submission, if applicable. ## Summary by CodeRabbit - **New Features** - Implemented functionality to download signed documents directly from the app. --- packages/api/v1/contract.ts | 12 ++++++ packages/api/v1/implementation.ts | 67 ++++++++++++++++++++++++++++++- packages/api/v1/schema.ts | 4 ++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/api/v1/contract.ts b/packages/api/v1/contract.ts index 162cdcf9d..ca2b6e2f5 100644 --- a/packages/api/v1/contract.ts +++ b/packages/api/v1/contract.ts @@ -11,6 +11,7 @@ import { ZDeleteDocumentMutationSchema, ZDeleteFieldMutationSchema, ZDeleteRecipientMutationSchema, + ZDownloadDocumentSuccessfulSchema, ZGetDocumentsQuerySchema, ZSendDocumentForSigningMutationSchema, ZSuccessfulDocumentResponseSchema, @@ -51,6 +52,17 @@ export const ApiContractV1 = c.router( summary: 'Get a single document', }, + downloadSignedDocument: { + method: 'GET', + path: '/api/v1/documents/:id/download', + responses: { + 200: ZDownloadDocumentSuccessfulSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + }, + summary: 'Download a signed document when the storage transport is S3', + }, + createDocument: { method: 'POST', path: '/api/v1/documents', diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index d9bc1a6d7..8ee0350bd 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -23,7 +23,10 @@ import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/ import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { getFile } from '@documenso/lib/universal/upload/get-file'; import { putFile } from '@documenso/lib/universal/upload/put-file'; -import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; +import { + getPresignGetUrl, + getPresignPostUrl, +} from '@documenso/lib/universal/upload/server-actions'; import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { ApiContractV1 } from './contract'; @@ -83,6 +86,68 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { } }), + downloadSignedDocument: authenticatedMiddleware(async (args, user, team) => { + const { id: documentId } = args.params; + + try { + if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') { + return { + status: 500, + body: { + message: 'Please make sure the storage transport is set to S3.', + }, + }; + } + + const document = await getDocumentById({ + id: Number(documentId), + userId: user.id, + teamId: team?.id, + }); + + if (!document || !document.documentDataId) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + + if (DocumentDataType.S3_PATH !== document.documentData.type) { + return { + status: 400, + body: { + message: 'Invalid document data type', + }, + }; + } + + if (document.status !== DocumentStatus.COMPLETED) { + return { + status: 400, + body: { + message: 'Document is not completed yet.', + }, + }; + } + + const { url } = await getPresignGetUrl(document.documentData.data); + + return { + status: 200, + body: { downloadUrl: url }, + }; + } catch (err) { + return { + status: 500, + body: { + message: 'Error downloading the document. Please try again.', + }, + }; + } + }), + deleteDocument: authenticatedMiddleware(async (args, user, team) => { const { id: documentId } = args.params; diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index 01f6e2d58..be0ea1271 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -53,6 +53,10 @@ export const ZUploadDocumentSuccessfulSchema = z.object({ key: z.string(), }); +export const ZDownloadDocumentSuccessfulSchema = z.object({ + downloadUrl: z.string(), +}); + export type TUploadDocumentSuccessfulSchema = z.infer; export const ZCreateDocumentMutationSchema = z.object({ From afaeba97393094017dc1c85f544ea4b6bf4cc16d Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 22 Apr 2024 13:31:49 +0700 Subject: [PATCH 114/174] fix: resize fields --- .../document/document-read-only-fields.tsx | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/document/document-read-only-fields.tsx b/apps/web/src/components/document/document-read-only-fields.tsx index 530066fa8..95a907d8f 100644 --- a/apps/web/src/components/document/document-read-only-fields.tsx +++ b/apps/web/src/components/document/document-read-only-fields.tsx @@ -75,7 +75,7 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
-
+
{match(field) .with({ type: FieldType.SIGNATURE }, (field) => field.Signature?.signatureImageAsBase64 ? ( @@ -90,18 +90,17 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl

), ) - .with({ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) }, () => ( -

{field.customText}

- )) - .with({ type: FieldType.DATE }, () => ( -

- {convertToLocalSystemFormat( - field.customText, - documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, - documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, - )} -

- )) + .with( + { type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) }, + () => field.customText, + ) + .with({ type: FieldType.DATE }, () => + convertToLocalSystemFormat( + field.customText, + documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, + documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, + ), + ) .with({ type: FieldType.FREE_SIGNATURE }, () => null) .exhaustive()}
From 0eee57078101fc31cfb656f768187aa25413514e Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 23 Apr 2024 12:33:40 +0300 Subject: [PATCH 115/174] fix: complete document when all recipients are CC --- .../documents/[id]/edit-document.tsx | 13 ++++++++++++- .../trpc/server/document-router/router.ts | 19 +++++++++++++++++++ .../trpc/server/document-router/schema.ts | 6 ++++++ 3 files changed, 37 insertions(+), 1 deletion(-) 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..fbc700219 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -8,6 +8,7 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META, } from '@documenso/lib/constants/trpc'; +import { DocumentStatus, RecipientRole } from '@documenso/prisma/client'; import type { DocumentWithDetails } from '@documenso/prisma/types/document'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -68,6 +69,10 @@ export const EditDocumentForm = ({ const { Recipient: recipients, Field: fields } = document; + const allRecipientsAreCC = recipients.every((recipient) => recipient.role === RecipientRole.CC); + + const { mutateAsync: updateDocumentStatus } = trpc.document.updateDocument.useMutation(); + const { mutateAsync: setSettingsForDocument } = trpc.document.setSettingsForDocument.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: (newData) => { @@ -248,8 +253,14 @@ export const EditDocumentForm = ({ const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { const { subject, message } = data.meta; - try { + if (allRecipientsAreCC) { + await updateDocumentStatus({ + documentId: document.id, + data: { status: DocumentStatus.COMPLETED }, + }); + } + await sendDocument({ documentId: document.id, teamId: team?.id, diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index d12002674..fa9d95262 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -16,6 +16,7 @@ import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/ import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; +import { updateDocument } from '@documenso/lib/server-only/document/update-document'; import { updateDocumentSettings } from '@documenso/lib/server-only/document/update-document-settings'; import { updateTitle } from '@documenso/lib/server-only/document/update-title'; import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; @@ -36,6 +37,7 @@ import { ZSetPasswordForDocumentMutationSchema, ZSetSettingsForDocumentMutationSchema, ZSetTitleForDocumentMutationSchema, + ZUpdateDocumentMutationSchema, } from './schema'; export const documentRouter = router({ @@ -132,6 +134,23 @@ export const documentRouter = router({ } }), + updateDocument: authenticatedProcedure + .input(ZUpdateDocumentMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, data } = input; + + await updateDocument({ documentId, data, userId: ctx.user.id }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to delete this document. Please try again later.', + }); + } + }), + deleteDocument: authenticatedProcedure .input(ZDeleteDocumentMutationSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 483d32e50..4936aae37 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -48,6 +48,12 @@ export const ZCreateDocumentMutationSchema = z.object({ teamId: z.number().optional(), }); +export const ZUpdateDocumentMutationSchema = z.object({ + documentId: z.number().min(1), + teamId: z.number().optional(), + data: z.any(), +}); + export type TCreateDocumentMutationSchema = z.infer; export const ZSetSettingsForDocumentMutationSchema = z.object({ From 4d5365bddcd94dacad4fd6c4df3e84652b6f9504 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 23 Apr 2024 14:24:58 +0300 Subject: [PATCH 116/174] fix: complete document when all recipients are CC --- .../documents/[id]/edit-document.tsx | 12 ------------ .../server-only/document/send-document.tsx | 6 +++++- .../trpc/server/document-router/router.ts | 19 ------------------- .../trpc/server/document-router/schema.ts | 6 ------ 4 files changed, 5 insertions(+), 38 deletions(-) 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 fbc700219..e175ae18a 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -8,7 +8,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META, } from '@documenso/lib/constants/trpc'; -import { DocumentStatus, RecipientRole } from '@documenso/prisma/client'; import type { DocumentWithDetails } from '@documenso/prisma/types/document'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -69,10 +68,6 @@ export const EditDocumentForm = ({ const { Recipient: recipients, Field: fields } = document; - const allRecipientsAreCC = recipients.every((recipient) => recipient.role === RecipientRole.CC); - - const { mutateAsync: updateDocumentStatus } = trpc.document.updateDocument.useMutation(); - const { mutateAsync: setSettingsForDocument } = trpc.document.setSettingsForDocument.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: (newData) => { @@ -254,13 +249,6 @@ export const EditDocumentForm = ({ const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { const { subject, message } = data.meta; try { - if (allRecipientsAreCC) { - await updateDocumentStatus({ - documentId: document.id, - data: { status: DocumentStatus.COMPLETED }, - }); - } - await sendDocument({ documentId: document.id, teamId: team?.id, diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 5bb7e2352..6b9ab8037 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -211,6 +211,10 @@ export const sendDocument = async ({ }), ); + const allRecipientsAreCC = document.Recipient.every( + (recipient) => recipient.role === RecipientRole.CC, + ); + const updatedDocument = await prisma.$transaction(async (tx) => { if (document.status === DocumentStatus.DRAFT) { await tx.documentAuditLog.create({ @@ -229,7 +233,7 @@ export const sendDocument = async ({ id: documentId, }, data: { - status: DocumentStatus.PENDING, + status: allRecipientsAreCC ? DocumentStatus.COMPLETED : DocumentStatus.PENDING, }, include: { Recipient: true, diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index fa9d95262..d12002674 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -16,7 +16,6 @@ import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/ import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; -import { updateDocument } from '@documenso/lib/server-only/document/update-document'; import { updateDocumentSettings } from '@documenso/lib/server-only/document/update-document-settings'; import { updateTitle } from '@documenso/lib/server-only/document/update-title'; import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; @@ -37,7 +36,6 @@ import { ZSetPasswordForDocumentMutationSchema, ZSetSettingsForDocumentMutationSchema, ZSetTitleForDocumentMutationSchema, - ZUpdateDocumentMutationSchema, } from './schema'; export const documentRouter = router({ @@ -134,23 +132,6 @@ export const documentRouter = router({ } }), - updateDocument: authenticatedProcedure - .input(ZUpdateDocumentMutationSchema) - .mutation(async ({ input, ctx }) => { - try { - const { documentId, data } = input; - - await updateDocument({ documentId, data, userId: ctx.user.id }); - } catch (err) { - console.error(err); - - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'We were unable to delete this document. Please try again later.', - }); - } - }), - deleteDocument: authenticatedProcedure .input(ZDeleteDocumentMutationSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 4936aae37..483d32e50 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -48,12 +48,6 @@ export const ZCreateDocumentMutationSchema = z.object({ teamId: z.number().optional(), }); -export const ZUpdateDocumentMutationSchema = z.object({ - documentId: z.number().min(1), - teamId: z.number().optional(), - data: z.any(), -}); - export type TCreateDocumentMutationSchema = z.infer; export const ZSetSettingsForDocumentMutationSchema = z.object({ From bb43547a459dc3ef3ae0d02ccf2607b674373f67 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 24 Apr 2024 09:39:47 +0300 Subject: [PATCH 117/174] fix: complete document when all recipients are CC --- .../lib/server-only/document/send-document.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 6b9ab8037..8d92de3a3 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -4,6 +4,8 @@ import { mailer } from '@documenso/email/mailer'; import { render } from '@documenso/email/render'; import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite'; import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; +import { sealDocument } from '@documenso/lib/server-only/document/seal-document'; +import { updateDocument } from '@documenso/lib/server-only/document/update-document'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; @@ -215,6 +217,17 @@ export const sendDocument = async ({ (recipient) => recipient.role === RecipientRole.CC, ); + if (allRecipientsAreCC) { + const updatedDocument = await updateDocument({ + documentId, + userId, + teamId, + data: { status: DocumentStatus.COMPLETED }, + }); + + return await sealDocument({ documentId: updatedDocument.id, requestMetadata }); + } + const updatedDocument = await prisma.$transaction(async (tx) => { if (document.status === DocumentStatus.DRAFT) { await tx.documentAuditLog.create({ @@ -233,7 +246,7 @@ export const sendDocument = async ({ id: documentId, }, data: { - status: allRecipientsAreCC ? DocumentStatus.COMPLETED : DocumentStatus.PENDING, + status: DocumentStatus.PENDING, }, include: { Recipient: true, From d7959950e21684286caad13cbddacaa46b4d559e Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 24 Apr 2024 09:41:34 +0300 Subject: [PATCH 118/174] fix: edit-document line --- apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx | 1 + 1 file changed, 1 insertion(+) 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 e175ae18a..2e2f0c889 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -248,6 +248,7 @@ export const EditDocumentForm = ({ const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { const { subject, message } = data.meta; + try { await sendDocument({ documentId: document.id, From 87423e240af3c915b7ec70eca865f3fc1f3a5fe1 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 24 Apr 2024 17:32:11 +1000 Subject: [PATCH 119/174] chore: update foreign key constraints --- .../migration.sql | 23 +++++++++++++++++++ packages/prisma/schema.prisma | 10 ++++---- 2 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 packages/prisma/migrations/20240424072655_update_foreign_key_constraints/migration.sql diff --git a/packages/prisma/migrations/20240424072655_update_foreign_key_constraints/migration.sql b/packages/prisma/migrations/20240424072655_update_foreign_key_constraints/migration.sql new file mode 100644 index 000000000..89c38943d --- /dev/null +++ b/packages/prisma/migrations/20240424072655_update_foreign_key_constraints/migration.sql @@ -0,0 +1,23 @@ +-- DropForeignKey +ALTER TABLE "PasswordResetToken" DROP CONSTRAINT "PasswordResetToken_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Signature" DROP CONSTRAINT "Signature_fieldId_fkey"; + +-- DropForeignKey +ALTER TABLE "Team" DROP CONSTRAINT "Team_ownerUserId_fkey"; + +-- DropForeignKey +ALTER TABLE "TeamMember" DROP CONSTRAINT "TeamMember_userId_fkey"; + +-- AddForeignKey +ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Signature" ADD CONSTRAINT "Signature_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "Field"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Team" ADD CONSTRAINT "Team_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 8971f837f..97b6e9eeb 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -98,7 +98,7 @@ model PasswordResetToken { createdAt DateTime @default(now()) expiry DateTime userId Int - User User @relation(fields: [userId], references: [id]) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) } model Passkey { @@ -415,7 +415,7 @@ model Signature { typedSignature String? Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade) - Field Field @relation(fields: [fieldId], references: [id], onDelete: Restrict) + Field Field @relation(fields: [fieldId], references: [id], onDelete: Cascade) @@index([recipientId]) } @@ -457,7 +457,7 @@ model Team { emailVerification TeamEmailVerification? transferVerification TeamTransferVerification? - owner User @relation(fields: [ownerUserId], references: [id]) + owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade) subscription Subscription? document Document[] @@ -483,7 +483,7 @@ model TeamMember { createdAt DateTime @default(now()) role TeamMemberRole userId Int - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) @@unique([userId, teamId]) @@ -564,5 +564,5 @@ model SiteSettings { data Json lastModifiedByUserId Int? lastModifiedAt DateTime @default(now()) - lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id]) + lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id], onDelete: SetNull) } From 713cd09a063d1d97db6e753fb0b2edef29b5357f Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 24 Apr 2024 19:07:18 +1000 Subject: [PATCH 120/174] fix: downgrade playwright --- package-lock.json | 74 +++++++++++++++++++++++---------------- package.json | 2 +- packages/lib/package.json | 4 +-- 3 files changed, 46 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index fb03b3a67..83d9523c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "eslint-config-custom": "*", "husky": "^9.0.11", "lint-staged": "^15.2.2", - "playwright": "^1.43.0", + "playwright": "1.41.0", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" @@ -4702,19 +4702,6 @@ "node": ">=14" } }, - "node_modules/@playwright/browser-chromium": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz", - "integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "playwright-core": "1.43.0" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/@playwright/test": { "version": "1.40.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz", @@ -17673,11 +17660,11 @@ } }, "node_modules/playwright": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", - "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==", + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.0.tgz", + "integrity": "sha512-XOsfl5ZtAik/T9oek4V0jAypNlaCNzuKOwVhqhgYT3os6kH34PzbRb74F0VWcLYa5WFdnmxl7qyAHBXvPv7lqQ==", "dependencies": { - "playwright-core": "1.43.0" + "playwright-core": "1.41.0" }, "bin": { "playwright": "cli.js" @@ -17689,17 +17676,6 @@ "fsevents": "2.3.2" } }, - "node_modules/playwright-core": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", - "integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/playwright/node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -17713,6 +17689,17 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/playwright/node_modules/playwright-core": { + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz", + "integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -24981,7 +24968,7 @@ "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", - "playwright": "^1.43.0", + "playwright": "1.41.0", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -24989,10 +24976,23 @@ "zod": "^3.22.4" }, "devDependencies": { - "@playwright/browser-chromium": "^1.43.0", + "@playwright/browser-chromium": "1.41.0", "@types/luxon": "^3.3.1" } }, + "packages/lib/node_modules/@playwright/browser-chromium": { + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.41.0.tgz", + "integrity": "sha512-TaHfh3rDsz4+tVKdMMo4kdFOk8/4U6cPyMXHhoiJVmhOhjHXjR0qPMoa5gz5jDGl478cn5SoXmtgKPgTDFuS0g==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "playwright-core": "1.41.0" + }, + "engines": { + "node": ">=16" + } + }, "packages/lib/node_modules/nanoid": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", @@ -25010,6 +25010,18 @@ "node": "^14 || ^16 || >=18" } }, + "packages/lib/node_modules/playwright-core": { + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz", + "integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "packages/prettier-config": { "name": "@documenso/prettier-config", "version": "0.0.0", diff --git a/package.json b/package.json index 396b2ecfd..70ed541e1 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "eslint-config-custom": "*", "husky": "^9.0.11", "lint-staged": "^15.2.2", - "playwright": "^1.43.0", + "playwright": "1.41.0", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" diff --git a/packages/lib/package.json b/packages/lib/package.json index 1aa7e431e..5e40e047b 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -39,7 +39,7 @@ "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", - "playwright": "^1.43.0", + "playwright": "1.41.0", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -48,6 +48,6 @@ }, "devDependencies": { "@types/luxon": "^3.3.1", - "@playwright/browser-chromium": "^1.43.0" + "@playwright/browser-chromium": "1.41.0" } } \ No newline at end of file From 41ed6c9ad7f34c9ea430808a8e4f5cf95d9e7037 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 24 Apr 2024 19:49:10 +0700 Subject: [PATCH 121/174] fix: disable cert download when document not complete --- .../documents/[id]/logs/document-logs-page-view.tsx | 6 +++++- .../documents/[id]/logs/download-certificate-button.tsx | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx index 2d786b9c9..0556fcd2d 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx @@ -133,7 +133,11 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
- +
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx index 49a330b94..1f2028358 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx @@ -2,6 +2,7 @@ import { DownloadIcon } from 'lucide-react'; +import { DocumentStatus } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -10,11 +11,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type DownloadCertificateButtonProps = { className?: string; documentId: number; + documentStatus: DocumentStatus; }; export const DownloadCertificateButton = ({ className, documentId, + documentStatus, }: DownloadCertificateButtonProps) => { const { toast } = useToast(); @@ -69,6 +72,7 @@ export const DownloadCertificateButton = ({ className={cn('w-full sm:w-auto', className)} loading={isLoading} variant="outline" + disabled={documentStatus !== DocumentStatus.COMPLETED} onClick={() => void onDownloadCertificatesClick()} > {!isLoading && } From e4cf9c82518a6f38ca1626c8899c89abe92efa46 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 24 Apr 2024 19:51:18 +0700 Subject: [PATCH 122/174] fix: add server logic --- packages/trpc/server/document-router/router.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index d12002674..64f3c2480 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -4,6 +4,7 @@ import { DateTime } from 'luxon'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { AppError } from '@documenso/lib/errors/app-error'; import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { createDocument } from '@documenso/lib/server-only/document/create-document'; @@ -20,6 +21,7 @@ import { updateDocumentSettings } from '@documenso/lib/server-only/document/upda import { updateTitle } from '@documenso/lib/server-only/document/update-title'; import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { DocumentStatus } from '@documenso/prisma/client'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { @@ -413,6 +415,10 @@ export const documentRouter = router({ teamId, }); + if (document.status !== DocumentStatus.COMPLETED) { + throw new AppError('DOCUMENT_NOT_COMPLETE'); + } + const encrypted = encryptSecondaryData({ data: document.id.toString(), expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), From 4de122f814b4cbb50ddb9b838f42c5d198102489 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 24 Apr 2024 20:07:38 +0700 Subject: [PATCH 123/174] fix: hide account action reauth --- .../primitives/document-flow/add-settings.tsx | 22 ++++++++++++------- .../primitives/document-flow/add-signers.tsx | 16 ++++++++------ 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index ea962dee5..ce52e03c2 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -9,7 +9,11 @@ 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 } from '@documenso/lib/types/document-auth'; +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'; @@ -216,9 +220,9 @@ export const AddSettingsFormPartial = ({

    -
  • + {/*
  • Require account - The recipient must be signed in -
  • + */}
  • Require passkey - The recipient must have an account and passkey configured via their settings @@ -242,11 +246,13 @@ export const AddSettingsFormPartial = ({ - {Object.values(DocumentActionAuth).map((authType) => ( - - {DOCUMENT_AUTH_TYPES[authType].value} - - ))} + {Object.values(DocumentActionAuth) + .filter((auth) => auth !== DocumentAuth.ACCOUNT) + .map((authType) => ( + + {DOCUMENT_AUTH_TYPES[authType].value} + + ))} {/* Note: -1 is remapped in the Zod schema to the required value. */} None diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index b796f4328..2f9f2f234 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -302,10 +302,10 @@ export const AddSignersFormPartial = ({ global action signing authentication method configured in the "General Settings" step
  • -
  • + {/*
  • Require account - The recipient must be signed in -
  • + */}
  • Require passkey - The recipient must have an account and passkey configured via their settings @@ -326,11 +326,13 @@ export const AddSignersFormPartial = ({ {/* Note: -1 is remapped in the Zod schema to the required value. */} Inherit authentication method - {Object.values(RecipientActionAuth).map((authType) => ( - - {DOCUMENT_AUTH_TYPES[authType].value} - - ))} + {Object.values(RecipientActionAuth) + .filter((auth) => auth !== RecipientActionAuth.ACCOUNT) + .map((authType) => ( + + {DOCUMENT_AUTH_TYPES[authType].value} + + ))} From e1573465f6e66b67b47bb83bcb4e5fa0899a3711 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 25 Apr 2024 23:32:59 +0700 Subject: [PATCH 124/174] fix: hide team webhooks from users --- packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts b/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts index 121fc670d..0877d878f 100644 --- a/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts +++ b/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts @@ -4,6 +4,7 @@ export const getWebhooksByUserId = async (userId: number) => { return await prisma.webhook.findMany({ where: { userId, + teamId: null, }, orderBy: { createdAt: 'desc', From 40808066069d189ad21ddaf06fdaf9fee612dd56 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 26 Apr 2024 02:17:56 +0000 Subject: [PATCH 125/174] fix: minor updates --- .../lib/server-only/document/send-document.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 8d92de3a3..9f68ed29b 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -213,11 +213,11 @@ export const sendDocument = async ({ }), ); - const allRecipientsAreCC = document.Recipient.every( + const allRecipientsHaveNoActionToTake = document.Recipient.every( (recipient) => recipient.role === RecipientRole.CC, ); - if (allRecipientsAreCC) { + if (allRecipientsHaveNoActionToTake) { const updatedDocument = await updateDocument({ documentId, userId, @@ -225,7 +225,17 @@ export const sendDocument = async ({ data: { status: DocumentStatus.COMPLETED }, }); - return await sealDocument({ documentId: updatedDocument.id, requestMetadata }); + await sealDocument({ documentId: updatedDocument.id, requestMetadata }); + + // Keep the return type the same for the `sendDocument` method + return await prisma.document.findFirstOrThrow({ + where: { + id: documentId, + }, + include: { + Recipient: true, + }, + }); } const updatedDocument = await prisma.$transaction(async (tx) => { From 88dedc98298a29d9c0438d9491ab45f3fc38e315 Mon Sep 17 00:00:00 2001 From: Mythie Date: Fri, 26 Apr 2024 13:18:31 +1000 Subject: [PATCH 126/174] fix: use cdp and upgrade playwright again --- package-lock.json | 26 +++++++++---------- package.json | 2 +- packages/lib/package.json | 4 +-- .../htmltopdf/get-certificate-pdf.ts | 4 ++- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 83d9523c5..e9e822cf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "eslint-config-custom": "*", "husky": "^9.0.11", "lint-staged": "^15.2.2", - "playwright": "1.41.0", + "playwright": "1.43.0", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" @@ -17660,11 +17660,11 @@ } }, "node_modules/playwright": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.0.tgz", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", "integrity": "sha512-XOsfl5ZtAik/T9oek4V0jAypNlaCNzuKOwVhqhgYT3os6kH34PzbRb74F0VWcLYa5WFdnmxl7qyAHBXvPv7lqQ==", "dependencies": { - "playwright-core": "1.41.0" + "playwright-core": "1.43.0" }, "bin": { "playwright": "cli.js" @@ -17690,8 +17690,8 @@ } }, "node_modules/playwright/node_modules/playwright-core": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", "integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==", "bin": { "playwright-core": "cli.js" @@ -24968,7 +24968,7 @@ "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", - "playwright": "1.41.0", + "playwright": "1.43.0", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -24976,18 +24976,18 @@ "zod": "^3.22.4" }, "devDependencies": { - "@playwright/browser-chromium": "1.41.0", + "@playwright/browser-chromium": "1.43.0", "@types/luxon": "^3.3.1" } }, "packages/lib/node_modules/@playwright/browser-chromium": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.41.0.tgz", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz", "integrity": "sha512-TaHfh3rDsz4+tVKdMMo4kdFOk8/4U6cPyMXHhoiJVmhOhjHXjR0qPMoa5gz5jDGl478cn5SoXmtgKPgTDFuS0g==", "dev": true, "hasInstallScript": true, "dependencies": { - "playwright-core": "1.41.0" + "playwright-core": "1.43.0" }, "engines": { "node": ">=16" @@ -25011,8 +25011,8 @@ } }, "packages/lib/node_modules/playwright-core": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", "integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==", "dev": true, "bin": { diff --git a/package.json b/package.json index 70ed541e1..3480aae28 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "eslint-config-custom": "*", "husky": "^9.0.11", "lint-staged": "^15.2.2", - "playwright": "1.41.0", + "playwright": "1.43.0", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" diff --git a/packages/lib/package.json b/packages/lib/package.json index 5e40e047b..c6144df92 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -39,7 +39,7 @@ "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", - "playwright": "1.41.0", + "playwright": "1.43.0", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -48,6 +48,6 @@ }, "devDependencies": { "@types/luxon": "^3.3.1", - "@playwright/browser-chromium": "1.41.0" + "@playwright/browser-chromium": "1.43.0" } } \ No newline at end of file diff --git a/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts index a7182410e..dee40d41a 100644 --- a/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts +++ b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts @@ -18,7 +18,9 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions let browser: Browser; if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) { - browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL); + // !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version. + // !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors. + browser = await chromium.connectOverCDP(process.env.NEXT_PRIVATE_BROWSERLESS_URL); } else { browser = await chromium.launch(); } From 481d739c37e7ba147b9c3120a1bac47ea0f7919c Mon Sep 17 00:00:00 2001 From: Mythie Date: Fri, 26 Apr 2024 13:25:16 +1000 Subject: [PATCH 127/174] chore: update package-lock --- package-lock.json | 84 ++++++++++++++++++++--------------------------- 1 file changed, 36 insertions(+), 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index e9e822cf2..479463b25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4702,13 +4702,26 @@ "node": ">=14" } }, + "node_modules/@playwright/browser-chromium": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz", + "integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "playwright-core": "1.43.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@playwright/test": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz", - "integrity": "sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz", + "integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==", "dev": true, "dependencies": { - "playwright": "1.40.0" + "playwright": "1.43.1" }, "bin": { "playwright": "cli.js" @@ -4732,12 +4745,12 @@ } }, "node_modules/@playwright/test/node_modules/playwright": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz", - "integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", + "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", "dev": true, "dependencies": { - "playwright-core": "1.40.0" + "playwright-core": "1.43.1" }, "bin": { "playwright": "cli.js" @@ -4750,9 +4763,9 @@ } }, "node_modules/@playwright/test/node_modules/playwright-core": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz", - "integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", + "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -17662,7 +17675,7 @@ "node_modules/playwright": { "version": "1.43.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", - "integrity": "sha512-XOsfl5ZtAik/T9oek4V0jAypNlaCNzuKOwVhqhgYT3os6kH34PzbRb74F0VWcLYa5WFdnmxl7qyAHBXvPv7lqQ==", + "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==", "dependencies": { "playwright-core": "1.43.0" }, @@ -17676,6 +17689,17 @@ "fsevents": "2.3.2" } }, + "node_modules/playwright-core": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", + "integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/playwright/node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -17689,17 +17713,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/playwright/node_modules/playwright-core": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", - "integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -24980,19 +24993,6 @@ "@types/luxon": "^3.3.1" } }, - "packages/lib/node_modules/@playwright/browser-chromium": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz", - "integrity": "sha512-TaHfh3rDsz4+tVKdMMo4kdFOk8/4U6cPyMXHhoiJVmhOhjHXjR0qPMoa5gz5jDGl478cn5SoXmtgKPgTDFuS0g==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "playwright-core": "1.43.0" - }, - "engines": { - "node": ">=16" - } - }, "packages/lib/node_modules/nanoid": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", @@ -25010,18 +25010,6 @@ "node": "^14 || ^16 || >=18" } }, - "packages/lib/node_modules/playwright-core": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", - "integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==", - "dev": true, - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, "packages/prettier-config": { "name": "@documenso/prettier-config", "version": "0.0.0", From 20edee7f1a2293344827f20deee372381aa25021 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Apr 2024 16:01:09 +0700 Subject: [PATCH 128/174] fix: ssr feature flags (#1119) ## Description Feature flags are broken on SSR due to this error ``` TypeError: fetch failed at Object.fetch (node:internal/deps/undici/undici:11731:11) at process.processTicksAndRejections (node:internal/process/task_queues:95:5) { cause: RequestContentLengthMismatchError: Request body length does not match content-length header at write (node:internal/deps/undici/undici:8590:41) at _resume (node:internal/deps/undici/undici:8563:33) at resume (node:internal/deps/undici/undici:8459:7) at [dispatch] (node:internal/deps/undici/undici:7704:11) at Client.Intercept (node:internal/deps/undici/undici:7377:20) at Client.dispatch (node:internal/deps/undici/undici:6023:44) at [dispatch] (node:internal/deps/undici/undici:6254:32) at Pool.dispatch (node:internal/deps/undici/undici:6023:44) at [dispatch] (node:internal/deps/undici/undici:9343:27) at Agent.Intercept (node:internal/deps/undici/undici:7377:20) { code: 'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH' } } ``` I've removed content-length header since it isn't mandatory to my knowledge for get requests. ## Changes - Add fallback local flags when individual flag request fails - Add error logging - Remove `content-length` from headers being passed to Posthog --- packages/lib/universal/get-feature-flag.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/lib/universal/get-feature-flag.ts b/packages/lib/universal/get-feature-flag.ts index f4650f691..92f186ab3 100644 --- a/packages/lib/universal/get-feature-flag.ts +++ b/packages/lib/universal/get-feature-flag.ts @@ -17,6 +17,7 @@ export const getFlag = async ( options?: GetFlagOptions, ): Promise => { const requestHeaders = options?.requestHeaders ?? {}; + delete requestHeaders['content-length']; if (!isFeatureFlagEnabled()) { return LOCAL_FEATURE_FLAGS[flag] ?? true; @@ -25,7 +26,7 @@ export const getFlag = async ( const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`); url.searchParams.set('flag', flag); - const response = await fetch(url, { + return await fetch(url, { headers: { ...requestHeaders, }, @@ -35,9 +36,10 @@ export const getFlag = async ( }) .then(async (res) => res.json()) .then((res) => ZFeatureFlagValueSchema.parse(res)) - .catch(() => false); - - return response; + .catch((err) => { + console.error(err); + return LOCAL_FEATURE_FLAGS[flag] ?? false; + }); }; /** @@ -50,6 +52,7 @@ export const getAllFlags = async ( options?: GetFlagOptions, ): Promise> => { const requestHeaders = options?.requestHeaders ?? {}; + delete requestHeaders['content-length']; if (!isFeatureFlagEnabled()) { return LOCAL_FEATURE_FLAGS; @@ -67,7 +70,10 @@ export const getAllFlags = async ( }) .then(async (res) => res.json()) .then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res)) - .catch(() => LOCAL_FEATURE_FLAGS); + .catch((err) => { + console.error(err); + return LOCAL_FEATURE_FLAGS; + }); }; /** @@ -89,7 +95,10 @@ export const getAllAnonymousFlags = async (): Promise res.json()) .then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res)) - .catch(() => LOCAL_FEATURE_FLAGS); + .catch((err) => { + console.error(err); + return LOCAL_FEATURE_FLAGS; + }); }; interface GetFlagOptions { From 364c49992776fff2d804a3e6f38294b72b94485b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sat, 27 Apr 2024 15:21:46 +0700 Subject: [PATCH 129/174] fix: increase trpc max duration --- apps/web/src/pages/api/trpc/[trpc].ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/api/trpc/[trpc].ts b/apps/web/src/pages/api/trpc/[trpc].ts index c43291ea1..3db86c50d 100644 --- a/apps/web/src/pages/api/trpc/[trpc].ts +++ b/apps/web/src/pages/api/trpc/[trpc].ts @@ -3,7 +3,7 @@ import { createTrpcContext } from '@documenso/trpc/server/context'; import { appRouter } from '@documenso/trpc/server/router'; export const config = { - maxDuration: 60, + maxDuration: 90, api: { bodyParser: { sizeLimit: '50mb', From 74b9bc786bcfed756d5f1f676c79ec919b2c34ee Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sat, 27 Apr 2024 18:29:52 +0700 Subject: [PATCH 130/174] fix: extend --- apps/web/src/pages/api/trpc/[trpc].ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/api/trpc/[trpc].ts b/apps/web/src/pages/api/trpc/[trpc].ts index 3db86c50d..ba79244b5 100644 --- a/apps/web/src/pages/api/trpc/[trpc].ts +++ b/apps/web/src/pages/api/trpc/[trpc].ts @@ -3,7 +3,7 @@ import { createTrpcContext } from '@documenso/trpc/server/context'; import { appRouter } from '@documenso/trpc/server/router'; export const config = { - maxDuration: 90, + maxDuration: 120, api: { bodyParser: { sizeLimit: '50mb', From c98c1b94674c2ea73fc89ba782f35566dd5b6b2f Mon Sep 17 00:00:00 2001 From: Sumit Bisht Date: Sun, 28 Apr 2024 19:35:57 +0530 Subject: [PATCH 131/174] added teams url under a team name in teams section --- .../components/(dashboard)/layout/menu-switcher.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx index bb8429adc..4b90b5c93 100644 --- a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx +++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx @@ -170,11 +170,20 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
    {teams.map((team) => ( - + + 1 && 'group-hover:hidden')}> + {formatSecondaryAvatarText(team)} + + 1 && 'group-hover:opacity-75')} + >{`/t/${team.url}`} + + } rightSideComponent={ isPathTeamUrl(team.url) && ( From 80c03fcf3f096c645ebd7b5d85191a9816e54068 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 29 Apr 2024 04:28:13 +0530 Subject: [PATCH 132/174] 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 133/174] 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 e82e4025400660e860c9eb090b8942ad015b0777 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 29 Apr 2024 17:10:56 +0530 Subject: [PATCH 134/174] feat: remove the existing empty signer if its the only one --- .../primitives/document-flow/add-signers.tsx | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 2f9f2f234..b83e5064c 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -138,16 +138,6 @@ export const AddSignersFormPartial = ({ ); }; - const onAddSelfSigner = () => { - appendSigner({ - formId: nanoid(12), - name: user?.name ?? '', - email: user?.email ?? '', - role: RecipientRole.SIGNER, - actionAuth: undefined, - }); - }; - const onAddSigner = () => { appendSigner({ formId: nanoid(12), @@ -174,6 +164,17 @@ export const AddSignersFormPartial = ({ removeSigner(index); }; + const onAddSelfSigner = () => { + if(signers.length !== 0) onRemoveSigner(0); + appendSigner({ + formId: nanoid(12), + name: user?.name ?? '', + email: user?.email ?? '', + role: RecipientRole.SIGNER, + actionAuth: undefined, + }); + }; + const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { onAddSigner(); @@ -302,10 +303,10 @@ export const AddSignersFormPartial = ({ global action signing authentication method configured in the "General Settings" step
  • - {/*
  • +
  • Require account - The recipient must be signed in -
  • */} +
  • Require passkey - The recipient must have an account and passkey configured via their settings @@ -326,13 +327,11 @@ export const AddSignersFormPartial = ({ {/* Note: -1 is remapped in the Zod schema to the required value. */} Inherit authentication method - {Object.values(RecipientActionAuth) - .filter((auth) => auth !== RecipientActionAuth.ACCOUNT) - .map((authType) => ( - - {DOCUMENT_AUTH_TYPES[authType].value} - - ))} + {Object.values(RecipientActionAuth).map((authType) => ( + + {DOCUMENT_AUTH_TYPES[authType].value} + + ))} From 917c83fc5f68a8c861a6bf3ee2449e3cd15a7593 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 29 Apr 2024 17:30:01 +0530 Subject: [PATCH 135/174] chore: refactor removal logic --- packages/ui/primitives/document-flow/add-signers.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index b83e5064c..b62d4c433 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -116,6 +116,7 @@ export const AddSignersFormPartial = ({ const onFormSubmit = form.handleSubmit(onSubmit); + const { append: appendSigner, fields: signers, @@ -124,7 +125,7 @@ export const AddSignersFormPartial = ({ control, name: 'signers', }); - + const hasBeenSentToRecipientId = (id?: number) => { if (!id) { return false; @@ -165,7 +166,10 @@ export const AddSignersFormPartial = ({ }; const onAddSelfSigner = () => { - if(signers.length !== 0) onRemoveSigner(0); + const lastSignerIndex = signers.length - 1; + if(!signers[lastSignerIndex].name || !signers[lastSignerIndex].email){ + onRemoveSigner(lastSignerIndex) + } appendSigner({ formId: nanoid(12), name: user?.name ?? '', From 6b5750c7bf8e8af051872bd49068381b11b8b3de Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 29 Apr 2024 17:48:00 +0530 Subject: [PATCH 136/174] chore: revert previous changes --- .../primitives/document-flow/add-signers.tsx | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index b62d4c433..2f9f2f234 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -116,7 +116,6 @@ export const AddSignersFormPartial = ({ const onFormSubmit = form.handleSubmit(onSubmit); - const { append: appendSigner, fields: signers, @@ -125,7 +124,7 @@ export const AddSignersFormPartial = ({ control, name: 'signers', }); - + const hasBeenSentToRecipientId = (id?: number) => { if (!id) { return false; @@ -139,6 +138,16 @@ export const AddSignersFormPartial = ({ ); }; + const onAddSelfSigner = () => { + appendSigner({ + formId: nanoid(12), + name: user?.name ?? '', + email: user?.email ?? '', + role: RecipientRole.SIGNER, + actionAuth: undefined, + }); + }; + const onAddSigner = () => { appendSigner({ formId: nanoid(12), @@ -165,20 +174,6 @@ export const AddSignersFormPartial = ({ removeSigner(index); }; - const onAddSelfSigner = () => { - const lastSignerIndex = signers.length - 1; - if(!signers[lastSignerIndex].name || !signers[lastSignerIndex].email){ - onRemoveSigner(lastSignerIndex) - } - appendSigner({ - formId: nanoid(12), - name: user?.name ?? '', - email: user?.email ?? '', - role: RecipientRole.SIGNER, - actionAuth: undefined, - }); - }; - const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { onAddSigner(); @@ -307,10 +302,10 @@ export const AddSignersFormPartial = ({ global action signing authentication method configured in the "General Settings" step
  • -
  • + {/*
  • Require account - The recipient must be signed in -
  • + */}
  • Require passkey - The recipient must have an account and passkey configured via their settings @@ -331,11 +326,13 @@ export const AddSignersFormPartial = ({ {/* Note: -1 is remapped in the Zod schema to the required value. */} Inherit authentication method - {Object.values(RecipientActionAuth).map((authType) => ( - - {DOCUMENT_AUTH_TYPES[authType].value} - - ))} + {Object.values(RecipientActionAuth) + .filter((auth) => auth !== RecipientActionAuth.ACCOUNT) + .map((authType) => ( + + {DOCUMENT_AUTH_TYPES[authType].value} + + ))} From bde0f5893fa0f5a1b0f338cb978cbddb85c6b4eb Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 29 Apr 2024 17:49:50 +0530 Subject: [PATCH 137/174] feat: update add self signer logic --- .../primitives/document-flow/add-signers.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 2f9f2f234..3227cdeb6 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -138,16 +138,6 @@ export const AddSignersFormPartial = ({ ); }; - const onAddSelfSigner = () => { - appendSigner({ - formId: nanoid(12), - name: user?.name ?? '', - email: user?.email ?? '', - role: RecipientRole.SIGNER, - actionAuth: undefined, - }); - }; - const onAddSigner = () => { appendSigner({ formId: nanoid(12), @@ -174,6 +164,20 @@ export const AddSignersFormPartial = ({ removeSigner(index); }; + const onAddSelfSigner = () => { + const lastSignerIndex = signers.length - 1; + if(!signers[lastSignerIndex].name || !signers[lastSignerIndex].email){ + onRemoveSigner(lastSignerIndex) + } + appendSigner({ + formId: nanoid(12), + name: user?.name ?? '', + email: user?.email ?? '', + role: RecipientRole.SIGNER, + actionAuth: undefined, + }); + }; + const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { onAddSigner(); From 97d334a1da63b8e0284d2697f914631fa64671eb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 29 Apr 2024 20:15:40 +0700 Subject: [PATCH 138/174] 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 db9e605031ed5a57e5c86f28d5845860fbf15707 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Tue, 30 Apr 2024 01:32:58 +0530 Subject: [PATCH 139/174] chore: fix lint issues Signed-off-by: Adithya Krishna --- packages/ui/primitives/document-flow/add-signers.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 3227cdeb6..2b3a9592d 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -166,8 +166,8 @@ export const AddSignersFormPartial = ({ const onAddSelfSigner = () => { const lastSignerIndex = signers.length - 1; - if(!signers[lastSignerIndex].name || !signers[lastSignerIndex].email){ - onRemoveSigner(lastSignerIndex) + if (!signers[lastSignerIndex].name || !signers[lastSignerIndex].email) { + onRemoveSigner(lastSignerIndex); } appendSigner({ formId: nanoid(12), From 0e16a86e74702986fe4e3146a775db9293617795 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Tue, 30 Apr 2024 11:55:01 +0530 Subject: [PATCH 140/174] 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 6df525b670f0b6e571b22ab83fa2cc1110890da1 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Tue, 30 Apr 2024 12:05:42 +0530 Subject: [PATCH 141/174] feat: updated signer logic Signed-off-by: Adithya Krishna --- .../primitives/document-flow/add-signers.tsx | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 2b3a9592d..2203b7e6d 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -110,6 +110,8 @@ export const AddSignersFormPartial = ({ const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings); const { + setValue, + getValues, formState: { errors, isSubmitting }, control, } = form; @@ -164,18 +166,29 @@ export const AddSignersFormPartial = ({ removeSigner(index); }; + const emptySignerIndex = signers.findIndex( + (signer) => + !getValues(`signers.${signers.indexOf(signer)}.name`) || + !getValues(`signers.${signers.indexOf(signer)}.email`), + ); + const onAddSelfSigner = () => { const lastSignerIndex = signers.length - 1; - if (!signers[lastSignerIndex].name || !signers[lastSignerIndex].email) { + if (!signers[lastSignerIndex].name && !signers[lastSignerIndex].email) { onRemoveSigner(lastSignerIndex); } - appendSigner({ - formId: nanoid(12), - name: user?.name ?? '', - email: user?.email ?? '', - role: RecipientRole.SIGNER, - actionAuth: undefined, - }); + if (emptySignerIndex !== -1) { + setValue(`signers.${emptySignerIndex}.name`, user?.name ?? ''); + setValue(`signers.${emptySignerIndex}.email`, user?.email ?? ''); + } else { + appendSigner({ + formId: nanoid(12), + name: user?.name ?? '', + email: user?.email ?? '', + role: RecipientRole.SIGNER, + actionAuth: undefined, + }); + } }; const onKeyDown = (event: React.KeyboardEvent) => { From 8622e688534ffc374aee85715625a12262a38c20 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 30 Apr 2024 15:50:22 +0700 Subject: [PATCH 142/174] 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 143/174] 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 144/174] 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 153/174] 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 154/174] 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 155/174] 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 98672560ca0db215e3816bc383061edc287e5feb Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Wed, 8 May 2024 17:02:01 +0530 Subject: [PATCH 156/174] chore: update self signer logic Signed-off-by: Adithya Krishna --- packages/ui/primitives/document-flow/add-signers.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 682a3fe7a..cfee325e1 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -169,7 +169,7 @@ export const AddSignersFormPartial = ({ const onAddSelfSigner = () => { const lastSignerIndex = signers.length - 1; - if (!signers[lastSignerIndex].name && !signers[lastSignerIndex].email) { + if (signers[lastSignerIndex].email || signers[lastSignerIndex].name) { onRemoveSigner(lastSignerIndex); } if (emptySignerIndex !== -1) { @@ -268,7 +268,7 @@ export const AddSignersFormPartial = ({ disabled={ isSubmitting || hasBeenSentToRecipientId(signer.nativeId) || - signers[index].email === user?.email + signers[index].name === user?.name } onKeyDown={onKeyDown} /> From cc4efddabf8f20dffcc89d3cf8de33a8a1fdd38d Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Wed, 8 May 2024 17:03:57 +0530 Subject: [PATCH 157/174] 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 158/174] 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 159/174] 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) + + + +