From 2a448fe06d29d4a98bbada9d4c373d3ade1c210e Mon Sep 17 00:00:00 2001 From: Ashraf Date: Wed, 20 Dec 2023 17:08:09 +0800 Subject: [PATCH 001/299] fix: fixed the recipients viewing issue on touch screens --- .../app/(dashboard)/documents/[id]/page.tsx | 6 +- .../app/(dashboard)/documents/data-table.tsx | 6 +- .../avatar/stack-avatars-component.tsx | 71 +++++++++++++ .../avatar/stack-avatars-with-tooltip.tsx | 99 ------------------- .../avatar/stack-avatars-with-ui.tsx | 51 ++++++++++ 5 files changed, 127 insertions(+), 106 deletions(-) create mode 100644 apps/web/src/components/(dashboard)/avatar/stack-avatars-component.tsx delete mode 100644 apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx create mode 100644 apps/web/src/components/(dashboard)/avatar/stack-avatars-with-ui.tsx diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index b26b6308c..ce18f27b8 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -11,7 +11,7 @@ import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/clie import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; -import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; +import { StackAvatarsWithUI } from '~/components/(dashboard)/avatar/stack-avatars-with-ui'; import { DocumentStatus } from '~/components/formatter/document-status'; export type DocumentPageProps = { @@ -71,9 +71,9 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
- + {recipients.length} Recipient(s) - +
)} diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index c8adb1422..a0cc4b8e8 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -12,7 +12,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; -import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; +import { StackAvatarsWithUI } from '~/components/(dashboard)/avatar/stack-avatars-with-ui'; import { DocumentStatus } from '~/components/formatter/document-status'; import { LocaleDate } from '~/components/formatter/locale-date'; @@ -64,9 +64,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { { header: 'Recipient', accessorKey: 'recipient', - cell: ({ row }) => { - return ; - }, + cell: ({ row }) => , }, { header: 'Status', diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-component.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-component.tsx new file mode 100644 index 000000000..d7f3106e6 --- /dev/null +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-component.tsx @@ -0,0 +1,71 @@ +import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; +import type { Recipient } from '@documenso/prisma/client'; + +import { AvatarWithRecipient } from './avatar-with-recipient'; +import { StackAvatar } from './stack-avatar'; + +export const StackAvatarsComponent = ({ recipients }: { recipients: Recipient[] }) => { + const waitingRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === 'waiting', + ); + + const openedRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === 'opened', + ); + + const completedRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === 'completed', + ); + + const uncompletedRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === 'unsigned', + ); + return ( +
+ {completedRecipients.length > 0 && ( +
+

Completed

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

Waiting

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

Opened

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

Uncompleted

+ {uncompletedRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} +
+ ); +}; 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 deleted file mode 100644 index 7429d8ee5..000000000 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; -import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import type { Recipient } from '@documenso/prisma/client'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@documenso/ui/primitives/tooltip'; - -import { AvatarWithRecipient } from './avatar-with-recipient'; -import { StackAvatar } from './stack-avatar'; -import { StackAvatars } from './stack-avatars'; - -export type StackAvatarsWithTooltipProps = { - recipients: Recipient[]; - position?: 'top' | 'bottom'; - children?: React.ReactNode; -}; - -export const StackAvatarsWithTooltip = ({ - recipients, - position, - children, -}: StackAvatarsWithTooltipProps) => { - const waitingRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === 'waiting', - ); - - const openedRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === 'opened', - ); - - const completedRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === 'completed', - ); - - const uncompletedRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === 'unsigned', - ); - - return ( - - - - {children || } - - - -
- {completedRecipients.length > 0 && ( -
-

Completed

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

Waiting

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

Opened

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

Uncompleted

- {uncompletedRecipients.map((recipient: Recipient) => ( - - ))} -
- )} -
-
-
-
- ); -}; diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-ui.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-ui.tsx new file mode 100644 index 000000000..5d5f24413 --- /dev/null +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-ui.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size'; +import type { Recipient } from '@documenso/prisma/client'; +import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@documenso/ui/primitives/tooltip'; + +import { StackAvatars } from './stack-avatars'; +import { StackAvatarsComponent } from './stack-avatars-component'; + +export type StackAvatarsWithUIProps = { + recipients: Recipient[]; + position?: 'top' | 'bottom'; + children?: React.ReactNode; +}; + +export const StackAvatarsWithUI = ({ recipients, position, children }: StackAvatarsWithUIProps) => { + const size = useWindowSize(); + return ( + <> + {size.width > 1050 ? ( + + + + {children || } + + + + + + + + ) : ( + + + {children || } + + + + + + + )} + + ); +}; From ce67de9a1cb1ddb5db8092814c08de8f644fe65d Mon Sep 17 00:00:00 2001 From: Ashraf Date: Fri, 29 Dec 2023 19:29:13 +0800 Subject: [PATCH 002/299] refactor: changed component name for better readability --- apps/web/src/app/(dashboard)/documents/[id]/page.tsx | 6 +++--- apps/web/src/app/(dashboard)/documents/data-table.tsx | 4 ++-- .../{stack-avatars-with-ui.tsx => stack-avatars-ui.tsx} | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) rename apps/web/src/components/(dashboard)/avatar/{stack-avatars-with-ui.tsx => stack-avatars-ui.tsx} (91%) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index ce18f27b8..708746af1 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -11,7 +11,7 @@ import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/clie import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; -import { StackAvatarsWithUI } from '~/components/(dashboard)/avatar/stack-avatars-with-ui'; +import { StackAvatarsUI } from '~/components/(dashboard)/avatar/stack-avatars-ui'; import { DocumentStatus } from '~/components/formatter/document-status'; export type DocumentPageProps = { @@ -71,9 +71,9 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
- + {recipients.length} Recipient(s) - +
)} diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index a0cc4b8e8..ca2da02d3 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -12,7 +12,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; -import { StackAvatarsWithUI } from '~/components/(dashboard)/avatar/stack-avatars-with-ui'; +import { StackAvatarsUI } from '~/components/(dashboard)/avatar/stack-avatars-ui'; import { DocumentStatus } from '~/components/formatter/document-status'; import { LocaleDate } from '~/components/formatter/locale-date'; @@ -64,7 +64,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { { header: 'Recipient', accessorKey: 'recipient', - cell: ({ row }) => , + cell: ({ row }) => , }, { header: 'Status', diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-ui.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-ui.tsx similarity index 91% rename from apps/web/src/components/(dashboard)/avatar/stack-avatars-with-ui.tsx rename to apps/web/src/components/(dashboard)/avatar/stack-avatars-ui.tsx index 5d5f24413..c1c44836a 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-ui.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-ui.tsx @@ -13,14 +13,15 @@ import { import { StackAvatars } from './stack-avatars'; import { StackAvatarsComponent } from './stack-avatars-component'; -export type StackAvatarsWithUIProps = { +export type StackAvatarsUIProps = { recipients: Recipient[]; position?: 'top' | 'bottom'; children?: React.ReactNode; }; -export const StackAvatarsWithUI = ({ recipients, position, children }: StackAvatarsWithUIProps) => { +export const StackAvatarsUI = ({ recipients, position, children }: StackAvatarsUIProps) => { const size = useWindowSize(); + return ( <> {size.width > 1050 ? ( From 0a9006430fe57f17320c874435d0d92fbafc9431 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Thu, 4 Jan 2024 23:40:35 +0530 Subject: [PATCH 003/299] fix: command --- package.json | 2 +- turbo.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 30076100f..59ee798d4 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dx": "npm i && npm run dx:up && npm run prisma:migrate-dev", "dx:up": "docker compose -f docker/compose-services.yml up -d", "dx:down": "docker compose -f docker/compose-services.yml down", - "ci": "turbo run build test:e2e", + "ci": "turbo run test:e2e", "prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma", "prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma", "prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma", diff --git a/turbo.json b/turbo.json index 3a96c2a07..5bc0ac483 100644 --- a/turbo.json +++ b/turbo.json @@ -27,7 +27,8 @@ "cache": false }, "test:e2e": { - "dependsOn": ["^build"] + "dependsOn": ["^build"], + "cache": false } }, "globalDependencies": ["**/.env.*local"], From e470020b166abf37975034c73d2efac5a59b305f Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Thu, 4 Jan 2024 23:41:24 +0530 Subject: [PATCH 004/299] feat: add cache build action --- .github/actions/cache-build/action.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/actions/cache-build/action.yml diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml new file mode 100644 index 000000000..6fba4f745 --- /dev/null +++ b/.github/actions/cache-build/action.yml @@ -0,0 +1,25 @@ +name: Cache production build binaries +description: 'Cache or restore if necessary' +inputs: + node_version: + required: false + default: v18.x +runs: + using: 'composite' + steps: + - name: Cache production build + uses: actions/cache@v3 + id: production-build-cache + env: + cache-name: prod-build + with: + path: | + ${{ github.workspace }}/apps/web/.next + ${{ github.workspace }}/apps/marketing/.next + **/.turbo/** + **/dist/** + + key: ${{ runner.os }}-${{ env.cache-name }}-${{ github.run_id }} + + - run: npm run build + if: steps.production-build-cache.outputs.cache-hit != 'true' From fc372d0aa9cb05fe495d1a05536530965ec28fee Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Thu, 4 Jan 2024 23:41:48 +0530 Subject: [PATCH 005/299] feat: add node install action --- .github/actions/node-install/action.yml | 33 +++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/actions/node-install/action.yml diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml new file mode 100644 index 000000000..92d02092a --- /dev/null +++ b/.github/actions/node-install/action.yml @@ -0,0 +1,33 @@ +name: 'Setup node and cache node_modules' +inputs: + node_version: + required: false + default: v18.x + +runs: + using: 'composite' + steps: + - name: Set up Node ${{ inputs.node_version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node_version }} + + - name: Cache node modules + id: cache-npm + uses: actions/cache@v3 + env: + cache-name: cache-node-modules + with: + path: ~/.npm + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }} + + - name: Install dependencies + run: | + npm ci + npm run prisma:generate + env: + HUSKY: '0' From 9b5d64cc1a356e67600562f444b9a54aa5064cf8 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Thu, 4 Jan 2024 23:44:27 +0530 Subject: [PATCH 006/299] feat: add playwright action --- .github/actions/playwright-install/action.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/actions/playwright-install/action.yml diff --git a/.github/actions/playwright-install/action.yml b/.github/actions/playwright-install/action.yml new file mode 100644 index 000000000..6d0648649 --- /dev/null +++ b/.github/actions/playwright-install/action.yml @@ -0,0 +1,18 @@ +name: Install playwright binaries +description: 'Install playwright, cache and restore if necessary' +runs: + using: 'composite' + steps: + - name: Cache playwright + id: cache-playwright + uses: actions/cache@v3 + with: + path: | + ~/.cache/ms-playwright + ${{ github.workspace }}/node_modules/playwright + key: playwright-${{ hashFiles('**/package-lock.json') }} + restore-keys: playwright- + + - name: Install playwright + if: steps.cache-playwright.outputs.cache-hit != 'true' + run: npx playwright install --with-deps From 9e57de512a8cce6cbb225db7721981dc4f384599 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Thu, 4 Jan 2024 23:46:09 +0530 Subject: [PATCH 007/299] feat: use actions --- .github/workflows/ci.yml | 12 ++---------- .github/workflows/e2e-tests.yml | 17 +++++++---------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index deda53ff0..53ed03f20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,20 +23,12 @@ jobs: with: fetch-depth: 2 - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 18 - cache: npm - - - name: Install dependencies - run: npm ci + - uses: ./.github/actions/node-install - name: Copy env run: cp .env.example .env - - name: Build - run: npm run build + - uses: ./.github/actions/cache-build build_docker: name: Build Docker Image diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 7b05458d9..9d1782363 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -6,26 +6,21 @@ on: branches: ['main'] jobs: e2e_tests: - name: "E2E Tests" + name: 'E2E Tests' timeout-minutes: 60 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 18 - cache: npm - - name: Install dependencies - run: npm ci - name: Copy env run: cp .env.example .env + - uses: ./.github/actions/node-install + - name: Start Services run: npm run dx:up - - name: Install Playwright Browsers - run: npx playwright install --with-deps + - uses: ./.github/actions/playwright-install - name: Generate Prisma Client run: npm run prisma:generate -w @documenso/prisma @@ -36,6 +31,8 @@ jobs: - name: Seed the database run: npm run prisma:seed + - uses: ./.github/actions/cache-build + - name: Run Playwright tests run: npm run ci @@ -43,7 +40,7 @@ jobs: if: always() with: name: test-results - path: "packages/app-tests/**/test-results/*" + path: 'packages/app-tests/**/test-results/*' retention-days: 30 env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} From ce6f523230164f830cabcfb4fff224a9db8080dd Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Thu, 4 Jan 2024 23:56:32 +0530 Subject: [PATCH 008/299] fix: key --- .github/actions/cache-build/action.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index 6fba4f745..b91332e04 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -12,6 +12,11 @@ runs: id: production-build-cache env: cache-name: prod-build + key-1: ${{ hashFiles('**/package-lock.json') }} + key-2: ${{ github.run_id }} + + # Ensures production-build.yml will always be fresh + key-3: ${{ github.sha }} with: path: | ${{ github.workspace }}/apps/web/.next @@ -19,7 +24,7 @@ runs: **/.turbo/** **/dist/** - key: ${{ runner.os }}-${{ env.cache-name }}-${{ github.run_id }} + key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }} - run: npm run build if: steps.production-build-cache.outputs.cache-hit != 'true' From b35f050409a3b137e09a0a8d593b30a71e249050 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 00:03:20 +0530 Subject: [PATCH 009/299] fix: add shell --- .github/actions/cache-build/action.yml | 1 + .github/actions/node-install/action.yml | 1 + .github/actions/playwright-install/action.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index b91332e04..cbbe3a0a1 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -27,4 +27,5 @@ runs: key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }} - run: npm run build + shell: bash if: steps.production-build-cache.outputs.cache-hit != 'true' diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml index 92d02092a..351598182 100644 --- a/.github/actions/node-install/action.yml +++ b/.github/actions/node-install/action.yml @@ -26,6 +26,7 @@ runs: ${{ runner.os }} - name: Install dependencies + shell: bash run: | npm ci npm run prisma:generate diff --git a/.github/actions/playwright-install/action.yml b/.github/actions/playwright-install/action.yml index 6d0648649..27d0e66b4 100644 --- a/.github/actions/playwright-install/action.yml +++ b/.github/actions/playwright-install/action.yml @@ -16,3 +16,4 @@ runs: - name: Install playwright if: steps.cache-playwright.outputs.cache-hit != 'true' run: npx playwright install --with-deps + shell: bash From e5b7bf81fa2af9a7add33502044f479d4641ff18 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 00:06:16 +0530 Subject: [PATCH 010/299] fix: add action to codeql --- .github/workflows/codeql-analysis.yml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 465041c0a..314dc7b7b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,19 +25,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 18 - cache: npm - - - name: Install Dependencies - run: npm ci - - name: Copy env run: cp .env.example .env - - name: Build Documenso - run: npm run build + - uses: ./.github/actions/node-install + + - uses: ./.github/actions/cache-build - name: Initialize CodeQL uses: github/codeql-action/init@v2 From 308f55f3d40df50ec36e0bc9a816af668ed057bb Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 00:09:41 +0530 Subject: [PATCH 011/299] fix: key --- .github/actions/cache-build/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index cbbe3a0a1..9c0b1feae 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -24,7 +24,7 @@ runs: **/.turbo/** **/dist/** - key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }} + key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }} - run: npm run build shell: bash From 26b604dbd0fd6db002b318e6922df997beb44dfd Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 00:21:05 +0530 Subject: [PATCH 012/299] fix: add workflow call --- .github/workflows/ci.yml | 4 +--- .github/workflows/codeql-analysis.yml | 1 + .github/workflows/e2e-tests.yml | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53ed03f20..54dec497b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: 'Continuous Integration' on: + workflow_call: push: branches: ['main'] pull_request: @@ -10,9 +11,6 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true -env: - HUSKY: 0 - jobs: build_app: name: Build App diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 314dc7b7b..873869210 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,6 +1,7 @@ name: 'CodeQL' on: + workflow_call: workflow_dispatch: push: branches: ['main'] diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9d1782363..ad9295ac2 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1,5 +1,6 @@ name: Playwright Tests on: + workflow_call: push: branches: ['main'] pull_request: From 0c12e34c38d09ee00ec04edff6c4164cac762399 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 01:08:32 +0530 Subject: [PATCH 013/299] fix: remove call --- .github/workflows/codeql-analysis.yml | 1 - .github/workflows/e2e-tests.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 873869210..314dc7b7b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,7 +1,6 @@ name: 'CodeQL' on: - workflow_call: workflow_dispatch: push: branches: ['main'] diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index ad9295ac2..9d1782363 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1,6 +1,5 @@ name: Playwright Tests on: - workflow_call: push: branches: ['main'] pull_request: From c86f79dd7b68c32d47b051e5745515e6db4385da Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 01:11:28 +0530 Subject: [PATCH 014/299] feat: add workflow call actions --- .github/workflows/node-install.yml | 16 ++++++++++++++++ .github/workflows/production-build.yml | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 .github/workflows/node-install.yml create mode 100644 .github/workflows/production-build.yml diff --git a/.github/workflows/node-install.yml b/.github/workflows/node-install.yml new file mode 100644 index 000000000..9b1bd52a7 --- /dev/null +++ b/.github/workflows/node-install.yml @@ -0,0 +1,16 @@ +name: Node install + +on: + workflow_call: + +jobs: + setup: + name: Setup Node & cache + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Copy env + run: cp .env.example .env + + - uses: ./.github/actions/node-install diff --git a/.github/workflows/production-build.yml b/.github/workflows/production-build.yml new file mode 100644 index 000000000..e227f2aaa --- /dev/null +++ b/.github/workflows/production-build.yml @@ -0,0 +1,21 @@ +name: Production Build + +on: + workflow_call: + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Copy env + run: cp .env.example .env + + - uses: ./.github/actions/node-install + + - uses: ./.github/actions/cache-build From d24b9de254062fff60162bc294ce1d23c15467ab Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 01:19:22 +0530 Subject: [PATCH 015/299] fix: skip install --- .github/actions/node-install/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml index 351598182..947049b62 100644 --- a/.github/actions/node-install/action.yml +++ b/.github/actions/node-install/action.yml @@ -27,6 +27,7 @@ runs: - name: Install dependencies shell: bash + if: steps.cache-npm.outputs.cache-hit != 'true' run: | npm ci npm run prisma:generate From 2bbbe1098a71fab800ea3486c2d381037e816f14 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 01:32:47 +0530 Subject: [PATCH 016/299] fix: action --- .github/actions/cache-build/action.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index 9c0b1feae..ca7550c76 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -12,11 +12,11 @@ runs: id: production-build-cache env: cache-name: prod-build - key-1: ${{ hashFiles('**/package-lock.json') }} - key-2: ${{ github.run_id }} - + key-1: ${{ inputs.node_version }}-${{ hashFiles('**/package-lock.json') }} + key-2: ${{ hashFiles('apps/**/**.[jt]s', 'apps/**/**.[jt]sx', 'packages/**/**.[jt]s', 'packages/**/**.[jt]sx', '!**/node_modules') }} + key-3: ${{ github.event.pull_request.number || github.ref }} # Ensures production-build.yml will always be fresh - key-3: ${{ github.sha }} + key-4: ${{ github.sha }} with: path: | ${{ github.workspace }}/apps/web/.next @@ -24,7 +24,7 @@ runs: **/.turbo/** **/dist/** - key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }} + key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }} - run: npm run build shell: bash From 634807328ed6b14c344a0f15decbe62c03157438 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 01:38:14 +0530 Subject: [PATCH 017/299] fix: command --- .github/actions/node-install/action.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml index 947049b62..351598182 100644 --- a/.github/actions/node-install/action.yml +++ b/.github/actions/node-install/action.yml @@ -27,7 +27,6 @@ runs: - name: Install dependencies shell: bash - if: steps.cache-npm.outputs.cache-hit != 'true' run: | npm ci npm run prisma:generate From 75630ef19d3329c03d85b44dd905152c00a7e072 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 01:58:00 +0530 Subject: [PATCH 018/299] fix: npm action --- .github/actions/node-install/action.yml | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml index 351598182..77483a9a4 100644 --- a/.github/actions/node-install/action.yml +++ b/.github/actions/node-install/action.yml @@ -12,23 +12,28 @@ runs: with: node-version: ${{ inputs.node_version }} - - name: Cache node modules - id: cache-npm + - name: Cache npm uses: actions/cache@v3 - env: - cache-name: cache-node-modules with: path: ~/.npm - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }} + key: npm-${{ hashFiles('package-lock.json') }} + restore-keys: npm- + + - name: Cache node_modules + uses: actions/cache@v3 + id: cache-node-modules + with: + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: modules-${{ hashFiles('package-lock.json') }} - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' shell: bash run: | - npm ci + npm ci --no-audit npm run prisma:generate env: HUSKY: '0' From c8337d7dcc2def5bbaddf190867d946fe1a29b37 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 02:13:42 +0530 Subject: [PATCH 019/299] fix: key --- .github/actions/cache-build/action.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index ca7550c76..b903e8b07 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -10,13 +10,6 @@ runs: - name: Cache production build uses: actions/cache@v3 id: production-build-cache - env: - cache-name: prod-build - key-1: ${{ inputs.node_version }}-${{ hashFiles('**/package-lock.json') }} - key-2: ${{ hashFiles('apps/**/**.[jt]s', 'apps/**/**.[jt]sx', 'packages/**/**.[jt]s', 'packages/**/**.[jt]sx', '!**/node_modules') }} - key-3: ${{ github.event.pull_request.number || github.ref }} - # Ensures production-build.yml will always be fresh - key-4: ${{ github.sha }} with: path: | ${{ github.workspace }}/apps/web/.next @@ -24,7 +17,8 @@ runs: **/.turbo/** **/dist/** - key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }} + key: prod-build-${{ github.run_id }} + restore-keys: prod-build- - run: npm run build shell: bash From 346078dbbe7672c181214821b671f2723b130d90 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 02:15:27 +0530 Subject: [PATCH 020/299] fix: e2e --- .github/actions/cache-build/action.yml | 1 - .github/workflows/e2e-tests.yml | 3 --- 2 files changed, 4 deletions(-) diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index b903e8b07..e1eb4da22 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -22,4 +22,3 @@ runs: - run: npm run build shell: bash - if: steps.production-build-cache.outputs.cache-hit != 'true' diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9d1782363..12a7d9521 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -22,9 +22,6 @@ jobs: - uses: ./.github/actions/playwright-install - - name: Generate Prisma Client - run: npm run prisma:generate -w @documenso/prisma - - name: Create the database run: npm run prisma:migrate-dev From 8eed13e27520206627cce61c5d1771b08a93fc76 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 02:22:42 +0530 Subject: [PATCH 021/299] fix: remove additional workflow --- .github/workflows/node-install.yml | 16 ---------------- .github/workflows/production-build.yml | 21 --------------------- 2 files changed, 37 deletions(-) delete mode 100644 .github/workflows/node-install.yml delete mode 100644 .github/workflows/production-build.yml diff --git a/.github/workflows/node-install.yml b/.github/workflows/node-install.yml deleted file mode 100644 index 9b1bd52a7..000000000 --- a/.github/workflows/node-install.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Node install - -on: - workflow_call: - -jobs: - setup: - name: Setup Node & cache - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Copy env - run: cp .env.example .env - - - uses: ./.github/actions/node-install diff --git a/.github/workflows/production-build.yml b/.github/workflows/production-build.yml deleted file mode 100644 index e227f2aaa..000000000 --- a/.github/workflows/production-build.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Production Build - -on: - workflow_call: - -jobs: - build: - name: Build - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Copy env - run: cp .env.example .env - - - uses: ./.github/actions/node-install - - - uses: ./.github/actions/cache-build From 6d1ad179d4b6cca4f835cc23e4a646f6f729ec7d Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 6 Jan 2024 13:30:21 +0530 Subject: [PATCH 022/299] feat: add clean cache workflow --- .github/workflows/clean-cache.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/clean-cache.yml diff --git a/.github/workflows/clean-cache.yml b/.github/workflows/clean-cache.yml new file mode 100644 index 000000000..2cb13f661 --- /dev/null +++ b/.github/workflows/clean-cache.yml @@ -0,0 +1,29 @@ +name: cleanup caches by a branch +on: + pull_request: + types: + - closed + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Cleanup + run: | + gh extension install actions/gh-actions-cache + + echo "Fetching list of cache key" + cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 ) + + ## Setting this to not fail the workflow while deleting cache keys. + set +e + echo "Deleting caches..." + for cacheKey in $cacheKeysForPR + do + gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm + done + echo "Done" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge From 3eb1a17d3c1be45f345967c749ea800419409d81 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 6 Jan 2024 14:42:49 +0530 Subject: [PATCH 023/299] chore: force build error --- apps/web/src/app/(dashboard)/layout.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index 433aeb18c..40831549b 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -2,8 +2,6 @@ import React from 'react'; import { redirect } from 'next/navigation'; -import { getServerSession } from 'next-auth'; - import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; From 142c93aa630c20622cfd31ae2cbd5383e2c006a6 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 6 Jan 2024 14:45:44 +0530 Subject: [PATCH 024/299] chore: revert force build error --- apps/web/src/app/(dashboard)/layout.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index 40831549b..433aeb18c 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { redirect } from 'next/navigation'; +import { getServerSession } from 'next-auth'; + import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; From 46e83d65bb3caaba4904d207750f9f66420ef9cd Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 6 Jan 2024 15:14:28 +0530 Subject: [PATCH 025/299] feat: cache docker --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54dec497b..117064721 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,5 +37,13 @@ jobs: with: fetch-depth: 2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build Docker Image - run: ./docker/build.sh + uses: docker/build-push-action@v5 + with: + push: false + context: . + file: ./docker/Dockerfile + tags: documenso-${{ github.sha }} From ba37633ecd2a6aabce35b9250f8008d993953c74 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 6 Jan 2024 15:18:09 +0530 Subject: [PATCH 026/299] fix: revert --- .github/workflows/ci.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 117064721..54dec497b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,13 +37,5 @@ jobs: with: fetch-depth: 2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Build Docker Image - uses: docker/build-push-action@v5 - with: - push: false - context: . - file: ./docker/Dockerfile - tags: documenso-${{ github.sha }} + run: ./docker/build.sh From 34a59d2db3324b90044594be7a2ed46b4f6d25f2 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 6 Jan 2024 15:18:33 +0530 Subject: [PATCH 027/299] fix: cache --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54dec497b..117064721 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,5 +37,13 @@ jobs: with: fetch-depth: 2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build Docker Image - run: ./docker/build.sh + uses: docker/build-push-action@v5 + with: + push: false + context: . + file: ./docker/Dockerfile + tags: documenso-${{ github.sha }} From 60651407157fd93153c5ace69b3b9b8689f32c58 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 6 Jan 2024 15:29:19 +0530 Subject: [PATCH 028/299] feat: cache layers --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 117064721..bebca8e85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,14 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Build Docker Image uses: docker/build-push-action@v5 with: @@ -47,3 +55,13 @@ jobs: context: . file: ./docker/Dockerfile tags: documenso-${{ github.sha }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max + + - # Temp fix + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache From 98667dac15235a937d9effbce218aa2a50466108 Mon Sep 17 00:00:00 2001 From: Gautam-Hegde Date: Mon, 22 Jan 2024 12:03:14 +0530 Subject: [PATCH 029/299] chore: code tidy --- .../src/components/(dashboard)/layout/verify-email-banner.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx b/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx index 24e47c186..43eab21c5 100644 --- a/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx +++ b/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import { AlertTriangle } from 'lucide-react'; -import { ONE_SECOND } from '@documenso/lib/constants/time'; +import { ONE_DAY, ONE_SECOND } from '@documenso/lib/constants/time'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -65,7 +65,7 @@ export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => { if (emailVerificationDialogLastShown) { const lastShownTimestamp = parseInt(emailVerificationDialogLastShown); - if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) { + if (Date.now() - lastShownTimestamp < ONE_DAY) { return; } } From 6e22eff5a14905a337fdf5b51aa2a0fe88708fb8 Mon Sep 17 00:00:00 2001 From: Gautam-Hegde Date: Tue, 23 Jan 2024 00:02:04 +0530 Subject: [PATCH 030/299] feat: command grp border --- packages/ui/primitives/command.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui/primitives/command.tsx b/packages/ui/primitives/command.tsx index cbc306c66..5f1ebe2e4 100644 --- a/packages/ui/primitives/command.tsx +++ b/packages/ui/primitives/command.tsx @@ -96,7 +96,10 @@ const CommandGroup = React.forwardRef< className, )} {...props} - /> + > +
+ {props.children} + )); CommandGroup.displayName = CommandPrimitive.Group.displayName; From 58f4b729398d5a9f21769c8857ae445fe21bf8a4 Mon Sep 17 00:00:00 2001 From: apoorv taneja Date: Thu, 8 Feb 2024 13:31:38 +0530 Subject: [PATCH 031/299] added fixed width for status col --- apps/web/src/app/(dashboard)/documents/data-table.tsx | 1 + packages/ui/primitives/data-table.tsx | 7 ++++++- 2 files changed, 7 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 c8adb1422..a1bc76b1d 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -72,6 +72,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { header: 'Status', accessorKey: 'status', cell: ({ row }) => , + size: 140, }, { header: 'Actions', diff --git a/packages/ui/primitives/data-table.tsx b/packages/ui/primitives/data-table.tsx index 9cc14a684..55895e08f 100644 --- a/packages/ui/primitives/data-table.tsx +++ b/packages/ui/primitives/data-table.tsx @@ -115,7 +115,12 @@ export function DataTable({ table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} From 0c8a89a2eaf6daf22916f3fa736becbc111752a6 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Fri, 1 Mar 2024 12:53:51 +0530 Subject: [PATCH 032/299] feat: add typefully card Signed-off-by: Adithya Krishna --- .../src/app/(marketing)/open/cap-table.tsx | 3 +- .../src/app/(marketing)/open/page.tsx | 3 ++ .../src/app/(marketing)/open/typefully.tsx | 47 ++++++++++++++++++ packages/assets/twitter-icon.png | Bin 0 -> 14293 bytes 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 apps/marketing/src/app/(marketing)/open/typefully.tsx create mode 100644 packages/assets/twitter-icon.png diff --git a/apps/marketing/src/app/(marketing)/open/cap-table.tsx b/apps/marketing/src/app/(marketing)/open/cap-table.tsx index ca63bd7bf..ba6a12dc4 100644 --- a/apps/marketing/src/app/(marketing)/open/cap-table.tsx +++ b/apps/marketing/src/app/(marketing)/open/cap-table.tsx @@ -1,6 +1,7 @@ 'use client'; -import { HTMLAttributes, useEffect, useState } from 'react'; +import type { HTMLAttributes } from 'react'; +import { useEffect, useState } from 'react'; import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts'; diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index 76de85fcf..8fef81134 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -15,6 +15,7 @@ import { MonthlyNewUsersChart } from './monthly-new-users-chart'; import { MonthlyTotalUsersChart } from './monthly-total-users-chart'; import { TeamMembers } from './team-members'; import { OpenPageTooltip } from './tooltip'; +import { Typefully } from './typefully'; export const metadata: Metadata = { title: 'Open Startup', @@ -237,6 +238,8 @@ export default async function OpenPage() { + +

Where's the rest?

diff --git a/apps/marketing/src/app/(marketing)/open/typefully.tsx b/apps/marketing/src/app/(marketing)/open/typefully.tsx new file mode 100644 index 000000000..1a927d4d6 --- /dev/null +++ b/apps/marketing/src/app/(marketing)/open/typefully.tsx @@ -0,0 +1,47 @@ +'use client'; + +import type { HTMLAttributes } from 'react'; +import { useEffect, useState } from 'react'; + +import Image from 'next/image'; +import Link from 'next/link'; + +import Twitter from '@documenso/assets/twitter-icon.png'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type TypefullyProps = HTMLAttributes; + +export const Typefully = ({ className, ...props }: TypefullyProps) => { + const [isSSR, setIsSSR] = useState(true); + + useEffect(() => { + setIsSSR(false); + }, []); + return ( +
+

Twitter Stats

+ +
+ {!isSSR && ( +
+ Twitter Logo + +

Documenso on X

+ + + +
+ )} +
+
+ ); +}; diff --git a/packages/assets/twitter-icon.png b/packages/assets/twitter-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f1da461c2d3a56e5772bce09065abaa8d25a8fbe GIT binary patch literal 14293 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 Date: Fri, 1 Mar 2024 11:25:21 +0000 Subject: [PATCH 033/299] remove debug logs console.log on signup form --- apps/web/src/components/forms/v2/signup.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/components/forms/v2/signup.tsx b/apps/web/src/components/forms/v2/signup.tsx index 713bde4b4..dbf8fc6b4 100644 --- a/apps/web/src/components/forms/v2/signup.tsx +++ b/apps/web/src/components/forms/v2/signup.tsx @@ -119,8 +119,6 @@ export const SignUpFormV2 = ({ form.formState.dirtyFields.signature && form.formState.errors.signature === undefined; - console.log({ formSTate: form.formState }); - const { mutateAsync: signup } = trpc.auth.signup.useMutation(); const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormV2Schema) => { From e5fe3d897d9704a2b0b43933ef2c156e2e7932d6 Mon Sep 17 00:00:00 2001 From: McPizza Date: Fri, 1 Mar 2024 11:27:24 +0000 Subject: [PATCH 034/299] remove fixed true condition from auth signup router --- packages/trpc/server/auth-router/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 3f199ac11..d2e688d9b 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -25,7 +25,7 @@ export const authRouter = router({ const { name, email, password, signature, url } = input; - if ((true || IS_BILLING_ENABLED()) && url && url.length <= 6) { + if ((IS_BILLING_ENABLED()) && url && url.length <= 6) { throw new AppError( AppErrorCode.PREMIUM_PROFILE_URL, 'Only subscribers can have a username shorter than 6 characters', From 665ccd7628220a347874bcb3cebd668f4b417ca1 Mon Sep 17 00:00:00 2001 From: McPizza Date: Fri, 1 Mar 2024 11:30:42 +0000 Subject: [PATCH 035/299] update username min characters --- packages/trpc/server/auth-router/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index d2e688d9b..f42a9b6d3 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -25,7 +25,7 @@ export const authRouter = router({ const { name, email, password, signature, url } = input; - if ((IS_BILLING_ENABLED()) && url && url.length <= 6) { + if ((IS_BILLING_ENABLED()) && url && url.length < 6) { throw new AppError( AppErrorCode.PREMIUM_PROFILE_URL, 'Only subscribers can have a username shorter than 6 characters', From 00c36782ff78b01681c3089f72f5e9abbab444c5 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 1 Mar 2024 22:59:52 +1100 Subject: [PATCH 036/299] fix: why didn't prettier catch this --- packages/trpc/server/auth-router/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index f42a9b6d3..16b370b3e 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -25,7 +25,7 @@ export const authRouter = router({ const { name, email, password, signature, url } = input; - if ((IS_BILLING_ENABLED()) && url && url.length < 6) { + if (IS_BILLING_ENABLED() && url && url.length < 6) { throw new AppError( AppErrorCode.PREMIUM_PROFILE_URL, 'Only subscribers can have a username shorter than 6 characters', From 0f03ad4a6baeb3574f66314e61c42cee1a582fb9 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Fri, 1 Mar 2024 19:05:16 +0530 Subject: [PATCH 037/299] chore: updated wordings for claimed ursers Signed-off-by: Adithya Krishna --- .../settings/profile/claim-profile-alert-dialog.tsx | 7 ++++--- apps/web/src/components/forms/v2/signup.tsx | 3 +-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx b/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx index 827ea1cbe..6cc0dc0a4 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx @@ -27,10 +27,11 @@ export const ClaimProfileAlertDialog = ({ className, user }: ClaimProfileAlertDi variant="neutral" >
- Claim your profile + {user.url ? 'Update your profile' : 'Claim your profile'} - Profiles are coming soon! Claim your profile username now to reserve your corner of the - signing revolution. + {user.url + ? 'Profiles are coming soon! Update your profile username to reserve your corner of the signing revolution.' + : 'Profiles are coming soon! Claim your profile username now to reserve your corner of the signing revolution.'}
diff --git a/apps/web/src/components/forms/v2/signup.tsx b/apps/web/src/components/forms/v2/signup.tsx index 713bde4b4..4afc44ca7 100644 --- a/apps/web/src/components/forms/v2/signup.tsx +++ b/apps/web/src/components/forms/v2/signup.tsx @@ -421,8 +421,7 @@ export const SignUpFormV2 = ({ size="lg" variant="secondary" className="flex-1" - disabled={step === 'BASIC_DETAILS'} - loading={form.formState.isSubmitting} + disabled={step === 'BASIC_DETAILS' || form.formState.isSubmitting} onClick={() => setStep('BASIC_DETAILS')} > Back From 36a95f6153e9a6710d758ad8659c0878f947ee17 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Fri, 1 Mar 2024 19:10:15 +0530 Subject: [PATCH 038/299] chore: updated text color Signed-off-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 c5f761853..f44d6029d 100644 --- a/apps/marketing/src/app/(marketing)/layout.tsx +++ b/apps/marketing/src/app/(marketing)/layout.tsx @@ -56,7 +56,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) { />
-
+
Claim your documenso public profile username now!{' '} documenso.com/u/yourname
From 437410c73ab2cf16f37f0f54202b6bc1efe1aa3e Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Fri, 1 Mar 2024 19:11:47 +0530 Subject: [PATCH 039/299] chore: updated text color Signed-off-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 f44d6029d..a7c599b36 100644 --- a/apps/marketing/src/app/(marketing)/layout.tsx +++ b/apps/marketing/src/app/(marketing)/layout.tsx @@ -56,7 +56,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) { />
-
+
Claim your documenso public profile username now!{' '} documenso.com/u/yourname
From 452545dab19b0376b6b57b8b4e7f92cab54f4741 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Fri, 1 Mar 2024 19:12:09 +0530 Subject: [PATCH 040/299] chore: updated button text Signed-off-by: Adithya Krishna --- .../(dashboard)/settings/profile/claim-profile-alert-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx b/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx index 6cc0dc0a4..c894113b6 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/claim-profile-alert-dialog.tsx @@ -36,7 +36,7 @@ export const ClaimProfileAlertDialog = ({ className, user }: ClaimProfileAlertDi
- +
From 73aae6f1e3bfe7fe12c983224872bb3c35a6b552 Mon Sep 17 00:00:00 2001 From: Mythie Date: Sun, 3 Mar 2024 01:55:33 +1100 Subject: [PATCH 041/299] feat: improve admin panel --- .../admin/documents/[id]/admin-actions.tsx | 69 +++++++ .../(dashboard)/admin/documents/[id]/page.tsx | 86 +++++++++ .../admin/documents/[id]/recipient-item.tsx | 182 ++++++++++++++++++ .../admin/documents/data-table.tsx | 125 ------------ .../admin/documents/document-results.tsx | 150 +++++++++++++++ .../app/(dashboard)/admin/documents/page.tsx | 24 +-- .../admin/users/[id]/delete-user-dialog.tsx | 131 +++++++++++++ .../app/(dashboard)/admin/users/[id]/page.tsx | 9 +- .../src/app/(dashboard)/admin/users/page.tsx | 2 +- .../(dashboard)/layout/desktop-nav.tsx | 2 +- .../components/(dashboard)/layout/header.tsx | 2 +- .../client-only/hooks/use-copy-share-link.ts | 2 +- .../server-only/admin/get-entire-document.ts | 26 +++ .../lib/server-only/admin/update-recipient.ts | 30 +++ .../lib/server-only/document/seal-document.ts | 13 +- packages/lib/server-only/user/delete-user.ts | 10 +- packages/trpc/server/admin-router/router.ts | 83 +++++++- packages/trpc/server/admin-router/schema.ts | 41 +++- packages/trpc/server/profile-router/router.ts | 6 +- 19 files changed, 824 insertions(+), 169 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/admin/documents/[id]/admin-actions.tsx create mode 100644 apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx create mode 100644 apps/web/src/app/(dashboard)/admin/documents/[id]/recipient-item.tsx delete mode 100644 apps/web/src/app/(dashboard)/admin/documents/data-table.tsx create mode 100644 apps/web/src/app/(dashboard)/admin/documents/document-results.tsx create mode 100644 apps/web/src/app/(dashboard)/admin/users/[id]/delete-user-dialog.tsx create mode 100644 packages/lib/server-only/admin/get-entire-document.ts create mode 100644 packages/lib/server-only/admin/update-recipient.ts diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/admin-actions.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/admin-actions.tsx new file mode 100644 index 000000000..5d6cae4af --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/admin-actions.tsx @@ -0,0 +1,69 @@ +'use client'; + +import Link from 'next/link'; + +import { type Document, 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'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@documenso/ui/primitives/tooltip'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminActionsProps = { + className?: string; + document: Document; +}; + +export const AdminActions = ({ className, document }: AdminActionsProps) => { + const { toast } = useToast(); + + const { mutate: resealDocument, isLoading: isResealDocumentLoading } = + trpc.admin.resealDocument.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Document resealed', + }); + }, + onError: () => { + toast({ + title: 'Error', + description: 'Failed to reseal document', + variant: 'destructive', + }); + }, + }); + + return ( +
+ + + + + + + + Attempts sealing the document again, useful for after a code change has occurred to + resolve an erroneous 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 new file mode 100644 index 000000000..a22345457 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx @@ -0,0 +1,86 @@ +import { DateTime } from 'luxon'; + +import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@documenso/ui/primitives/accordion'; +import { Badge } from '@documenso/ui/primitives/badge'; + +import { DocumentStatus } from '~/components/formatter/document-status'; +import { LocaleDate } from '~/components/formatter/locale-date'; + +import { AdminActions } from './admin-actions'; +import { RecipientItem } from './recipient-item'; + +type AdminDocumentDetailsPageProps = { + params: { + id: string; + }; +}; + +export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) { + const document = await getEntireDocument({ id: Number(params.id) }); + + return ( +
+
+
+

{document.title}

+ +
+ + {document.deletedAt && ( + + Deleted + + )} +
+ +
+
+ Created on: +
+
+ Last updated at: +
+
+ +
+ +

Admin Actions

+ + + +
+

Recipients

+ +
+ + {document.Recipient.map((recipient) => ( + + +
+

{recipient.name}

+ + {recipient.email} + +
+
+ + + + +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/recipient-item.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/recipient-item.tsx new file mode 100644 index 000000000..3bf8c78ab --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/recipient-item.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { + type Field, + type Recipient, + type Signature, + SigningStatus, +} from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { + Form, + FormControl, + 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 ZAdminUpdateRecipientFormSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), +}); + +type TAdminUpdateRecipientFormSchema = z.infer; + +export type RecipientItemProps = { + recipient: Recipient & { + Field: Array< + Field & { + Signature: Signature | null; + } + >; + }; +}; + +export const RecipientItem = ({ recipient }: RecipientItemProps) => { + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm({ + defaultValues: { + name: recipient.name, + email: recipient.email, + }, + }); + + const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation(); + + const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => { + try { + await updateRecipient({ + id: recipient.id, + name, + email, + }); + + toast({ + title: 'Recipient updated', + description: 'The recipient has been updated successfully', + }); + + router.refresh(); + } catch (error) { + toast({ + title: 'Failed to update recipient', + description: error.message, + variant: 'destructive', + }); + } + }; + + return ( +
+
+ +
+ ( + + Name + + + + + + + + )} + /> + + ( + + Email + + + + + + + + )} + /> + +
+ +
+
+
+ + +
+ +

Fields

+ +
{row.original.id}
, + }, + { + header: 'Type', + accessorKey: 'type', + cell: ({ row }) =>
{row.original.type}
, + }, + { + header: 'Inserted', + accessorKey: 'inserted', + cell: ({ row }) =>
{row.original.inserted ? 'True' : 'False'}
, + }, + { + header: 'Value', + accessorKey: 'customText', + cell: ({ row }) =>
{row.original.customText}
, + }, + { + header: 'Signature', + accessorKey: 'signature', + cell: ({ row }) => ( +
+ {row.original.Signature?.typedSignature && ( + {row.original.Signature.typedSignature} + )} + + {row.original.Signature?.signatureImageAsBase64 && ( + Signature + )} +
+ ), + }, + ]} + /> +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx deleted file mode 100644 index 0fc660968..000000000 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx +++ /dev/null @@ -1,125 +0,0 @@ -'use client'; - -import { useTransition } from 'react'; - -import Link from 'next/link'; - -import { Loader } from 'lucide-react'; - -import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import type { FindResultSet } from '@documenso/lib/types/find-result-set'; -import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; -import type { Document, User } from '@documenso/prisma/client'; -import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; -import { DataTable } from '@documenso/ui/primitives/data-table'; -import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; - -import { DocumentStatus } from '~/components/formatter/document-status'; -import { LocaleDate } from '~/components/formatter/locale-date'; - -export type DocumentsDataTableProps = { - results: FindResultSet< - Document & { - User: Pick; - } - >; -}; - -export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { - const [isPending, startTransition] = useTransition(); - - const updateSearchParams = useUpdateSearchParams(); - - const onPaginationChange = (page: number, perPage: number) => { - startTransition(() => { - updateSearchParams({ - page, - perPage, - }); - }); - }; - - return ( -
- , - }, - { - header: 'Title', - accessorKey: 'title', - cell: ({ row }) => { - return ( -
- {row.original.title} -
- ); - }, - }, - { - header: 'Owner', - accessorKey: 'owner', - cell: ({ row }) => { - const avatarFallbackText = row.original.User.name - ? extractInitials(row.original.User.name) - : row.original.User.email.slice(0, 1).toUpperCase(); - - return ( - - - - - - {avatarFallbackText} - - - - - - - - {avatarFallbackText} - - - -
- {row.original.User.name} - {row.original.User.email} -
-
-
- ); - }, - }, - { - header: 'Last updated', - accessorKey: 'updatedAt', - cell: ({ row }) => , - }, - { - header: 'Status', - accessorKey: 'status', - cell: ({ row }) => , - }, - ]} - data={results.data} - perPage={results.perPage} - currentPage={results.currentPage} - totalPages={results.totalPages} - onPaginationChange={onPaginationChange} - > - {(table) => } -
- - {isPending && ( -
- -
- )} -
- ); -}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/document-results.tsx b/apps/web/src/app/(dashboard)/admin/documents/document-results.tsx new file mode 100644 index 000000000..b7e235981 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/document-results.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useState } from 'react'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; + +import { Loader } from 'lucide-react'; + +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import { trpc } from '@documenso/trpc/react'; +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Input } from '@documenso/ui/primitives/input'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; + +import { DocumentStatus } from '~/components/formatter/document-status'; +import { LocaleDate } from '~/components/formatter/locale-date'; + +// export type AdminDocumentResultsProps = {}; + +export const AdminDocumentResults = () => { + const searchParams = useSearchParams(); + + const updateSearchParams = useUpdateSearchParams(); + + const [term, setTerm] = useState(() => searchParams?.get?.('term') ?? ''); + const debouncedTerm = useDebouncedValue(term, 500); + + const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined; + const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined; + + const { data: findDocumentsData, isLoading: isFindDocumentsLoading } = + trpc.admin.findDocuments.useQuery( + { + term: debouncedTerm, + page: page || 1, + perPage: perPage || 20, + }, + { + keepPreviousData: true, + }, + ); + + const onPaginationChange = (newPage: number, newPerPage: number) => { + updateSearchParams({ + page: newPage, + perPage: newPerPage, + }); + }; + + return ( +
+ setTerm(e.target.value)} + /> + +
+ , + }, + { + header: 'Title', + accessorKey: 'title', + cell: ({ row }) => { + return ( + + {row.original.title} + + ); + }, + }, + { + header: 'Status', + accessorKey: 'status', + cell: ({ row }) => , + }, + { + header: 'Owner', + accessorKey: 'owner', + cell: ({ row }) => { + const avatarFallbackText = row.original.User.name + ? extractInitials(row.original.User.name) + : row.original.User.email.slice(0, 1).toUpperCase(); + + return ( + + + + + + {avatarFallbackText} + + + + + + + + + {avatarFallbackText} + + + +
+ {row.original.User.name} + {row.original.User.email} +
+
+
+ ); + }, + }, + { + header: 'Last updated', + accessorKey: 'updatedAt', + cell: ({ row }) => , + }, + ]} + data={findDocumentsData?.data ?? []} + perPage={findDocumentsData?.perPage ?? 20} + currentPage={findDocumentsData?.currentPage ?? 1} + totalPages={findDocumentsData?.totalPages ?? 1} + onPaginationChange={onPaginationChange} + > + {(table) => } +
+ + {isFindDocumentsLoading && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/page.tsx b/apps/web/src/app/(dashboard)/admin/documents/page.tsx index 2fbbcd4dc..96e4dcef8 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/page.tsx @@ -1,28 +1,12 @@ -import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; - -import { DocumentsDataTable } from './data-table'; - -export type DocumentsPageProps = { - searchParams?: { - page?: string; - perPage?: string; - }; -}; - -export default async function Documents({ searchParams = {} }: DocumentsPageProps) { - const page = Number(searchParams.page) || 1; - const perPage = Number(searchParams.perPage) || 20; - - const results = await findDocuments({ - page, - perPage, - }); +import { AdminDocumentResults } from './document-results'; +export default function AdminDocumentsPage() { return (

Manage documents

+
- +
); diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/delete-user-dialog.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/delete-user-dialog.tsx new file mode 100644 index 000000000..42e523ece --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/delete-user-dialog.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import type { User } 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 DeleteUserDialogProps = { + className?: string; + user: User; +}; + +export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => { + const router = useRouter(); + const { toast } = useToast(); + + const [email, setEmail] = useState(''); + + const { mutateAsync: deleteUser, isLoading: isDeletingUser } = + trpc.admin.deleteUser.useMutation(); + + const onDeleteAccount = async () => { + try { + await deleteUser({ + id: user.id, + email, + }); + + toast({ + title: 'Account deleted', + description: 'The account has been deleted successfully.', + duration: 5000, + }); + + router.push('/admin/users'); + } 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 account. 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 enter the accounts email address
({user.email}). +
+ + setEmail(e.target.value)} + /> +
+ + + + +
+
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx index 3bd909623..b9068329a 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -7,7 +7,7 @@ import { useForm } from 'react-hook-form'; import type { z } from 'zod'; import { trpc } from '@documenso/trpc/react'; -import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema'; +import { ZAdminUpdateProfileMutationSchema } from '@documenso/trpc/server/admin-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Form, @@ -20,9 +20,10 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { DeleteUserDialog } from './delete-user-dialog'; import { MultiSelectRoleCombobox } from './multiselect-role-combobox'; -const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true }); +const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true }); type TUserFormSchema = z.infer; @@ -137,6 +138,10 @@ export default function UserPage({ params }: { params: { id: number } }) { + +
+ + {user && }
); } diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx index 577e0739a..1a5d2f554 100644 --- a/apps/web/src/app/(dashboard)/admin/users/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -19,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag const [{ users, totalPages }, individualPrices] = await Promise.all([ search(searchString, page, perPage), - getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY), + getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY).catch(() => []), ]); const individualPriceIds = individualPrices.map((price) => price.id); diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index 9eef1f4bd..262e297d6 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -57,7 +57,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { key={href} href={`${rootHref}${href}`} className={cn( - 'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2', + 'text-muted-foreground dark:text-muted-foreground/60 focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2', { 'text-foreground dark:text-muted-foreground': pathname?.startsWith( `${rootHref}${href}`, diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index 65bb63230..b0ede5b8b 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -86,7 +86,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => { >
diff --git a/packages/lib/client-only/hooks/use-copy-share-link.ts b/packages/lib/client-only/hooks/use-copy-share-link.ts index 255949e3c..cff552e8f 100644 --- a/packages/lib/client-only/hooks/use-copy-share-link.ts +++ b/packages/lib/client-only/hooks/use-copy-share-link.ts @@ -1,5 +1,5 @@ import { trpc } from '@documenso/trpc/react'; -import { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema'; +import type { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema'; import { useCopyToClipboard } from './use-copy-to-clipboard'; diff --git a/packages/lib/server-only/admin/get-entire-document.ts b/packages/lib/server-only/admin/get-entire-document.ts new file mode 100644 index 000000000..e74ee4c7b --- /dev/null +++ b/packages/lib/server-only/admin/get-entire-document.ts @@ -0,0 +1,26 @@ +import { prisma } from '@documenso/prisma'; + +export type GetEntireDocumentOptions = { + id: number; +}; + +export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => { + const document = await prisma.document.findFirstOrThrow({ + where: { + id, + }, + include: { + Recipient: { + include: { + Field: { + include: { + Signature: true, + }, + }, + }, + }, + }, + }); + + return document; +}; diff --git a/packages/lib/server-only/admin/update-recipient.ts b/packages/lib/server-only/admin/update-recipient.ts new file mode 100644 index 000000000..dcd826476 --- /dev/null +++ b/packages/lib/server-only/admin/update-recipient.ts @@ -0,0 +1,30 @@ +import { prisma } from '@documenso/prisma'; +import { SigningStatus } from '@documenso/prisma/client'; + +export type UpdateRecipientOptions = { + id: number; + name: string | undefined; + email: string | undefined; +}; + +export const updateRecipient = async ({ id, name, email }: UpdateRecipientOptions) => { + const recipient = await prisma.recipient.findFirstOrThrow({ + where: { + id, + }, + }); + + if (recipient.signingStatus === SigningStatus.SIGNED) { + throw new Error('Cannot update a recipient that has already signed.'); + } + + return await prisma.recipient.update({ + where: { + id, + }, + data: { + name, + email, + }, + }); +}; diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 8f39e3d25..dd427dc95 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -22,12 +22,14 @@ import { sendCompletedEmail } from './send-completed-email'; export type SealDocumentOptions = { documentId: number; sendEmail?: boolean; + isResealing?: boolean; requestMetadata?: RequestMetadata; }; export const sealDocument = async ({ documentId, sendEmail = true, + isResealing = false, requestMetadata, }: SealDocumentOptions) => { 'use server'; @@ -78,11 +80,20 @@ export const sealDocument = async ({ throw new Error(`Document ${document.id} has unsigned fields`); } + if (isResealing) { + // If we're resealing we want to use the initial data for the document + // so we aren't placing fields on top of eachother. + documentData.data = documentData.initialData; + } + // !: Need to write the fields onto the document as a hard copy const pdfData = await getFile(documentData); const doc = await PDFDocument.load(pdfData); + // Flatten the form to stop annotation layers from appearing above documenso fields + doc.getForm().flatten(); + for (const field of fields) { await insertFieldInPDF(doc, field); } @@ -134,7 +145,7 @@ export const sealDocument = async ({ }); }); - if (sendEmail) { + if (sendEmail && !isResealing) { await sendCompletedEmail({ documentId, requestMetadata }); } diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index d6d4284b4..71f579c26 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -4,20 +4,18 @@ import { DocumentStatus } from '@documenso/prisma/client'; import { deletedAccountServiceAccount } from './service-accounts/deleted-account'; export type DeleteUserOptions = { - email: string; + id: number; }; -export const deleteUser = async ({ email }: DeleteUserOptions) => { +export const deleteUser = async ({ id }: DeleteUserOptions) => { const user = await prisma.user.findFirst({ where: { - email: { - contains: email, - }, + id, }, }); if (!user) { - throw new Error(`User with email ${email} not found`); + throw new Error(`User with ID ${id} not found`); } const serviceAccount = await deletedAccountServiceAccount(); diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 7d71ab346..5be3ad9db 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -1,14 +1,39 @@ 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 { 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'; +import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; import { adminProcedure, router } from '../trpc'; -import { ZUpdateProfileMutationByAdminSchema, ZUpdateSiteSettingMutationSchema } from './schema'; +import { + ZAdminDeleteUserMutationSchema, + ZAdminFindDocumentsQuerySchema, + ZAdminResealDocumentMutationSchema, + ZAdminUpdateProfileMutationSchema, + ZAdminUpdateRecipientMutationSchema, + ZAdminUpdateSiteSettingMutationSchema, +} from './schema'; export const adminRouter = router({ + findDocuments: adminProcedure.input(ZAdminFindDocumentsQuerySchema).query(async ({ input }) => { + const { term, page, perPage } = input; + + try { + return await findDocuments({ term, page, perPage }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to retrieve the documents. Please try again.', + }); + } + }), + updateUser: adminProcedure - .input(ZUpdateProfileMutationByAdminSchema) + .input(ZAdminUpdateProfileMutationSchema) .mutation(async ({ input }) => { const { id, name, email, roles } = input; @@ -22,8 +47,23 @@ export const adminRouter = router({ } }), + updateRecipient: adminProcedure + .input(ZAdminUpdateRecipientMutationSchema) + .mutation(async ({ input }) => { + const { id, name, email } = input; + + try { + return await updateRecipient({ id, name, email }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to update the recipient provided.', + }); + } + }), + updateSiteSetting: adminProcedure - .input(ZUpdateSiteSettingMutationSchema) + .input(ZAdminUpdateSiteSettingMutationSchema) .mutation(async ({ ctx, input }) => { try { const { id, enabled, data } = input; @@ -41,4 +81,41 @@ export const adminRouter = router({ }); } }), + + resealDocument: adminProcedure + .input(ZAdminResealDocumentMutationSchema) + .mutation(async ({ input }) => { + const { id } = input; + + try { + return await sealDocument({ documentId: id, isResealing: true }); + } catch (err) { + console.log('resealDocument error', err); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to reseal the document provided.', + }); + } + }), + + deleteUser: adminProcedure.input(ZAdminDeleteUserMutationSchema).mutation(async ({ input }) => { + const { id, email } = input; + + try { + const user = await getUserById({ id }); + + if (user.email !== email) { + throw new Error('Email does not match'); + } + + return await deleteUser({ id }); + } catch (err) { + console.log(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to delete the specified account. Please try again.', + }); + } + }), }); diff --git a/packages/trpc/server/admin-router/schema.ts b/packages/trpc/server/admin-router/schema.ts index 0b99c8372..cfedb06ba 100644 --- a/packages/trpc/server/admin-router/schema.ts +++ b/packages/trpc/server/admin-router/schema.ts @@ -3,17 +3,48 @@ import z from 'zod'; import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema'; -export const ZUpdateProfileMutationByAdminSchema = z.object({ +export const ZAdminFindDocumentsQuerySchema = z.object({ + term: z.string().optional(), + page: z.number().optional().default(1), + perPage: z.number().optional().default(20), +}); + +export type TAdminFindDocumentsQuerySchema = z.infer; + +export const ZAdminUpdateProfileMutationSchema = z.object({ id: z.number().min(1), name: z.string().nullish(), email: z.string().email().optional(), roles: z.array(z.nativeEnum(Role)).optional(), }); -export type TUpdateProfileMutationByAdminSchema = z.infer< - typeof ZUpdateProfileMutationByAdminSchema +export type TAdminUpdateProfileMutationSchema = z.infer; + +export const ZAdminUpdateRecipientMutationSchema = z.object({ + id: z.number().min(1), + name: z.string().optional(), + email: z.string().email().optional(), +}); + +export type TAdminUpdateRecipientMutationSchema = z.infer< + typeof ZAdminUpdateRecipientMutationSchema >; -export const ZUpdateSiteSettingMutationSchema = ZSiteSettingSchema; +export const ZAdminUpdateSiteSettingMutationSchema = ZSiteSettingSchema; -export type TUpdateSiteSettingMutationSchema = z.infer; +export type TAdminUpdateSiteSettingMutationSchema = z.infer< + typeof ZAdminUpdateSiteSettingMutationSchema +>; + +export const ZAdminResealDocumentMutationSchema = z.object({ + id: z.number().min(1), +}); + +export type TAdminResealDocumentMutationSchema = z.infer; + +export const ZAdminDeleteUserMutationSchema = z.object({ + id: z.number().min(1), + email: z.string().email(), +}); + +export type TAdminDeleteUserMutationSchema = z.infer; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index f9f409aa6..542ac2807 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -207,9 +207,9 @@ export const profileRouter = router({ deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => { try { - const user = ctx.user; - - return await deleteUser(user); + return await deleteUser({ + id: ctx.user.id, + }); } catch (err) { let message = 'We were unable to delete your account. Please try again.'; From a17d4a2a39b33bbfe34dd89a0fe4987f4547d5a4 Mon Sep 17 00:00:00 2001 From: Mythie Date: Sun, 3 Mar 2024 11:36:28 +1100 Subject: [PATCH 042/299] fix: handle signature annotations --- .../lib/server-only/document/seal-document.ts | 27 +++++++++++++++++-- .../signing/helpers/addSigningPlaceholder.ts | 16 ++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index dd427dc95..95b7d9dc4 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -2,7 +2,7 @@ import { nanoid } from 'nanoid'; import path from 'node:path'; -import { PDFDocument } from 'pdf-lib'; +import { PDFDocument, PDFSignature, rectangle } from 'pdf-lib'; import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; @@ -91,8 +91,31 @@ export const sealDocument = async ({ const doc = await PDFDocument.load(pdfData); + const form = doc.getForm(); + + // Remove old signatures + for (const field of form.getFields()) { + if (field instanceof PDFSignature) { + field.acroField.getWidgets().forEach((widget) => { + widget.ensureAP(); + + try { + widget.getNormalAppearance(); + } catch (e) { + const { context } = widget.dict; + + const xobj = context.formXObject([rectangle(0, 0, 0, 0)]); + + const streamRef = context.register(xobj); + + widget.setNormalAppearance(streamRef); + } + }); + } + } + // Flatten the form to stop annotation layers from appearing above documenso fields - doc.getForm().flatten(); + form.flatten(); for (const field of fields) { await insertFieldInPDF(doc, field); diff --git a/packages/signing/helpers/addSigningPlaceholder.ts b/packages/signing/helpers/addSigningPlaceholder.ts index 6c7bb18e3..211a534ea 100644 --- a/packages/signing/helpers/addSigningPlaceholder.ts +++ b/packages/signing/helpers/addSigningPlaceholder.ts @@ -1,5 +1,13 @@ import signer from 'node-signpdf'; -import { PDFArray, PDFDocument, PDFHexString, PDFName, PDFNumber, PDFString } from 'pdf-lib'; +import { + PDFArray, + PDFDocument, + PDFHexString, + PDFName, + PDFNumber, + PDFString, + rectangle, +} from 'pdf-lib'; export type AddSigningPlaceholderOptions = { pdf: Buffer; @@ -39,6 +47,12 @@ export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOption P: pages[0].ref, }); + const xobj = widget.context.formXObject([rectangle(0, 0, 0, 0)]); + + const streamRef = widget.context.register(xobj); + + widget.set(PDFName.of('AP'), widget.context.obj({ N: streamRef })); + const widgetRef = doc.context.register(widget); let widgets = pages[0].node.get(PDFName.of('Annots')); From 691255da3fcb77e96c6646d91aa71197d34bc48b Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Tue, 5 Mar 2024 14:57:29 +0530 Subject: [PATCH 043/299] chore: updated styling of tooltips and margins Signed-off-by: Adithya Krishna --- .../src/app/(marketing)/open/monthly-new-users-chart.tsx | 5 ++++- .../src/app/(marketing)/open/monthly-total-users-chart.tsx | 5 ++++- apps/marketing/src/app/(marketing)/open/typefully.tsx | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) 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 b96bbf50d..0df73e30c 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 @@ -14,7 +14,7 @@ export type MonthlyNewUsersChartProps = { export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartProps) => { const formattedData = [...data].reverse().map(({ month, count }) => { return { - month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL'), + month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'), count: Number(count), }; }); @@ -32,6 +32,9 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr [Number(value).toLocaleString('en-US'), 'New Users']} cursor={{ fill: 'hsl(var(--primary) / 10%)' }} /> 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 e31bb9def..96ce34556 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 @@ -14,7 +14,7 @@ export type MonthlyTotalUsersChartProps = { export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersChartProps) => { const formattedData = [...data].reverse().map(({ month, cume_count: count }) => { return { - month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL'), + month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'), count: Number(count), }; }); @@ -32,6 +32,9 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha [Number(value).toLocaleString('en-US'), 'Total Users']} cursor={{ fill: 'hsl(var(--primary) / 10%)' }} /> diff --git a/apps/marketing/src/app/(marketing)/open/typefully.tsx b/apps/marketing/src/app/(marketing)/open/typefully.tsx index 1a927d4d6..809b0f6c3 100644 --- a/apps/marketing/src/app/(marketing)/open/typefully.tsx +++ b/apps/marketing/src/app/(marketing)/open/typefully.tsx @@ -22,7 +22,7 @@ export const Typefully = ({ className, ...props }: TypefullyProps) => {

Twitter Stats

-
+
{!isSSR && (
Twitter Logo From a03ce728f35791d09eafc13a4420052fe33c47b0 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Tue, 5 Mar 2024 15:34:42 +0530 Subject: [PATCH 044/299] chore: remove ssr Signed-off-by: Adithya Krishna --- .../src/app/(marketing)/open/typefully.tsx | 36 ++++++++----------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/open/typefully.tsx b/apps/marketing/src/app/(marketing)/open/typefully.tsx index 809b0f6c3..98f4186d0 100644 --- a/apps/marketing/src/app/(marketing)/open/typefully.tsx +++ b/apps/marketing/src/app/(marketing)/open/typefully.tsx @@ -1,7 +1,6 @@ 'use client'; import type { HTMLAttributes } from 'react'; -import { useEffect, useState } from 'react'; import Image from 'next/image'; import Link from 'next/link'; @@ -13,34 +12,27 @@ import { Button } from '@documenso/ui/primitives/button'; export type TypefullyProps = HTMLAttributes; export const Typefully = ({ className, ...props }: TypefullyProps) => { - const [isSSR, setIsSSR] = useState(true); - - useEffect(() => { - setIsSSR(false); - }, []); return (

Twitter Stats

- {!isSSR && ( -
- Twitter Logo +
+ Twitter Logo + +

Documenso on X

+ + - -
- )} + + +
); From e83a4beceeb9dbcf46b8000920a4c3a40d2bb815 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Tue, 5 Mar 2024 15:41:34 +0530 Subject: [PATCH 045/299] chore: update twitter logo Signed-off-by: Adithya Krishna --- apps/marketing/src/app/(marketing)/open/typefully.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/open/typefully.tsx b/apps/marketing/src/app/(marketing)/open/typefully.tsx index 98f4186d0..a233904db 100644 --- a/apps/marketing/src/app/(marketing)/open/typefully.tsx +++ b/apps/marketing/src/app/(marketing)/open/typefully.tsx @@ -2,10 +2,10 @@ import type { HTMLAttributes } from 'react'; -import Image from 'next/image'; import Link from 'next/link'; -import Twitter from '@documenso/assets/twitter-icon.png'; +import { FaXTwitter } from 'react-icons/fa6'; + import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -17,8 +17,8 @@ export const Typefully = ({ className, ...props }: TypefullyProps) => {

Twitter Stats

-
- Twitter Logo +
+

Documenso on X

From 41ccefe212e829b85091c3dd6b0481c2353d82a7 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Tue, 5 Mar 2024 15:43:33 +0530 Subject: [PATCH 046/299] chore: remove twt logo asset Signed-off-by: Adithya Krishna --- packages/assets/twitter-icon.png | Bin 14293 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 packages/assets/twitter-icon.png diff --git a/packages/assets/twitter-icon.png b/packages/assets/twitter-icon.png deleted file mode 100644 index f1da461c2d3a56e5772bce09065abaa8d25a8fbe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14293 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 Date: Tue, 5 Mar 2024 22:06:48 +1100 Subject: [PATCH 047/299] feat: add offline development support (#987) ## Description Add support to develop without network access since TRPC by default will prevent network requests when offline. https://tanstack.com/query/v4/docs/framework/react/guides/network-mode#network-mode-always ## Changes Made - Add dynamic logic to toggle offline development - Removed teams feature flag --- .../components/(dashboard)/layout/header.tsx | 34 ---- .../(dashboard)/layout/profile-dropdown.tsx | 177 ------------------ .../settings/layout/desktop-nav.tsx | 27 ++- .../settings/layout/mobile-nav.tsx | 27 ++- packages/lib/constants/feature-flags.ts | 1 - packages/trpc/react/index.tsx | 23 ++- 6 files changed, 46 insertions(+), 243 deletions(-) delete mode 100644 apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index b0ede5b8b..eebfa4b02 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -7,7 +7,6 @@ import { useParams } from 'next/navigation'; import { MenuIcon, SearchIcon } from 'lucide-react'; -import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; import { getRootHref } from '@documenso/lib/utils/params'; import type { User } from '@documenso/prisma/client'; @@ -19,7 +18,6 @@ import { CommandMenu } from '../common/command-menu'; import { DesktopNav } from './desktop-nav'; import { MenuSwitcher } from './menu-switcher'; import { MobileNavigation } from './mobile-navigation'; -import { ProfileDropdown } from './profile-dropdown'; export type HeaderProps = HTMLAttributes & { user: User; @@ -29,10 +27,6 @@ export type HeaderProps = HTMLAttributes & { export const Header = ({ className, user, teams, ...props }: HeaderProps) => { const params = useParams(); - const { getFlag } = useFeatureFlags(); - - const isTeamsEnabled = getFlag('app_teams'); - const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false); const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false); const [scrollY, setScrollY] = useState(0); @@ -47,34 +41,6 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => { return () => window.removeEventListener('scroll', onScroll); }, []); - if (!isTeamsEnabled) { - return ( -
5 && 'border-b-border', - className, - )} - {...props} - > -
- - - - - - -
- -
-
-
- ); - } - return (
{ - const { getFlag } = useFeatureFlags(); - const { theme, setTheme } = useTheme(); - const isUserAdmin = isAdmin(user); - - const isBillingEnabled = getFlag('app_billing'); - - const avatarFallback = user.name - ? extractInitials(user.name) - : user.email.slice(0, 1).toUpperCase(); - - return ( - - - - - - - Account - - {isUserAdmin && ( - <> - - - - Admin - - - - - - )} - - - - - Profile - - - - - - - Security - - - - - - - API Tokens - - - - {isBillingEnabled && ( - - - - Billing - - - )} - - - - - - Templates - - - - - - - - Themes - - - - - - Light - - - - Dark - - - - System - - - - - - - - - - Star on Github - - - - - - - void signOut({ - callbackUrl: '/', - }) - } - > - - Sign Out - - - - ); -}; diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index 6109d1f3d..94e366e27 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -19,7 +19,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { const { getFlag } = useFeatureFlags(); const isBillingEnabled = getFlag('app_billing'); - const isTeamsEnabled = getFlag('app_teams'); return (
@@ -36,20 +35,18 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { - {isTeamsEnabled && ( - - - - )} + + + - {isTeamsEnabled && ( - - - - )} + + + + )} diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css index 58dbc892d..8e488ad95 100644 --- a/packages/ui/styles/theme.css +++ b/packages/ui/styles/theme.css @@ -42,6 +42,8 @@ --ring: 95.08 71.08% 67.45%; --radius: 0.5rem; + + --warning: 54 96% 45%; } .dark { @@ -79,6 +81,8 @@ --ring: 95.08 71.08% 67.45%; --radius: 0.5rem; + + --warning: 54 96% 45%; } } From 0c426983bbc4a01d83663d538fbf6273a73c4475 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Thu, 7 Mar 2024 19:28:35 +0530 Subject: [PATCH 071/299] chore: updated initial animation state Signed-off-by: Adithya Krishna --- .../ui/lib/document-dropzone-constants.ts | 8 +-- packages/ui/primitives/document-dropzone.tsx | 63 +++++++++---------- 2 files changed, 35 insertions(+), 36 deletions(-) diff --git a/packages/ui/lib/document-dropzone-constants.ts b/packages/ui/lib/document-dropzone-constants.ts index 2f76b8ceb..6117f0c7a 100644 --- a/packages/ui/lib/document-dropzone-constants.ts +++ b/packages/ui/lib/document-dropzone-constants.ts @@ -68,7 +68,7 @@ export const DocumentDropzoneCardCenterVariants: Variants = { export const DocumentDropzoneDisabledCardLeftVariants: Variants = { initial: { x: 40, - y: 30, + y: 0, rotate: -14, }, animate: { @@ -85,8 +85,8 @@ export const DocumentDropzoneDisabledCardLeftVariants: Variants = { export const DocumentDropzoneDisabledCardRightVariants: Variants = { initial: { - x: -40, - y: 30, + x: -50, + y: 5, rotate: 14, }, animate: { @@ -103,7 +103,7 @@ export const DocumentDropzoneDisabledCardRightVariants: Variants = { export const DocumentDropzoneDisabledCardCenterVariants: Variants = { initial: { - x: 20, + x: -10, y: 0, }, animate: { diff --git a/packages/ui/primitives/document-dropzone.tsx b/packages/ui/primitives/document-dropzone.tsx index 916798b20..e4ca84892 100644 --- a/packages/ui/primitives/document-dropzone.tsx +++ b/packages/ui/primitives/document-dropzone.tsx @@ -69,26 +69,25 @@ export const DocumentDropzone = ({ }); return ( - - - + + {disabled ? ( // Disabled State
@@ -152,21 +151,21 @@ export const DocumentDropzone = ({
)} +
- + -

{DocumentDescription[type].headline}

+

{DocumentDescription[type].headline}

-

- {disabled ? disabledMessage : 'Drag & drop your PDF here.'} -

- {disabled && ( - - )} -
-
-
+

+ {disabled ? disabledMessage : 'Drag & drop your PDF here.'} +

+ {disabled && ( + + )} + + ); }; From f7e7c6dedf865ed9b5187d238f38d64c658dce97 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Thu, 7 Mar 2024 20:08:11 +0530 Subject: [PATCH 072/299] fix: overflow issue --- apps/web/src/components/forms/v2/signup.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/forms/v2/signup.tsx b/apps/web/src/components/forms/v2/signup.tsx index bf1814588..d15b50473 100644 --- a/apps/web/src/components/forms/v2/signup.tsx +++ b/apps/web/src/components/forms/v2/signup.tsx @@ -360,7 +360,7 @@ export const SignUpFormV2 = ({ {step === 'CLAIM_USERNAME' && (
-
+
{baseUrl.host}/u/{field.value || ''}
From 6c9303012cfedc13bbf638186c22b9112d60921e Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Thu, 7 Mar 2024 21:06:16 +0530 Subject: [PATCH 073/299] chore: updated animation Signed-off-by: Adithya Krishna --- packages/ui/lib/document-dropzone-constants.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/lib/document-dropzone-constants.ts b/packages/ui/lib/document-dropzone-constants.ts index 6117f0c7a..7d611f24a 100644 --- a/packages/ui/lib/document-dropzone-constants.ts +++ b/packages/ui/lib/document-dropzone-constants.ts @@ -79,7 +79,7 @@ export const DocumentDropzoneDisabledCardLeftVariants: Variants = { hover: { x: 30, y: 0, - transition: { type: 'spring', duration: 0.25, bounce: 0.5 }, + transition: { type: 'spring', duration: 0.3, stiffness: 500 }, }, }; @@ -97,7 +97,7 @@ export const DocumentDropzoneDisabledCardRightVariants: Variants = { hover: { x: -40, y: 5, - transition: { type: 'spring', duration: 0.25, bounce: 0.5 }, + transition: { type: 'spring', duration: 0.3, stiffness: 500 }, }, }; @@ -114,5 +114,6 @@ export const DocumentDropzoneDisabledCardCenterVariants: Variants = { x: -20, y: 0, rotate: -5, + transition: { type: 'spring', duration: 0.3, stiffness: 1000 }, }, }; From 3ce5b9e0a02436f32254259310cd94f19db641dc Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Fri, 8 Mar 2024 00:33:15 +0530 Subject: [PATCH 074/299] chore: updated code_of_conduct link Signed-off-by: Adithya Krishna --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f70e7425a..5a85e0a81 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ open in devcontainer - Contributor Covenant + Contributor Covenant

From 92f44cd3044d183ec39579c17940384ddac46883 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Fri, 8 Mar 2024 00:36:18 +0530 Subject: [PATCH 075/299] chore: remove trailing slash Signed-off-by: Adithya Krishna --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5a85e0a81..93c6d9f95 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ open in devcontainer - Contributor Covenant + Contributor Covenant

From 59cdf3203e39fc0a7204d2e885c3a9d729f69f1c Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 7 Mar 2024 20:09:29 +0000 Subject: [PATCH 076/299] fix: add seed script to dx setup --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c25aed514..8ff557e9d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "commitlint": "commitlint --edit", "clean": "turbo run clean && rimraf node_modules", "d": "npm run dx && npm run dev", - "dx": "npm i && npm run dx:up && npm run prisma:migrate-dev", + "dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed", "dx:up": "docker compose -f docker/development/compose.yml up -d", "dx:down": "docker compose -f docker/development/compose.yml down", "ci": "turbo run test:e2e", From e47ca1d6b6168fb546eacd60d99bf0697c7a8d64 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 8 Mar 2024 00:04:27 +0000 Subject: [PATCH 077/299] chore: add e2e test for deleting a user --- .../profile/delete-account-dialog.tsx | 5 ++++- .../app-tests/e2e/test-delete-user.spec.ts | 21 +++++++++++++++++++ packages/prisma/package.json | 3 ++- packages/prisma/seed/users.ts | 21 +++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 packages/app-tests/e2e/test-delete-user.spec.ts diff --git a/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx b/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx index 933b37f31..4db0ba01a 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx @@ -78,7 +78,9 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
- + @@ -110,6 +112,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp onClick={onDeleteAccount} loading={isDeletingAccount} variant="destructive" + data-testid="delete-account-confirmation-button" disabled={hasTwoFactorAuthentication} > {isDeletingAccount ? 'Deleting account...' : 'Delete Account'} diff --git a/packages/app-tests/e2e/test-delete-user.spec.ts b/packages/app-tests/e2e/test-delete-user.spec.ts new file mode 100644 index 000000000..acda3f0fc --- /dev/null +++ b/packages/app-tests/e2e/test-delete-user.spec.ts @@ -0,0 +1,21 @@ +import { test } from '@playwright/test'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { manualLogin } from './fixtures/authentication'; + +test('delete user', async ({ page }) => { + const user = await seedUser(); + + await manualLogin({ + page, + email: user.email, + redirectPath: '/settings', + }); + + await page.getByTestId('delete-account-button').click(); + await page.getByTestId('delete-account-confirmation-button').click(); + + await page.waitForURL(`${WEBAPP_BASE_URL}/signin`); +}); diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 0cd3ed282..62248ffc0 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -12,7 +12,8 @@ "prisma:generate": "prisma generate", "prisma:migrate-dev": "prisma migrate dev", "prisma:migrate-deploy": "prisma migrate deploy", - "prisma:seed": "prisma db seed" + "prisma:seed": "prisma db seed", + "prisma:studio": "prisma studio" }, "prisma": { "seed": "ts-node --transpileOnly --project ./tsconfig.seed.json ./seed-database.ts" diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts index 353683a1d..647b93736 100644 --- a/packages/prisma/seed/users.ts +++ b/packages/prisma/seed/users.ts @@ -26,6 +26,27 @@ export const seedUser = async ({ }); }; +export const seed2faUser = async ({ + name = `2fa-user-${Date.now()}`, + email = `2fa-user-${Date.now()}@test.documenso.com`, + password = 'password', + verified = true, +}: SeedUserOptions = {}) => { + return await prisma.user.create({ + data: { + name, + email, + password: hashSync(password), + emailVerified: verified ? new Date() : undefined, + url: name, + twoFactorEnabled: true, + twoFactorSecret: + 'b2840b216b1f089cb086bdd4260196c645d90b0bd3ff8f66d20d19b99a0da1631bf299e416476917194f1064f58b', + twoFactorBackupCodes: 'a-bunch-of-backup-codes', + }, + }); +}; + export const unseedUser = async (userId: number) => { await prisma.user.delete({ where: { From ff3b49656cf3a001076ddc94ed047663249b965a Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 8 Mar 2024 00:07:11 +0000 Subject: [PATCH 078/299] chore: remove unused function --- packages/prisma/seed/users.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts index 647b93736..353683a1d 100644 --- a/packages/prisma/seed/users.ts +++ b/packages/prisma/seed/users.ts @@ -26,27 +26,6 @@ export const seedUser = async ({ }); }; -export const seed2faUser = async ({ - name = `2fa-user-${Date.now()}`, - email = `2fa-user-${Date.now()}@test.documenso.com`, - password = 'password', - verified = true, -}: SeedUserOptions = {}) => { - return await prisma.user.create({ - data: { - name, - email, - password: hashSync(password), - emailVerified: verified ? new Date() : undefined, - url: name, - twoFactorEnabled: true, - twoFactorSecret: - 'b2840b216b1f089cb086bdd4260196c645d90b0bd3ff8f66d20d19b99a0da1631bf299e416476917194f1064f58b', - twoFactorBackupCodes: 'a-bunch-of-backup-codes', - }, - }); -}; - export const unseedUser = async (userId: number) => { await prisma.user.delete({ where: { From f0fd5506fcd901a8701014b6ea713b40445da20b Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 8 Mar 2024 12:49:55 +1100 Subject: [PATCH 079/299] fix: skip seeding when running migrate dev When prisma:migrate-dev needs to reset the database it will run the seed script to repopulate data. Now that we've added the seed script to our root setup command we will want to avoid this behaviour since we will end up double seeding the database which currently can cause issues. --- packages/prisma/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 0cd3ed282..59c59bc67 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -10,7 +10,7 @@ "clean": "rimraf node_modules", "post-install": "prisma generate", "prisma:generate": "prisma generate", - "prisma:migrate-dev": "prisma migrate dev", + "prisma:migrate-dev": "prisma migrate dev --skip-seed", "prisma:migrate-deploy": "prisma migrate deploy", "prisma:seed": "prisma db seed" }, From ee35b4a24b2a380396d1540fbe74f245a386032b Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 8 Mar 2024 02:45:22 +0000 Subject: [PATCH 080/299] fix: update test to use getByRole --- .../settings/profile/delete-account-dialog.tsx | 2 +- package.json | 2 +- packages/app-tests/e2e/test-delete-user.spec.ts | 10 +++++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx b/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx index 4db0ba01a..8663aeea6 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx @@ -115,7 +115,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp data-testid="delete-account-confirmation-button" disabled={hasTwoFactorAuthentication} > - {isDeletingAccount ? 'Deleting account...' : 'Delete Account'} + {isDeletingAccount ? 'Deleting account...' : 'Confirm Deletion'} diff --git a/package.json b/package.json index 8ff557e9d..cbaa2a1eb 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma", "prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma", "prisma:seed": "npm run with:env -- npm run prisma:seed -w @documenso/prisma", - "prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma", + "prisma:studio": "npm run with:env -- npm run prisma:studio -w @documenso/prisma", "with:env": "dotenv -e .env -e .env.local --", "reset:hard": "npm run clean && npm i && npm run prisma:generate", "precommit": "npm install && git add package.json package-lock.json" diff --git a/packages/app-tests/e2e/test-delete-user.spec.ts b/packages/app-tests/e2e/test-delete-user.spec.ts index acda3f0fc..beae6eb09 100644 --- a/packages/app-tests/e2e/test-delete-user.spec.ts +++ b/packages/app-tests/e2e/test-delete-user.spec.ts @@ -1,6 +1,7 @@ -import { test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { seedUser } from '@documenso/prisma/seed/users'; import { manualLogin } from './fixtures/authentication'; @@ -14,8 +15,11 @@ test('delete user', async ({ page }) => { redirectPath: '/settings', }); - await page.getByTestId('delete-account-button').click(); - await page.getByTestId('delete-account-confirmation-button').click(); + await page.getByRole('button', { name: 'Delete Account' }).click(); + await page.getByRole('button', { name: 'Confirm Deletion' }).click(); await page.waitForURL(`${WEBAPP_BASE_URL}/signin`); + + // Verify that the user no longer exists in the database + await expect(getUserByEmail({ email: user.email })).rejects.toThrow(); }); From 3b3346e6affed0d5a62a38e91119c5d7bc621ce4 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 8 Mar 2024 13:47:59 +1100 Subject: [PATCH 081/299] fix: remove data-testid attributes --- .../(dashboard)/settings/profile/delete-account-dialog.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx b/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx index 8663aeea6..e9cc885e9 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx @@ -78,9 +78,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
- + @@ -112,7 +110,6 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp onClick={onDeleteAccount} loading={isDeletingAccount} variant="destructive" - data-testid="delete-account-confirmation-button" disabled={hasTwoFactorAuthentication} > {isDeletingAccount ? 'Deleting account...' : 'Confirm Deletion'} From e9664d6369860db68f8fcf632b54d6502f1e2905 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 8 Mar 2024 03:23:27 +0000 Subject: [PATCH 082/299] chore: tidy code --- .../ui/lib/document-dropzone-constants.ts | 17 ++-- packages/ui/primitives/document-dropzone.tsx | 80 +++++++++---------- 2 files changed, 48 insertions(+), 49 deletions(-) diff --git a/packages/ui/lib/document-dropzone-constants.ts b/packages/ui/lib/document-dropzone-constants.ts index 7d611f24a..dd12acf97 100644 --- a/packages/ui/lib/document-dropzone-constants.ts +++ b/packages/ui/lib/document-dropzone-constants.ts @@ -67,18 +67,19 @@ export const DocumentDropzoneCardCenterVariants: Variants = { export const DocumentDropzoneDisabledCardLeftVariants: Variants = { initial: { - x: 40, + x: 50, y: 0, rotate: -14, }, animate: { - x: 40, + x: 50, y: 0, rotate: -14, }, hover: { x: 30, y: 0, + rotate: -17, transition: { type: 'spring', duration: 0.3, stiffness: 500 }, }, }; @@ -86,17 +87,18 @@ export const DocumentDropzoneDisabledCardLeftVariants: Variants = { export const DocumentDropzoneDisabledCardRightVariants: Variants = { initial: { x: -50, - y: 5, + y: 0, rotate: 14, }, animate: { x: -50, - y: 5, + y: 0, rotate: 14, }, hover: { - x: -40, - y: 5, + x: -30, + y: 0, + rotate: 17, transition: { type: 'spring', duration: 0.3, stiffness: 500 }, }, }; @@ -111,9 +113,8 @@ export const DocumentDropzoneDisabledCardCenterVariants: Variants = { y: 0, }, hover: { - x: -20, + x: [-15, -10, -5, -10], y: 0, - rotate: -5, transition: { type: 'spring', duration: 0.3, stiffness: 1000 }, }, }; diff --git a/packages/ui/primitives/document-dropzone.tsx b/packages/ui/primitives/document-dropzone.tsx index e4ca84892..51c2f0141 100644 --- a/packages/ui/primitives/document-dropzone.tsx +++ b/packages/ui/primitives/document-dropzone.tsx @@ -6,7 +6,7 @@ import { motion } from 'framer-motion'; import { AlertTriangle, Plus } from 'lucide-react'; import { useDropzone } from 'react-dropzone'; -import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; +import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { @@ -37,18 +37,10 @@ export const DocumentDropzone = ({ onDrop, onDropRejected, disabled, - disabledMessage = 'You can upload up to 5 documents per month on your current plan.', + disabledMessage = 'You cannot upload documents at this time.', type = 'document', ...props }: DocumentDropzoneProps) => { - const DocumentDescription = { - document: { - headline: disabled ? 'You have reached your document limit.' : 'Add a document', - }, - template: { - headline: 'Upload Template Document', - }, - }; const { getRootProps, getInputProps } = useDropzone({ accept: { 'application/pdf': ['.pdf'], @@ -68,26 +60,31 @@ export const DocumentDropzone = ({ maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT), }); + const heading = { + document: disabled ? 'You have reached your document limit.' : 'Add a document', + template: 'Upload Template Document', + }; + return ( - - - + + {disabled ? ( // Disabled State
@@ -151,21 +148,22 @@ export const DocumentDropzone = ({
)} -
- + -

{DocumentDescription[type].headline}

+

{heading[type]}

-

- {disabled ? disabledMessage : 'Drag & drop your PDF here.'} -

- {disabled && ( - - )} -
-
+

+ {disabled ? disabledMessage : 'Drag & drop your PDF here.'} +

+ + {disabled && IS_BILLING_ENABLED() && ( + + )} + + + ); }; From bc60278bac0f647ae3da072feb7a012a06155272 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 8 Mar 2024 03:30:57 +0000 Subject: [PATCH 083/299] fix: remove useless ternaries --- packages/ui/primitives/document-dropzone.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ui/primitives/document-dropzone.tsx b/packages/ui/primitives/document-dropzone.tsx index 51c2f0141..c0006a5f6 100644 --- a/packages/ui/primitives/document-dropzone.tsx +++ b/packages/ui/primitives/document-dropzone.tsx @@ -90,7 +90,7 @@ export const DocumentDropzone = ({
@@ -99,7 +99,7 @@ export const DocumentDropzone = ({
@@ -121,7 +121,7 @@ export const DocumentDropzone = ({
@@ -130,7 +130,7 @@ export const DocumentDropzone = ({
From ddfd4b9e1b1198ce392c52cf5781d850891e2ae6 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 8 Mar 2024 03:59:15 +0000 Subject: [PATCH 084/299] fix: update styling --- apps/web/src/components/forms/v2/signup.tsx | 10 +++++----- apps/web/src/components/ui/user-profile-skeleton.tsx | 5 ++--- apps/web/src/components/ui/user-profile-timur.tsx | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/forms/v2/signup.tsx b/apps/web/src/components/forms/v2/signup.tsx index d15b50473..a7e33a759 100644 --- a/apps/web/src/components/forms/v2/signup.tsx +++ b/apps/web/src/components/forms/v2/signup.tsx @@ -227,9 +227,9 @@ export const SignUpFormV2 = ({
{step === 'BASIC_DETAILS' && (
-

Create a new account

+

Create a new account

-

+

Create your account and start using state-of-the-art document signing. Open and beautiful signing is within your grasp.

@@ -238,9 +238,9 @@ export const SignUpFormV2 = ({ {step === 'CLAIM_USERNAME' && (
-

Claim your username now

+

Claim your username now

-

+

You will get notified & be able to set up your documenso public profile when we launch the feature.

@@ -378,7 +378,7 @@ export const SignUpFormV2 = ({ -
+
{baseUrl.host}/u/{field.value || ''}
diff --git a/apps/web/src/components/ui/user-profile-skeleton.tsx b/apps/web/src/components/ui/user-profile-skeleton.tsx index c6936522b..c8b8b808a 100644 --- a/apps/web/src/components/ui/user-profile-skeleton.tsx +++ b/apps/web/src/components/ui/user-profile-skeleton.tsx @@ -24,9 +24,8 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk className, )} > -
- {baseUrl.host}/u/ - {user.url} +
+ {baseUrl.host}/u/{user.url}
diff --git a/apps/web/src/components/ui/user-profile-timur.tsx b/apps/web/src/components/ui/user-profile-timur.tsx index e99a314b4..8d1e517ad 100644 --- a/apps/web/src/components/ui/user-profile-timur.tsx +++ b/apps/web/src/components/ui/user-profile-timur.tsx @@ -25,7 +25,7 @@ export const UserProfileTimur = ({ className, rows = 2 }: UserProfileTimurProps) className, )} > -
+
{baseUrl.host}/u/timur
From 1b32c5a17f6125f8bfe599150ef053246699b4ab Mon Sep 17 00:00:00 2001 From: premiare <64188227+premiare@users.noreply.github.com> Date: Fri, 8 Mar 2024 18:27:30 +1100 Subject: [PATCH 085/299] fix: fix blog post date (#1003) Fixing the blog date for https://documenso.com/blog/removing-server-actions (I assume it was meant to be March 7th) ![image](https://github.com/documenso/documenso/assets/64188227/a7b96168-b094-46c0-877a-da26c9d140d4) --- apps/marketing/content/blog/removing-server-actions.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/removing-server-actions.mdx b/apps/marketing/content/blog/removing-server-actions.mdx index 7e53c5b58..36dabdf9d 100644 --- a/apps/marketing/content/blog/removing-server-actions.mdx +++ b/apps/marketing/content/blog/removing-server-actions.mdx @@ -4,7 +4,7 @@ description: 'This article talks about the need for the public API and the proce authorName: 'Lucas Smith' authorImage: '/blog/blog-author-lucas.png' authorRole: 'Co-Founder' -date: 2024-07-23 +date: 2024-03-07 tags: - Development --- From ee2cb0eedf472210c21e1a6a869692d3a770216d Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 8 Mar 2024 10:20:58 +0200 Subject: [PATCH 086/299] docs: add article about public api --- apps/marketing/content/blog/public-api.mdx | 301 ++++++++++++++++++ .../public/blog/api-implementation.webp | Bin 0 -> 56982 bytes apps/marketing/public/blog/api-package.webp | Bin 0 -> 19380 bytes .../public/blog/blog-author-catalin.webp | Bin 0 -> 18814 bytes apps/marketing/public/blog/docs.webp | Bin 0 -> 135304 bytes 5 files changed, 301 insertions(+) create mode 100644 apps/marketing/content/blog/public-api.mdx create mode 100644 apps/marketing/public/blog/api-implementation.webp create mode 100644 apps/marketing/public/blog/api-package.webp create mode 100644 apps/marketing/public/blog/blog-author-catalin.webp create mode 100644 apps/marketing/public/blog/docs.webp diff --git a/apps/marketing/content/blog/public-api.mdx b/apps/marketing/content/blog/public-api.mdx new file mode 100644 index 000000000..dd9a0b174 --- /dev/null +++ b/apps/marketing/content/blog/public-api.mdx @@ -0,0 +1,301 @@ +--- +title: 'Building the Documenso Public API - The Why and How' +description: 'This article talks about the need for the public API and the process of building it. It also discusses the requirements we had to meet and the constraints we had to work within.' +authorName: 'Catalin' +authorImage: '/blog/blog-author-catalin.webp' +authorRole: 'Developer' +date: 2024-03-08 +tags: + - Development + - API +--- + +This article covers the process of building the public API for Documenso. It starts by explaining why the API was needed for a digital document signing company in the first place. Then, it'll dive into the steps we took to build it. Lastly, it'll present the requirements we had to meet and the constraints we had to work within. + +## Why the public API + +We decided to build the public API to open a new way of interacting with Documenso. While the web app does the job well, there are use cases where it's not enough. In those cases, the users might want to interact with the platform programmatically. Usually, that's for integrating Documenso with other applications. + +The new public API enables them to do that. The users can integrate Documenso's functionalities within other applications to automate tasks, create custom solutions, and build custom workflows, to name just a few. + +The API provides 12 endpoints at the time of writing this article: + +- (GET) `/api/v1/documents` - retrieve all the documents +- (POST) `/api/v1/documents` - upload a new document and getting a presigned URL +- (GET) `/api/v1/documents/{id}` - fetch a specific document +- (DELETE) `/api/v1/documents/{id}` - delete a specific document +- (POST) `/api/v1/templates/{templateId}/create-document` - create a new document from an existing template +- (POST) `/api/v1/documents/{id}/send` - send a document for signing +- (POST) `/api/v1/documents/{id}/recipients` - create a document recipient +- (PATCH) `/api/v1/documents/{id}/recipients/{recipientId}` - update the details of a document recipient +- (DELETE) `/api/v1/documents/{id}/recipients/{recipientId}` - delete a specific recipient from a document +- (POST) `/api/v1/documents/{id}/fields` - create a field for a document +- (PATCH) `/api/v1/documents/{id}/fields` - update the details of a document field +- (DELETE) `/api/v1/documents/{id}/fields` - delete a field from a document + +> Check out the [API documentation](https://app.documenso.com/api/v1/openapi). + +Moreover, it also enables us to enhance the platform by bringing other integrations to Documenso, such as Zapier. + +In conclusion, the new public API extends Documenso's capabilities, provides more flexibility for users, and opens up a broader world of possibilities. + +## Picking the right approach & tech + +Once we decided to build the API, we had to choose the approach and technologies to use. There were 2 options: + +1. Build an additional application +2. Launch the API in the existing codebase + +### 1. Build an additional application + +That would mean creating a new codebase and building the API from scratch. Having a separate app for the API would result in benefits such as: + +- lower latency responses +- supporting larger field uploads +- separation between the apps (Documenso and the API) +- customizability and flexibility +- easier testing and debugging + +This approach has significant benefits. However, one major drawback is that it requires additional resources. + +We'd have to spend a lot of time just on the core stuff, such as building and configuring the basic server. After that, we'd spend time implementing the endpoints and authorization, among other things. + +When the building is done, there will be another application to deploy and manage. + +All of this would stretch our already limited resources. + +So, we asked ourselves if there is another way of doing it without sacrificing the API quality and the developer experience. + +### 2. Launch the API in the existing codebase + +The other option was to launch the API in the existing codebase. Rather than writing everything from scratch, we could use most of our existing code. + +Since we're using tRPC for our internal API (backend), we looked for solutions that work well with tRPC. We narrowed down the choices to: + +- [trpc-openapi](https://github.com/jlalmes/trpc-openapi) +- [ts-rest](https://ts-rest.com/) + +Both technologies allow you to build public APIs. The `trpc-openapi` technology allows you to easily turn tRPC procedures into REST endpoints. It's more like a plugin for tRPC. + +On the other hand, `ts-rest` is more of a standalone solution. `ts-rest` enables you to create a contract for the API, which can be used both on the client and server. You can consume and implement the contract in your application, thus providing end-to-end type safety and RPC-like client. + +> You can see a [comparison between trpc-openapi and ts-rest](https://catalins.tech/public-api-trpc/) here. + +So, the main difference between the 2 is that `trpc-openapi` is like a plugin that extends tRPC's capabilities, whereas `ts-rest` provides the tools for building a standalone API. + +### Our choice + +After analyzing and comparing the 2 options, we decided to go with `ts-rest` because of its benefits. + +Here's a paragraph from the `ts-rest` documentation that hits the nail on the head: + +> tRPC has many plugins to solve this issue by mapping the API implementation to a REST-like API, however, these approaches are often a bit clunky and reduce the safety of the system overall, ts-rest does this heavy lifting in the client and server implementations rather than requiring a second layer of abstraction and API endpoint(s) to be defined. + +## API Requirements + +We defined the following requirements for the API: + +- The API should use path-based versioning (e.g. `/v1`) +- The system should use bearer tokens for API authentication + - The API token should be a random string of 32 to 40 characters +- The system should encrypt the token and store the encrypted value +- The system should only display the API token when it's created +- The API should have self-generated documentation like Swagger +- Users should be able to create an API key + - Users should be able to choose a token name + - Users should be able to choose an expiration date for the token + - User should be able to choose between 7 days, 1 month, 3 months, 6 months, 12 months, never +- System should display all the user's tokens in the settings page + - System should display the token name, creation date, expiration date and a delete button +- Users should be able to delete an API key +- Users should be able to retrieve all the documents from their account +- Users should be able to upload a new document + - Users should receive an S3 pre-signed URL after a successful upload +- Users should be able to retrieve a specific document from their account by its id +- Users should be able to delete a specific document from their account by its id +- Users should be able to create a new document from an existing document template +- Users should be able to send a document for signing to 1 or more recipients +- Users should be able to create a recipient for a document +- Users should be able to update the details of a recipient +- Users should be able to delete a recipient from a document +- Users should be able to create a field (e.g. signature, email, name, date) for a document +- Users should be able to update a field for a document +- Users should be able to delete a field from a document + +## Constraints + +We also faced the following constraints while developing the API: + +1. Resources + +Limited resources were one of the main constraints. We're a new startup with a relatively small team. Building and maintaining an additional application would strain our limited resources. + +2. Technology stack + +Another constraint was the technology stack. Our tech stack includes TypeScript, Prisma, and tRPC, among others. We also use Vercel for hosting. + +As a result, we wanted to use technologies we are comfortable with. This allowed us to leverage our existing knowledge and ensured consistency across our applications. + +Using familiar technologies also meant we could develop the API faster, as we didn't have to spend time learning new technologies. We could also leverage existing code and tools used in our main application. + +It's worth mentioning that this is not a permanent decision. We're open to moving the API to another codebase/tech stack when it makes sense (e.g. API is heavily used and needs better performance). + +3. File uploads + +Due to our current architecture, we support file uploads with a maximum size of 50 MB. To circumvent this, we created an additional step for uploading documents. + +Users make a POST request to the `/api/v1/documents` endpoint and the API responds with an S3 pre-signed URL. The users then make a 2nd request to the pre-signed URL with their document. + +## How we built the API + +![API package diagram](api-package.webp) + +Our codebase is a monorepo, so we created a new API package in the `packages` directory. It contains both the API implementation and its documentation. + +The main 2 blocks of the implementation consist of the API contract and the code for the API endpoints. + +![API implementation diagram](api-implementation.webp) + +In a few words, the API contract defines the API structure, the format of the requests and responses, how to authenticate API calls, the available endpoints and their associated HTTP verbs. You can explore the [API contract](https://github.com/documenso/documenso/blob/main/packages/api/v1/contract.ts) on GitHub. + +Then, there's the implementation part, which is the actual code for each endpoint defined in the API contract. The implementation is where the API contract is brought to life and made functional. + +Let's take the endpoint `/api/v1/documents` as an example. + +```ts +export const ApiContractV1 = c.router( + { + getDocuments: { + method: 'GET', + path: '/api/v1/documents', + query: ZGetDocumentsQuerySchema, + responses: { + 200: ZSuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + }, + summary: 'Get all documents', + }, + ... + } +); +``` + +The API contract specifies the following things for `getDocuments`: + +- the allowed HTTP request method is GET, so trying to make a POST request, for example, results in an error +- the path is `/api/v1/documents` +- the query parameters the user can pass with the request + - in this case - `page` and `perPage` +- the allowed responses and their schema + - `200` returns an object containing an array of all documents and a field `totalPages`, which is self-explanatory + - `401` returns an object with a message such as "Unauthorized" + - `404` returns an object with a message such as "Not found" + +The implementation of this endpoint needs to match the contract completely; otherwise, `ts-rest` will complain, and your API might not work as intended. + +The `getDocuments` function from the `implementation.ts` file runs when the user hits the endpoint. + +```ts +export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { + getDocuments: authenticatedMiddleware(async (args, user, team) => { + const page = Number(args.query.page) || 1; + const perPage = Number(args.query.perPage) || 10; + + const { data: documents, totalPages } = await findDocuments({ + page, + perPage, + userId: user.id, + teamId: team?.id, + }); + + return { + status: 200, + body: { + documents, + totalPages, + }, + }; + }), + ... +}); +``` + +There is a middleware, too, `authenticatedMiddleware`, that handles the authentication for API requests. It ensures that the API token exists and the token used has the appropriate privileges for the resource it accesses. + +So, that's how the other endpoints work as well. The code differs, but the principles are the same. You can explore the [API implementation](https://github.com/documenso/documenso/blob/main/packages/api/v1/implementation.ts) and the [middleware code](https://github.com/documenso/documenso/blob/main/packages/api/v1/middleware/authenticated.ts) on GitHub. + +### Documentation + +For the documentation, we decided to use Swagger UI, which automatically generates the documentation from the OpenAPI specification. + +The OpenAPI specification describes an API containing the available endpoints and their HTTP request methods, authentication methods, and so on. Its purpose is to help both machines and humans understand the API without having to look at the code. + +The Documenso OpenAPI specification is live [here](https://documenso.com/api/v1/openapi.json). + +Thankfully, `ts-rest` makes it seamless to generate the OpenAPI specification. + +```ts +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.', + }, + }, + { + setOperationId: true, + }, +); +``` + +Then, the Swagger UI takes the OpenAPI specification as a prop and generates the documentation. The code below shows the component responsible for generating the documentation. + +```ts +'use client'; + +import SwaggerUI from 'swagger-ui-react'; +import 'swagger-ui-react/swagger-ui.css'; + +import { OpenAPIV1 } from '@documenso/api/v1/openapi'; + +export const OpenApiDocsPage = () => { + return ; +}; + +export default OpenApiDocsPage; +``` + +Lastly, we create an API endpoint to display the Swagger documentation. The code below dynamically imports the `OpenApiDocsPage` component and displays it. + +```ts +'use client'; + +import dynamic from 'next/dynamic'; + +const Docs = dynamic(async () => import('@documenso/api/v1/api-documentation'), { + ssr: false, +}); + +export default function OpenApiDocsPage() { + return ; +} +``` + +You can access and play around with the documentation at [documenso.com/api/v1/openapi](https://documenso.com/api/v1/openapi). You should see a page like the one shown in the screenshot below. + +![The documentation for the Documenso API](docs.webp) + +> This article shows how to [generate Swagger documentation for a Next.js API](https://catalins.tech/generate-swagger-documentation-next-js-api/). + +So, that's how we went about building the first iteration of the public API after taking into consideration all the constraints and the current needs. The [GitHub pull request for the API](https://github.com/documenso/documenso/pull/674) is publicly available on GitHub. You can go through it at your own pace. + +## Conclusion + +The current architecture and approach work well for our current stage and needs. However, as we continue to grow and evolve, our architecture and approach will likely need to adapt. We monitor API usage and performance regularly and collect feedback from users. This enables us to find areas for improvement, understand our users' needs, and make informed decisions about the next steps. diff --git a/apps/marketing/public/blog/api-implementation.webp b/apps/marketing/public/blog/api-implementation.webp new file mode 100644 index 0000000000000000000000000000000000000000..575df5dab95ab53bc3cfae13c26745897c89cac2 GIT binary patch literal 56982 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%0DNY?b$G>x{Ng{mc#hw~!KFDu z?Y7vb$e;&wv<~!>RO05Rb4(ZS5ev{d%^Bea1>%Taw-xL%6@)){fM#DOkIKKDccJth zx7OeZ=?VA<>=cRKaG%boWo!8p1rhD=?i|&)hDH8L7sh^jx6tp02E(VnU{Em6T_jSn zh%Q}leG*A>xr)DEzscb@s^c~vk^;PlCxk9H%=#c`8iFTyh*(1DG|>3Fb9>fS&)ytQMRu8v(#2nJBHyGQf^xx7E@ z@s6fVa^1JXY+^2v`jsuI@rLcx7BP={U+0Aa&53-YuW z8fkZ_-@+zkSr1Me1ZxWelH76tMvUtqogv>H4<~H*CmJXQL0~m=O_VK~Bgn99gBx@l zmb%`FJM>;WKZ4zVa5{PY5)Vct~^2a+#V7_ z)qvIvknTTl>lpil{ea`N*ktZ%^2|fp(myug7Z>|$$MG`7%!}o6M51n)Se_QBar8%Q+b*nZ-3(*4GWejS=_RL|r>O&wAVi8r&EH(Npk*70~ z#vCC!;T`?`6tHuH_2A*=gVP_DF4|d2JPD^8Rx(}m7i_) z>vOihG={V>Y zehAmA8C-c65YzNxdvQPA^^vW?+>S(J?mqrIp7!I6YIVG=Jae$U2q{S*0z`bhhY{lx z`ly2N{fj&c+Es=F4ig@1t5$n-G*Q1$Aq@0gJJ0atr7-{-;{1N~nSCRl{hJG3#65bJ z5f6_!sG_deB-1AIW7sCnZN~$B3x5vG6)W+F*oijqaxgn|1@s#X%JS9Wn=54yJs5Jr zi!Epy^bf|wVrr6x(IsFZ-e@w9;r*NpSTG4y@&&NAp2i?Y$x8#4ZRR|2KLhEOTwKF2RjC53{m0``nRm}EdrH34Inb`)sgGvpNnk$HZ7*Q zau}hkB=^azjd{v7AEYa-?QZWmjG>kZ!Q)+7&cojp%0DNU;|0(lT9PXZuk#aRZK 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 WkO^c0nZW-e@H(-Sp}Q*50d)t(H_|fz literal 0 HcmV?d00001 diff --git a/apps/marketing/public/blog/blog-author-catalin.webp b/apps/marketing/public/blog/blog-author-catalin.webp new file mode 100644 index 0000000000000000000000000000000000000000..19803d87d3421590c0c31afbe6eee3ec95bea7f5 GIT binary patch literal 18814 zcmbrkV{k7)*SGtRy<;ak){br4wr$&Xc5K_WZQFLTV>>7Jb6363hxgR^a(bqxulY@{ z#;Tc`sa~TXE+XP04gjbM3&^R+v8h1=008`-91ZlJU0hI5E*ALb6aXA+WoYdPN(TT~ z+c-HWhzj7VscZbF;{QqdhK_dpa&l7tNBFDKW+VAm;b*77$ajxLjV8> z@~0Y`p`C-%5A*%7jH{E~e>nDsQ4G!WjefZBhp8NX2Kd9X|IrQp%ZLAA)Bp0zf7n4u zLGWj8us@7%`d_B{4;%cK|1%eSLo)~KpE_1QOl)oC{4<9CN&lG%tg(%<;!lb5KiLtW z01yQT{J75m;0!PaSOJ^>)IX)okI(+!?HK;YP6lB6V`KPpv;(*U9DXcJ0cJn8R6nXS zz!6~lQ=0tf#y^|YPmQ1SzrO!ppE{Z_{-?d4DZ@$t0AL5--}iq20Ei3#;4}35`#t~r z`?CN509ge9dK3TG-Zt^aomW44?Eh-SSpWchAOO(Z{lA()5dhHg<713%JADWJ|LF(h z=Ll?K0s!2V00406008ojuc0;n|6c#Ezkk~PPy2v^6#ziV69AB!0szv|0RYmU{@}HK z?o|9<5dgrB7$^(qPYh5D5RW`@v@lUV0ZJO29~&5GeTxNL`gK_yNC**wZ&4Jb@S!q^ zSHkY~>QVFiNZljm+rPPkvYssX^_W$7r|4jvDqpd07WmJv#pYaTiTmri@0{b5I8X8S ztgP#MF0fsqE83Ut{Mv=PZrrcW8q169zMk?fD(64%E{9|F{hcZQh$A9tyQ|WpJ3skD zH-q^VX8+ZG-8sDT!#@?=%R*Zqmic?kT)YLu$4HXkw<`uAGe9q(BsG9U4qxE)fQ#F5 z%lr{&PPobNbNsG--8f2-c#QLRrWQE{oCMFv$pd*uN^Wr^Mh<AUg9e5^;3L_Y;I}DISi~nzItvR;xM1|Q;c7HP=IYv=j2wJ4ynYAm z2;PyUBF)STu zoT7W7v#S2s2hj}Xv9y1mS|Z%~(hLrN_ywCHdeG)d3NH3QJb4MC@UiY*bLk~G@td82J{_B zCSIX)`OrP4rH4Ox@2`|o_nVq$p>pk!(9gBm<*EmJ^pNb`$#pX33%WKPy=}XM;Y0mG zcQql~chOXzukMSl8=sA`G|5Vq#tt;xVb9-hg70;1Iv5TlEseR^ckX!K^#cC2=KoIO zL``LYR%-ml#wqu1#k!rqO`s3ORvm|Qt;zy&3<=!^Q~+>&(d6-xeMdP_Tn7YO3|+w? z+?b*WJ&qTTY_>p5K&{J%G3F+8GYUJo-6Nk{2`SmKgm0jT5Ef`+iXaIBv5E$h5pKBkgb~6R z|LGJ)QMr44K{Z*Swh%ujSQCp$z6vDK^A~`A9t|6fdt6CQQ$jxuFHLmxoKT|jjg`VBt>j{_4^LS}}5;V@eqrSq}cp*q6# zcR>z@gqqd_Q_{gLK4tD&5~cvU&XGWBt)Q`i?Nlz?3Ac|BL~Tnmg}JLO0k1gPNV1wm zoz<=rbvIj6N3l$!%`4AJtk`ZFS+}L*;NR=t4SuiB7H!D`v$$WV!Y<_ABb`R~TS4OT z7sJee(=t)6!HtYGu5>s12$Hdw6w6Xq9x$9J`!~CAU!+E$5rhqw=neA-YdQRjLHN#u z^c@xRp|T5h3jcq+E5_t37LYhpEuXYQ_BW~r#6ZPxATaI30OtW~-#Pa^w!ugaqivE~ zPBDT}Vr3QD^>o61h3pD^c+`Tn?$wqVg^~5x&IRt=gi(^}T}mxQ23Jl?i8Q$`y1$8Z z%^FnU-j9p#{blvKNNZZ#=7En8TZzHj%!`M!{^Qi=f^^bU5}slzPzmw{DG>KsC7B2^ zI>T^5r{3PkSK#2R3|1p-P;zyPigqX}b<%S%gk$OLwG}h!zrf>a{uwBCFDe{J%WT=i z52LpGCjV*Rs#CENhoXdpQ(H-B>yxwM?kgpR_qcS3noBl<@98SD2qd_FNSy=nd`8>4 zop8f3fB9_ZjQr(cz>NDZSiUcM0`82tvX!GFhsYU}G~z`=k$F4taY(>kTvIUY*E%B) zj3n9}>7?P+XasjhS~(d{Vr!xqCZR5@$?o?GVb1$Z&v@tXg6T+UGdH+Xj0=u`F|1p0 z!s6n6+Non+J%J^p(L1odtfQ0)?G$Q=*z$>}g#FXCN+4rSqF{vrsQ#DY@8BpyVHVDL zM-I0Zz5STe@^0>xp_6!5SL!s8-xCIIzuj3StHC&@q+$ZKmk{T*;RC%JK43yWdDM^} z6{Q-u*1h$62K1Cpl%vJE-qE4X3^-4J&ja14-Wp^N^5cm^(tx%4vs@qbs#)G%wcs7CQb1L{<#Z8T9b!4+w&_j z_pM{2({ZAev5j<KApQl&@Ul8B@WE*_`hJhc(4d8k}#Au|Q!A3i&7W(BG%d@z!9LlxAj;WsNCy|g(}4yowb4Z3&!E+Gh>Ur9h54@RmS zh4kih>Chy{KJoUg2ou`O??`Cv)yRn(VYxMxzj(q1f~F+*8_wKh&JrGhrC3`+Q(F{M zLoqPA=}oy-(90cXZ$o?_JQf$z%!3inV5)S^VBcCsnAMjj<=^{8Ha2Yvs@w`NOPpn& zcyaO9qRJ^oW9`pRFHO&GGs?z)jTpzKs@dDXCYAB6W7g}3d15Q9r#3Y{-jL(g>4_$M z&6OO_G-TIjE5|4u3{;{>>yz>UzCQnk4SHr|4T!tc-NOt8uuwD(FDAlZK+SdJr@UuNZ!s^em};YVrypx>&BIG;MA(%&JiK%c?BMb)NAe!3 z|6$LJj1!fsI+mUi2<T2hE8V9Jhk4@^8Y#!8`rc5@=)``C{z!8Mb2yo08l|} zhWHi-=mr^e&pK7Qj@J)2FEOQ0GlJWG0ex+2#WRLM zFycP_t2<3R;B??y0;?5HNA$7c{-|q{*YE^kz@iW}dhl1g-4Z9{gO3Uc()0+`Gaesl z{Q3m<*ZqJWSAKaPu|>?T`(I4^bTFxX##~kM_Wx`{XlFyY~Y(rg#TQqXKK?EwL z$oCa5GjaIBrIfIBG)SS6>4Y3Y&h%gd3{m|h3DLQ zeY&Rar-MaD(0Ilts$j>5T!gA9cusoWd4ylh+^Bh~!bheK-F%nuV`@EcIPbicDnh`t zd(&PM;>mIo$=j*U4-KSY-H?lbZm+qIR!<;#VDP&ZOrZoNbRFj))hEYE2&ZfpES>}v zqxTg`h-*KNQ1`JbF%Uf*Yy!n$BBVCJ^^uvH1r1EuCs1cD&hmRK{apnUQpWb*m_L*l(C(d;lE&5KmgKMT3KJJ0k-=0G!f<|u3m*|;I)^S(re+g9L% zu@QGDHMITOo$8X#|ZB3=xnApg}3C-{*aa+m6zk0lho zpJ$xX7rPxnVn;56wlqu!EcP*fG~0uyJZU0H6d+BooX@D-KfOU9A0LVWW@z)cnF45> zcX!el`9sXS&H*f;O_>UZ7{DR$q0Fl7HVHI)%&>V5hWhMg*pLyxBqSj|3##(8?PzPL z*DZ%{u63pg*fPd+Inyd!ax?>x3P_mU&Yr#VzS#9kTK3;m(S6`$$c6C>c)~|naW;!C z4a{6Qim=)z_LqG&OIDvZ zSsx&NP+TwtXX&`ts!P;FqgisA^rN||DC54mQMS&!T@h&6BCAx&r4wnOP+-Bnx86@k z*LGkSj9?1XPCmLmGT;m2{(%L{433Pq!C?m%%UYZ(?2gG0J|{1?@`?0+bt6UUdk0$o zN(>Y%A!{#oGnqBg;f=3HPm@$W!nmNlA5ZD0Gz3*}8#79aZ^j zGoEm@dWtURBo|DiBBIC&X5ydfAtY_fj)zu2}6ZmcTvJ$37I0678n%wnS&%R zvp&P73*}+pUVgZABN|+`^eY!SVYI?l z>{4&bM4~1<)ny)}_RuAtN;5-*Tr)}W6jN{ zFX!Y;X}i^DiKaXjbyzV=3%5Tjh)h2=&WWj_BNO{>}B3KNW(5~4xuNy8cU$=i0@7%+Nz)BBXUQzZCuBR(s=t|2m|8g#GqAB?e6wBl=0<`dVKg$s%_yBbH5T^)4xUONICfRg zkX$VtDhggO4}h@Dd5?@(8af7de25^Zo`P{)5q3fs0aM=(zh$K4-4JF*7HM0B#u6#H zOOA-HBmA4)amKs=ozadbB&T~1mL_P^s-Rw8YjK95cBtZ7TWzC+(oPdn`Yv6m4 zh@gAnNP80?bk;x6gulUS=N7UJl5`)l;PZmE1SA@S;<~%~@sW?}drn`WMUI*Wy>)W% zl@)fz?>q8vEnBRL;_2fu)%|9PqsTVHJVZir15@&p__9~r7IUj8MiB$NFg@89Ru?&8 zUL_z)HpcxPK8KwARzS2f9ma4LRFGJMe~i>%zNP{KHuI_N%18%xVONwjIN3R z=`qfFvwf(gt_&ylR!v&pvrtZee-w0N&7L2iSh9X>2Ikc zvNiKB<?K(IJ5&a_L^1cEEF*uQtQxbvoscla?oX zf@V%v`l*4*J_7w1QC4o-U|`1-_?SDuItx;`K|%+<7prLWLb&k~X*~zntAXk`eg(96 zta0*0{Sy#!S+Kc8mmay`!fKcYw7P0&gH=y(A>VZ5^umF=3Of@~!0dNsetbaMp-&dU zwnFa(W7BO)v~=TOwJNP@zlT>Tx40< z@q$BR3_LnV+Lt?gH^!i;c<=&U^uW=AJECxzKfa#5zJL*xkJg#jhYfXm?}AzG**BFA zsde78E}`npzm8`PmIdi>OpGwOuD%m&AaavAdTQdq>lsnrq1hCmb+d+a>WlpR!irxz zhDBp_8PdA&mf6Mw)o6)A_)M%^=u*R`&1FqrNR${KNax6l&7gbTS-MPM359CVcrIMZ zFjwxKC}Vr^nI18sSL&YqMwMe^pZTnJkRRvyKu`r%=Y@^TY!P+QgFW`rN09YWp~)@@ z+CDrL*VJbO(@Qug0B+3icvH#|$;#|P3%+l1Ln4^r$e9fTF$w@pTcXTw>5Vu9&AwB$ zcSl0rNp;67hZy8b_RI#3$XJ-v#tGA@vKLZqYtLygqHEG-NmfbNQRmF%q&^X5#t3ql zO&b+zr9~+&d*}d?twU6Tp`}1Pj7)ca^I8_(_EEyJQb~LvNzi;&eAmxnAf&r`CqBXY z%Aa-81;mrUyAxe(ODuOgXzIUH)V=lg7&Q1-s+5jn#WLn^oP?#=<_J7d3`|ThUXVsCea3#(!Y3_EEG=?hsMEY&(AF{$^ar||b2wyk zN?x+958s8S(KAzJ7KfWRROg5K=K?GhB;P>Rx7!539hNKmTWodkjJ-I*eumu2q?JXW z6+z00HGz<%%;UJIXRF0B#Ipa&Mp!|ur&wNV5*2 zHoHs)c4lti%v|$2JkZO-LSH<>4eK@dBcZ?9MYj!1jo*C-Vt4AH**A- z@A9Q|U;(qe`|eqiP?M44rUSIwq#y?t1#1i2uRe-gw-XXoAUNY9o-yWTyN68x?9;*vm9*q3u=1ptg}k|fR8_NI~+oc&uuw59B-nsnO*XUY3IY8_Wp zuZlprTm}c&qLjgAzA1f0#u>70LW{XgXnePKKC%Tt{`^ZW91NZcII zH~dL2b9Px!HEM>ooTZk+R?*|qI22WS>7YHQxn3{>96?73z;JFm(10vDr!chQZ|m5I z+mXn)G5z|4^%vIbXU0gxp!qj?&wQoT@A)T6=BS2R3++CC1JItvq`(EP^Jh2))!EZ~ z9dKtD2x?bm&@5c|>HR-GzZtg~M_gkFAe2WU zz_VjtL}O=e17t+xLq)VNOpowj;H{9?U0*CTfB#m*C5pb#XzS618>V zj{gE1jnIwrApmhHC3)M5-|1{rUGR}F^DZ&uJlqh4u@Z7&UZet03+D?`T=UYbQDxll z{(cm%OPloKM)TDS^2`q~WZbrP;Ye)gf~j0%-!cz44XEKPeQdc=5NNa6;t?fRAam)U za_Dwv9Q@9jw^(n25uM3zwKyYO45LNL-c|=!h}Gh4f<8@z-)2{Q`YA&GjFBYqrCe~^#s0q#%bXR7(CryIt`vO z9h5Lcv{TG0gN zkx<_b4Mb{W1c0wpoEbF7I;?E!bYyLgGMiqsf3E68fbXlbLf7G1dhB-?>Jp_nXk&Rr z;)U4Ib*y>rKRDQ-Q&Pq#&cMc~=wT@@sqKvH1veu(kZkLmwaLe7dP4TIh|Y~C{?7oA z(RNSl=6T{*#JLHl1HXx$lLB6A(B|+*^NOvy>;tP_yX(>rdk{Kw_Mg{E;e{7x zO)E}ZLJD_fcs3461`HyCsChsUanHQ?sY(kdp_s0ccBOo}a8oV3=Wj3rBF4 zX>8D%Y~IwM80QquuyTB96v{7cPauaN*vISZV@X!>CiiI{TWT??1ypUf*agWT3L5@pKSbzU1qsK52wlBxV z?s_a1!Ij9QA}MA|G`lpJ?@T&v*LTk5!@FmK1C=Bn`kBdstW*rBvV@e(nozF;dy^du z2J(MlGMIV6ZV6@PpnFresQ)T0!D~xHllmHd9dCkq=Bvg$e#5qe z-62AKlm;aRyJzGUAl`L88R>_}XuLm5=|irv{`0GbDAO~UVi~P%1LifrA`4w=VE`gN zFbtROpVskg9P2>f7V2?zrkpcfx=Lfnc3s5EjK9vtI`q;Ear`{T2z-47!cPpfp1@S0 zAf?x{pLmlry|84m0xWgmVQ-0BC@}$2pmmFNzLeyPvNoz$-Ae-zU^ zve{_*!H`!LgA$UUk67R*_Pu+8X}V(lFnoJu^P$@mCJ12 z>&^W(u=mTKtGAbYM{^xb?U?u5PUL-?oGl#^KRvmr<;6_gE~Cf^)+Vex+D}Hqp;|m6Us@CIwLBso|aGZ8ZGy1~T=-a&DDhUc0m_!R1K|cZmD>O4VsLYQo+bMTU$=x%s10GU-vGC$Jw`^X; z6WA!V_3$FlureQ4GHXGu)3%HKDL_AZ?gZhfsIqNCs$nzk>_m9~^0r0GgmHkZ%A*4u zNKC*HH`09}bD{vXIi3$Tse4RVk2OnH5)Dbh#m5nPqcczf5rM3wEWkup>_6NWkXnr3 zlSJugqbqaY@LADy4GaZbErRN05g9Sb69$TNsI_{O<*4Y1QlvcDoy5-!Tm>^Pkqz5M z3nEcxIE#`!ZMY9pu23Rb6G&mtL!+uD3q09)_H@&0NoQP0ml{exq)*no8>BhhEcnztH_ReR58a^+w3vfq1%ah%pzcm`Y)BzcPy z67;ZtRd+OAk}ioLRBW_SnW!0(1mOsHjuHuc#bMk+)Y~juOhOha6F)w4{I-s zSU;yd_^W}>iUQ~ke@J$c&IobxK}-r`mLO9#yo{cv6T0z{;_Npsa#zYN7}k(mq@pwG zTc=q-^upya>SLx`7UjEGK-urP+zD<41WVPAkZqXGb6Kjdzjut2)fcB)H&q6K7jY|z zeT^E_{dq4!X8RvZAn`m&S>Jn_b4oIxuxd)*&{$UiOg*piA?AP-bW$x)XD{6FWtQp@ zpUOQF%Y|c-+CC8w(jzujW8#CuC*FJUKcZ!X7o2$f^!dviS#$c5gGhI$dL@;-AYAHs z@*C1fZbZ*ZBk2)7N6rNuJ9vU+Jx%`aZiy?8Xf4R$<0Y&gX*OtjQ4|N@xl%Yqk+i`s z`yT81y&fFnE(;6TOm{QMMnbqu%qwlB(T6ngzWfZMFCKt@VZH$!-5iMvzk%x)m&099 z&b)u@ywEd;E=SnD5Y__w=yqaDvpHWZH)Y7~DORDpd#8#`GJ-UofE0y$^m=fl2A%OI zGL6c#XF6dm-v&Iru@)jY9>d^YpLp8u`P}p<`&DwT*Ej2Ft>}4Wq)XO4{$frr&j6G~ zQqS0n(hm-h$*KJk?~aln@RXoOm{tPD%8g+ubW=nvT#oy`lz7jAKMM#bEctGg5*IoI z!I|NZTcr~zSxkPFq>>nLHG51!7-?R7`jvZ^$ZW^2AxGM zQW1DDZ=^iZ+b60^q20^H+eBKyK9qcOPAIm^EI=^It>kUmA76;ysId~Nwn{qiZ#RB5e9Q(fHrhDyRO=74zPno(PN4u+qHC>%8 zkhq>nN!d&^hCVwj|2FMNN@#$d=as7Zk>-+SFaHRlBn=j7P)Xkulctp0<0$&-j>gKy zdNQj!d6LD}JL+;rytM6tp^ex7brzv`fDu8Ee@kd6!(<4%?Neal6DS3{G3V{$#g(1* ziXT+FDuba;camp%*~RzyxeCZ^Aln4%Q+WT7&D3;cs9Gsu6E8??eI^7gFIk~Q9Bm); z(_7mrhoB*^Fn&cI=j6dDeCmYDWBIyTQjIK(H6}(pD~*}%Qg1y6c(aT8IwQ3N@Z&yk zE^ZZ&4-C3~cj)dClp$Z7_w1Q4eb8t`0-<^&v;oCv)b9@YF_3*}<}kh!_uSv?lsGNM zP0U#}N6{w))xh1d2g(yF?1}cmRTh3Z1ny1`!lEwK@|2jP8?Nms)6(PW3Rm>j>z!0IGqifGz3bBv8!RU&LCLGMvbt4# zgGp>A0`q`n^+$vh1WUwV8i-0|A>&isk5cX#UDVprR2T`om6YaS#}IPk?9^ zR?#*~Po?lQRrQk1O9Kz?3OGM-l+VbA&zchs&f}-$xB2*Y>xDr{CS4xkEDp;Zf8ib% z8odOcmOCPmo%Ll{;B+F&aA#kI;pWKvUs~@1Od#NP!Q4h%CVx4Jf~yt={CW67Z8v@=gUqNjajc9!$cK_V#TKH!koenZYtjJhypgG)Flu9dpImt zoaYx=B=ep1Y@5OI=k6>c2z&bg9+nLVE2d^QlNZF$h=(c`=k=1w-65lwN-NzecBgBQ zkY=!7NXEGMPvn{L0v`S5HODA(oedgue# zPL#h?Y9RI@b#|>kqda3E-c01kDq}8kFpDo1+XKgsWhFiLht~3vaAQ}V6BsBX$$m{D zM7HXO`jsClr~p+SoAM)Doct#G9%t{D?DlxT`E9XANjUZ*623!{{tX6dX=Ee&zRp98 zBI})%d~;=GAOjZBIxz>|uq3c9v)NVIqh6|%$ed9z9a4zh>`{YM_lOGF9(Dzzs#J-~GD;afZUhSI(LCU2YOGvr&~5Y+y$f{$^!R( zP@W-aS^UcD7NHCjYu~b?9dYe_d^n77lVH)6u*rq*=9O?!Prj(5?<23f+*#l8QKD~} zY0U9}w|L6~sIZ@3+qS?q40~T2R3w~ReHM?C>Y{;an<}|Qfcv)nDncW1A{}S96XA!K zOPKw?_5m%y-t-qDsM#mqBrUlRH+ctW%8Wq^km%C@s z3^fD4l6KTIJ_9JKeOEg^beLTZiX-U)#m8fiN8$pdzb(IB*gcY7@(OW@Mt3IHitqEh zUYAFQp`+Xf{}4zIM81O*W2eB@0Yk4nfj6&L;JF-)zC>XJ4BC^tsW@TBmto1heH7f- z;qJ=~olC!&yq-QNz22H6Mh=d#Z!^nSPP_F@e;+e2x|8;B+7a*n{9mF7gMaj^JM@MUY3DB?D{75&^S;iqXa{%7xir#UC-go55R+zX`Y$~o=@tK?EvYY= zCLbP^FSw3=$?AeQ+RL$dJ}$A$6SGNzMoF$f@Ji_9Y%yJN@EKdwVTw#k7UknOQwn$S zvT;k+j~y$HhYhM(4|h-83Dq(pTDy!^o2pB{BX$kYrS&N-qrT?-TbFsVU*n=*fRWN> zUI2C6=#ywMQ8**J_zt`Q^QM`*3p8>1mO-B;FJP)emLUBsj{T|FmN zUO=2)RuR4kAs$$KdJ{2gHBf}w)@NispFU8*mN97uF?q?Q%+F$=8jr__{-3q9 zTQ;RWyc<+0m&&7eCcp4m;50D44M}NM>8u2wH$C(#Hf#uWNM`xF&m83krMWITF!&aN zfNp6Z8oz>BHT50Q(sFOUb5nzbjk~a01$RS!w3ni;UO%swew*KL52#V#tDMwW4~H)2 z)ZxiJ9MHcD)zHA9oRn)XRcuF{;!F@+2}5{#m76!Y(;p z58~+qnnJb;Cb5|9t^EM?v&~q>obY8>N`1WI4gOig9(pu&{82bn$j@k(jf3^er0 zq{zs7HVvtAed$mPw~yL7*EA1m1(25fO~&AoyzxA%p@`=p!|)wh+zZWOLn1R349Rt` zIVO*I{Nmv_k-Qj^gmTC6FK_!vS##{2BS(K_{)GbL74(FWf!xzqZSy2rKXjpPr?CN} zVYw=c&BM zat0X>yXd^f=7!q5kk#O<=7xO!gz9mTV~^yf&bAZmm$6)B4-{jx>BY`@U+G}2&sDyi zXXmG41@&4|;6{h)iR^0zGl__k7X^L`mW;U!bA=MsP9xFbf4-BaUM3J0g{ImZBL4}p zg0<6le7^1fYFaIFw_U|B5yrjnA8#|`3=m}KCPb~|S2SH>9Tm z_67h0r2q+>4jhc$IzdtpO_29Q1MI5Aql#V5p{Q1Y`xlaS>6@tc)1uLVXE0kv!D1}i znJ-Kod1WRc%nrH9V$WlKXYg**wCM~F+)IU;!#!ui?g%-c9Bgt1(P!jeL!|v${9SJ% ziBEdw5|7Oyg7WX1S}8h@tqo46{Ge7w4RsvgqDraL%-y&fR*stHa)xKWQPP66;yY~} zLr!!Db~~@q=e&>wzjK6hph@&-y^`K*-gmQ~XzL{;R(u0Vv@UP(M<=%r2v?m^?R56DALa8*y9fU+b2XR(ov67 z_iLOEqLPIwxhsZ7e_L0J)}oljAJUdAYRMu#C9vr8(&&%X-js({v2csd0b|(E>U6ER z!a0_V+UHwP^94hT8Mf}oVQ-H7Z`_*hiWu0huEEf{FW6xbxYpKCD}rG>Ch+A&K(nM3 z^Ayqr$kyU=@g`oS)h@};BJcLTNdTOQ_&Baew~zRPuDpd%fSZKTjM z+*VgbsGwC0m$$3|H+ z-rw-0-{Nj=Ib!ymDrh6@(~RP-;ic4K6tiUl9+iY;cBQpy<~S5G9S8}7C%LJc`AiBE z&JG}X7YTXst0~1j{igyDvWNeqB0koj;bjf*B`V0+7{lnQ@q&Z$c2hFBsa5H-Di_C) z<3_n}t*MzQUL4m`rkDjXn($mmi2w7Ofb#)&D;OwM zykS4OGpe%YP!uavDn<4mm1OPgd z)Kp}BPeTatC2vf!*DetZ}SK?|T zP*zPvr4xNPahQ&`E0`P*Hw)CBc%#Nw(p~eM+Yu8uRampX;OPK0ziQvERN5lBlnH+) z6oj^sy)$5_q}n(_byFL%r2iQn$Uj_QlnA!G<{!&5?zfMaOU)@Qvm@c>Qfm(RryR-g zre}qjh^H%VwOs7jVH$fxkXgrF9rEk`Ryou>Jo5wBfIsWk5yP7CC-o6xKHS1Mka1y^ zBn8J8>XZ28__-Oj(N}%)19{QVQ9FTn?L-gZ-A|~`i@uOKs}HCb#Iy$Y!Q%{eJrO?z z-V@PHPty+`i%>HFiJM`WQL#xna@OHQ5h#5h>Iw9U{HxR4TSRq;RAnJk?)GCxyymXP z6$}L?;3B@)YEmJL^-?ZCzW{01*VQW$DS?>ml+ch^yd-U+@7Wt39F0U)b3Ql&5Q;ScyMGL*v%g0!&QA;xY9j2_NNp?s+CEhSp|WkE z>@IF?D|898F? zC78)SxSlqx+JYfebn^4A!( zeS(KNieyV!DTz2Ay{Gq4^$?u_YL@Og*q1A-o?JA~p zpg7s}qnJsfGqF#$zhl2#x07m?_3&&!FSLvjcGGoFfP-#Go;;pj_}&Ng0bJrPOb>e5 zJuG`Eh~%6pSYV7*B6Ry6Q%qPfJt9l@PK@s_n58NQVMW8CPsL^1vUY7f_q$#x@=XqlR}qI{z=A*Bo)%wQ*ZQF z!IA|I#s{BccBdzK*i3k_luGFeJyxABB!}@^DPg@2sb~WVdJ&m_T4(r@&Z&$|2Y*zMv; zwtAWy;!9gro+5}r@)fC=HkK%pAwr+wM{PclkR@*s2|I)t&S_IJ{q9l#r5oUvxTDgM zKG`=1oMDWIlE6cj_X31d@rq82 zx*WFAckKv^rZAB5zgpSFk0K8q8&RfGmr3>=GCHn}0~K)7`FIodnJo`?gwKVgBc7Cx zJ&S$pIZ-8zb3EgDLLbkV8^4qyyCfKx_Qc4)_f1cDvRE%&54tpcr|B&GxMSskGf--; zVM8vOm*f?9cf`v7ncRJ>ST@dAaqiB;=?D>t=!FMF`^FVvddCI6dLpLejf%^Z7=Gee zm?CHth3ZqBEcgcGTKw%)r`hFIB)lt5ojGB0qM=f+O%>75hQ02ZL>W;Sa?eByV#!d5 zg>N-NB)PrGV~`QxYQTH7DhHB{83_2NirJI8ntT`ekAEj_yM0?F)GHRe-b=J8gtFIU z^OvQBkn0^rUMh%scaLF*k9R(^UVU%m{U;|i6dGivt=w80#)SC#KRd}w_sBN5;HgsF zPRW6l2)j7Wz&WEk7W-euvt!hGi}Y|~3V(M7{$<K`YV`+379*7bN&Th+6?|Y_ zNK=E-H()4qX3`k!=uj9}uGOJD&mOb@R*AE2RA==6Q&Lw!q`M4hEP$gd?AXF*gpp`H zDXFkbJe#bXdSmTZK2ZCIjAs7MI0=3E<<_= zGn}xMx)kveI5@+4GDd7_L+4P%#EWjt-Idl#d;MlO)mTv@SxsKTxk9Z*Rem|S=)tz9 zISVva9Mu+o^#4|b;{0ypXV8-u_`=DsH5MIfs2JstiPTMwkGXz$R%-n)?j&>tu(rw& zbKLmLu}6to#-Mv!TLjO9#^PEYNLWDS1XLZxhsvu(ipJyF7mFj;9s4%2u$wqX^0oYX zp(*t?yTL||Z{>W)^qxher$}L<7#{k)xOKU_4095cBFl>&e=_B^B*oq?7xV5E91mJ{ z%P@#3K&pK4M{NN(Y(z6~VQhXuf;aY7v|)QGfLrzs%1O*|C79m=U^9UQYrrTCs8wEe zN9wjK3-ZXkteSPXYN*j{}+-%*P+8}q+yB&0lDx z)ezb0PuVR}S~Df;vLXYfR3d)>+vmOSr#CXz0VnL$G?J%=T})w${6~{7-?DU~{|iAd zoXH2X&w-)8G35$HkzYgb07a!jMR9=nRnl$_rj@Jifsr|HH?Nq(@88E91V`7rhtIAb z5z_aQPXS4kg7TmHtrv@CMp>TiH;XsriOdT+59|66b@C2gU;?9ImR5zwBm5f zm#1@v*G9vAe&zAZm^XsQ1kM>(-PSd{7+XlVmdT#?`A9D&3{{b{=hOhtRHr+lFLL~{ z@QsM7;c0TRVxXF|2FKrV5w!c2eFbSQ9K|n-n7>75!sgd$8_}=WVbZgk;wJX@fLItG zKler^wI(60%W~|*!>!jF)xI2xWC+=@vr~Lclo6Q@p4x_?Nz37GG=+GR-m5Qda`PO;=b3mY*%|+~ZB{F|9pp1rDL18<@`P zADC%tPF5AFk_1wi#{BuHQzO?VuCJQ|GB+_gsa{iQWjd;0zzQ;TX&-I!YK{_0KwthO zhZN_2g$b6i>}hGM1uB1{e4`lQpdi*8%%`4+OmBnrY(bx167WWX{Ko479njF?vCvu0T* zmtu24QG-C6+6SP1DLvnl_?mJZMoRH`il;sdzX~sG)i?8SYwqyE!}zVgiFTdfl4`5= zyT&x+tMPEOY@XC^oM01LL^X-GgBY9XvK% z&3i&2IJTqS2F7TH{~A&y3dD#GYrug6<>{$RR=4=wmZncIWfYB>r}aSlvX~~#P;z$v z`EVp^r`J5w)nZ19t}>PWnB5RtqDbMPo7d%qwjuJ@z>%D}S}79&dFVix(zna!VLhLp z(z>8WD~TJSQm5&4=1_T`aBRx#DZ+nwRX8yK$rKP_*5p2beND^<>@!FS_umNl09J|551e!0XVpzZ_$@67JwGtTZEL3yI%cg+izB zE}LSp(lAbgOy9Xdw40E@pM{mt9S-02S0}4M96my!EIwT5^CoaEp2#EGW z8&<%UbAe^fGuVy)F*tyL+d%9#B&`;s6;kotEjEfjZtvY%_oS60_3L{mX zPHGS@ta62=`z0_*xgW3hGwcHy+3(-wshLObE!yw|FO>t$c9asavm3n{J=fL3bdf`K z`k>W!XXuH9R~ENdW%A=q%OMtI=D&kb=l`Do{Raa0B~zKPY;7kk+v5O1Q|z~8rt!$C zFJRW0w8w3+ElP6N=j72;NIW)P9z6p@X4G_!*%Hr@#lX<}1!ij|+&7y}4~xTlMiU*RYY6ZQMwi055Nc5mikE&q17uPRE8SOY+Q6?d{J{e9V{p>Upd=N( z9pbH4$kp9H4|ul*;$$x)^b%BVZp-Y`Q`k|o5T+rRQ$wCA$Y^1fS1>dWBxx?m%!Jgs z6>CP~>yup;Pro2mB4F!9ggkPGZGNPCYi5hv`<5i&JIC^6sm5kvajAP@{E1w}D+K^2 zjv;XO^h*F`9VH+=ykvvSuUny3XEObQ(j;TxCNE|9Og?syI!%?&*oY)fHFHseeP}^P zVP?5z7Rn7(ZWRRhdr8uDedYO-J*^716v);ctNE7Zp=_;YZ0|}$5RzzBn5vVLjwpD@ z>*}}9rG2U=3Ksa=d~>fdTwMZ|?03<7o@kzW)g=VOLCq~}b0}PlK_jWWLQ?3$x@+N9 z-4vih?L-MS@`c#e)pq8*eD{qkk>5$~McUdS&KdL1a9fnBLGBGfKbGM@aRZzK;>jTrP{p^J9HEF? zy_?RhOO;94M{x`#N+)}LGS7ZBP+*1jJ;-OE4~($YMCvLhPZSW`_MA{E#_T0v>KD_9 z9uZ5k^)XblA$t4S$ddc-i@L~ZSqbBw4rkc43F=yfiX%buI*8%p7grIQIzVo@(^MoS zP2xJprPeAJGBQ9Tp=xpEJb5^5q7-~wE*Q_o4^4ZSOkpyvYe@gFpDPl**9xRLtRe)_ zAvlbkRXCh&-2PV1do&Yzt~3L%SK`~e@Bf*A0I_y3hKBqsi}FzRh<)Dt48v4De6pkO z&?&&bRE{q1L#FD@4rK&e=!5CkT;7j4DlP>9b4ra#eUeoj`=PGk=B3Gldlve~gA@!Z znkgC_Aift*4s4fTh&(E9%wsA>(O;#?{{RBryRcDw-rAU;R#R}`Ku_M7QJLU_l@m=4 z>_R801q;DT2)ukHxr07s7$&jH=@t>Z%*uWB4nEvm2U5B<&pCl7;|;f{^(&N~(1DSn;L$ zfy=W?V9>VZaL$8hQ=URW&17!w=)4E8GE?KY;8}j@TkGUvkhOGv& zeY+s?17zDD_p7S>0X(`bG*j_pr%l=1-Qjhi!5>LE)RIy4>TTI@D30yi;Vm`c7d>P`q2Q!GgQJ4;7Dv)O%?|Mtu#l#5565&@5&jX=lx;uBQSa>l z4oL@ya74qj@ArtTZ^}R0?%IAkyAZh;nBiIHeq2)!uK+)7Gv28>&|(_LGe&^*L=V`8 z%xPI<@7NzJ9-GPaZe#nE!V%|n@suDVsn_{KZ8VXJ+AIfxJ}vs73#5m?B@{maK0XFe z6(LUQ$;iR=?2L@M7n9972>=m8?&1weqwOlYJaYeZP*F_^MY|8dvKUAXt$qwid32#^ zxNl@P%Sfen^Mxw~p%}S4tVP@oM5oG>k&u0%f7IKB=PeR>;T?;+&L0Jd(2rqQ(Wo4^!a-|1B4*^3zHeui%C;^Lxc8K zNe*mk4Y(Y-9^+MJ0G1FAeT3Jh%QyGft#R+2(QmRu5E=b7anfCnA2E#da4E{*fEL$s zgP_;jZMlhQUkvYpS%iHwF!B|Roj-*e*7}d(Y_7L{7;T{>{>ZWhkwG|>?39LB;syU* zp{cQkWa`=V2z|h)Mf8<bX<%*CKPw*qjsL>@rjw{2~=|r`P$|2%V75% zCBuynVGq998UMp$-je8Y20bFXQ^XZ)N8~hp@T`jhA)~kZD&z9E;rE-a9%vptO_02a zdI7lViCI*UI`n7X?l!H1&ZNf(pOsw`z^{tLEfPJvMQs1gT$A)*R3jC)4{iBaf-R%! zgy!de{M^}Kp^gqoEWHWDN__RE6aAw+th0r&{BX|`_i4o}6z8elx!;{Y+!bzzjj-3i z{THj_)j;-@(AXa0W9W7{!3DJKxmXZLfre~~-^heoGGjZHPNB*K*Zvz3;y94AcV-AR zI!PCg_GUkM#t4Ozw+=eyD({-Y>&%pc@>Js$J|0vx5RL|zTsi7t`puJs42yCRSgKk( zH>!GaE7=*dQbmpcq{T0|dHFq&b=Nj4neW_|OwU@&2y9r6`PnXWj*K*9sIfNd0-*|X`ik-!0V`e#%OHS_JgUqfy!FXLOS{!vb$prm_%A>J0L%dL Ar2qf` literal 0 HcmV?d00001 diff --git a/apps/marketing/public/blog/docs.webp b/apps/marketing/public/blog/docs.webp new file mode 100644 index 0000000000000000000000000000000000000000..466b560b27fadbe4d349ab2b6d09fb778056723a GIT binary patch literal 135304 zcmeFZbyS^8vIn|xcXtTx?(XhR2<|Sy3GVJL0fH0U-Gh_h65O5Oz8}e%IcMgM+_~@G z_5OKhuLZlhx>t99{Z(~U{i@bhk(Q9~Lk0zCii;>|C~;}Q0ssKI*S|bSz$!RET2xdC z7VotQz=O3hwsi(&0sw67-aDyCiV$mQ>kvcD0H6T~0C)f@0MgLd*+E`aLha}1pJRW1 z|NZA?4gm6#Gfn@``~QCQe`&#+*xH!@03hhEd@B=EXX97e@0GSNu{HV0-~UOwy?1yI z0DvmJ(&$dF8+@f-`vB-hztQeLY4hLcs9$5$R779Ld;|cX;mv>1uoXXPqhB;?^H18? z!pRl@!0mq>PiE}k^g2K8&$UQ?+rujb03iQ-9%6G-J5wja_ogPqznK4*|Ndqz21APZ-4(bT% z2^s_%1DXb!4_X1*2-*WW3OWnA4tfN74F&*%1H%L(0iy@w0uu#O1k(XC19JlN0gC`j z0m}!g1ZxHR0yYh{4t5N74-N^A22Ko456%lN1+ETm4DJZ-10DsQ0bT;$2;L7q4ZZ<> z4*mjx0D%ud3&8^+4WR{L4&eq70+9ky1knI705J=(191Zh35f|w3CRg51*r{b1?dGD z1(^+51=$Vx4RQLeMd5YfUEpKji{ZQB7vV1v z5D+L3gb;KP-Xp{ylp^#YtRmbYq9HOMN+X&e`XXi`HX=?To+7~@ks}Es=^=R{r6AQI zO(Gp5!yr>2iy|8$`ygi`w;<0WU!$O*Frz4<*rG(Cl%Wix?4v@VQlN^XnxF=u7NGW_ zZlQspk)nyB8KVWFeL@>R+d+p!r$m=Vw?dCZ|BOC?evW~P!G@uM;f|4s(Sfm!35H35 zDUE4^8H-tqIfwaxMSvxYWr`J!Rf#o)b&HLMEre}~9f4hiJ&XN-LyRMiV~rD!(}c5v z3yw>JtBmW0n}a)mdyI#U$BSo-7lBucw}cOhPlK<5?}cB0KZbuzKu91-;6RX0&_{4g zh)F0!XhoPr*hzRmghnJlWJ#1n)J1eij6p0+Y(tzz+)sQ?f=?nv;zE)~GC}hAhVqU2 zn}9b}Z&pZQNx4YPNRvo=NzciM$mGeq$;!!=$YIF2$SufI$-j`_QczN8QiM`8QS4J< zQ%X~MQkGMGr$V3-q;jOnr<$RLq~@Zwrp~7RN&`y6PGdonNi#_cO3OiONt;dkjShm2 zo6e3dpKhKWo?e*VmA;&Qg8`F4jv;`diQ$xyj8TU%p79Ig3llq&4bvy4WoA@n8Rh`y zR^|&98Wv-gOqLl|1Xc-FU)E;UA8fR2rffNE3+!m@^6a7Pz3k5%oE%OZl^h40WSj<^ zS)B7+=v+!%QCvgZ5Zof%zTEBH_dFat?|Eu@&Uop0t$E9M_xULKO!+?XZSlY1H{{Rb zUl$-2FcA1Cur5dpU< zs%WbEs%5G-Y9eZ}YRl?W>MrWN8b}(t8l@Vynqry>n(JDOS|7AVwQ;pAwVQRIbTo8| zbgtiuzfFF-qsy)vqC5AF@}2v;VLeEMDqg+L5mcNV@pxX49g!@QdYTEx7Lc* z#nwO@O`9rPXj=o@7CTfsYrB4XLVI`nZw|B$Ar9+~JdVkZ=T5RtMb043@0^?8qrV#I zF&8SAV3!S70oP2|J2wrtI(HOzJNGdU8jo;~Jx>YGLN9PHW3OIs5^sO+jSs>f@_ayi zjC^{1NqvKScl;#%O8jB{t^LOXm;w?4t^zd!TZ0IK{DQWEC4^^u&FMo>uGXn&FN(6ap_MP78$ddBAHcL_*vmux7jAy zQ#nF8mAM4Dk+}~aEk7>gN#-@?Q{<-*?U@}I9kHFTok5+zF3+y(ZinvU z9`l~9Uj5$XKCQl)e&zm&0oj3}L5ab>FT!8ChWLlthk1ruMz}_rMma_s$Joaj#@WXk zCO9SjC*_|q5o(--*D7x>c`_|q5o z(--*D7x)tw_!AfS6Bqas7x)tw_!AfS6BqdZF)r{ckMrscfc^9c3g!U-H7@`_ZX5te z&j0`@%wF>gy6;WhU-LTvfSoTzBxcxB1X@1>Z|!$|U%Flx?}@sAEP`de z)xgr1%%>_K@T1}ma87X9$MhR?ZyXQFcatu{Ct0OsSZp8nm@*_ z0n5A2b5DHFee;1Xz`Y&PN8q#Iq+lEp@MV6jn;*ch`2+-3-PsQ?^5{?a3O;GQti9Ym z6E*mtJ=#7>-mM)8E_AH}KR(4hf!#6k`M!Hfy|=&A%L8IRxLvND5N!%J`|1K0Uji=} z?*vx`Nqj3`)*d5&7~h=@1J8lPU7@}xFTT&IPq8<#$AVA5F&|hF&GKADAkt;%I@XkZ ztgq(F^o!(^pr2r$V6?CLOT!D} zQ`Joukm#`U2H5wq^^BG5wha6NtOLT_v)_O{>EDOmDW0rt0atIC z6!;B@00h4H4ouGZb^{B6U@w|4=6A6`;0;g+812gh1bZ%c@_huJbpe4#&zrtGPsa~X zkEXza%jx~K>)03ly|YJ_fLwZNwt@XP+-F8a7o!Pcdt}Zqyt<(UYx+&dA7JkO?LynT zIx70qj7g~@vODA#3HLG-D*99f2`j55?LwzFtQ(EQ}x&OrM*9Ost9`0WBHC% zOZBfI?%aHa5~ij%Fi{q}Gr;g|Lw@egji>K?y8D8gG+K&CG7#q#29u}I;%d0h-Sl`w zIHVxdn^}wc7Re58aP^!Rk(`4X{R%M=lv?R4(VgJA?>g|ai7Eit3kimUNHV&`(Ql#S zYNRWgI%peK^E~Wy93d2stIyWut-RN}PINTXfw%oGqfaiD0FzV~mghw3Qz~fXG$LGsTfl zGLm9;4CCHh?qx(0O^x!+Hexgb)Jmj!O*gREHiJEHb1hhU`2GM3qRkU}uHfz9qP!HG1H(}hbynZVPh&6HPq0+oDT9uki3h!! zAYPyVV_4r`+KkF`U9fl~FdS$!3AP;zl>9aqha@)7IIO-?CimGVVUZ`i!F#l27gm7gtyGYq92zk4GQj)o}n7iA007yU-EA9p3?`Og`!OsNfoJc7J2Y85nJ zf9lsySy~Wy6}^VB{~}R;J-oj?-p4<3%BJaBEY`G3-wj>!JS3Et z=M#N%7t1`z$3FRnyQJj-gP7d4?Bl)8TIQ4x=9F%z!VUc(cuaW`Vv5qV{QML~WoMI* zH&D$gJ{HfC)P%22+pq1~+#~ym5}jJpWzc6mQ0EW?c6rY7 z_8_8LzO+1E0e*a8HY|EauvC&vjHxLJ6CH|-`|kxAi|I+m59uUY$6>kIN`Qn*2QNt= z@Q0^J{FiZt4+e!SvFf~RRL@8_^%*YUcj7gyKGde~0s`J)gPi(IUflACaI)!}+nIOy z4$Flkwz?~t1UF1OI?&rCc*yV26t^fR+;&KNDm!HIX~KUd@~EenKx4Z+>1!jxis)CG zXDwch3sn~|F1e7m!7O9^dDs5KTmPNGeS*Cba(sNWzV6r6@bl*VTb;c5_)qVEKi*h( zAG2bK?o?4P7*dhJk4MR|zuw<}JIfz8;@_$IUoHcC2>vo@1ELU5N=+H1e<@E$V*gy? z*1d8yD`v=6VPXL0`?w$gV9W+f*PD)<5mW$9?RF2dw>W zHU9nt|KZuU!=!nIC(c|vai8yVzFA5x?V=G^dyt8-*$^n~Y>L;r^LNR8`+W5+9{KQ` zL)oE1`vZw+vHv4&dH14tZ;k0yFk%kWab*>lDHiNN>J&PUJ5gqZAVf&ldDhCHlSduXCtpi$%4EaP_H-5lG0-L8K#?O?xfgl1;$D=;i9FxjoX$R$t=Rw>O(*Lx*a^`w^VXLE=tjmv3V@KZ&P>eBOyvM z(ZUq1zGidmEj>g$?vu9thh>5k{mI=TEts7uTRjl!pJfU9{w zufe7M3Bp4uk=9IS+%iHFI4f7GGPB!X$XiXyj<}A#cslH=_~ztWES18CrW=5H;9RB} zx_w$fo$Zj%h`k!CD%swYHDhV(wj(mi(<}zoztPpU&wMa)ArzOP@8KxdXHvreM4BJq z4u9;ine zaAGGq8kTwLLK)P@9@5ZfgES?JX?s@p{+YGtUhVq4GUIGCC1~ROvD6e65&Wx+*@b2q z7-}&2IbV_z*~AIYuqLMJb&K4y)Gi8E2el}!*&!Q8k!%~dtgo0B zDf4GjUARav3oBUJykGibMOHE8(;wa^v}|9?KFXut@O?_pr@D3TbWP5%P*v?EcwwA*#U-db3&GbwsRd+VbV+(tg_ zl|{FZecGM7k{j3)&b>EU;(RKp)@#!Hj5C#A+R*w#@A*2hz+pf~wQw-# zz7NAvAr%*G4_>{H=l5=7s?j7KZm6{7k!_W!SZ?xu%Zn)Y4$p4>T)k@q8BGPF`g9 zbT`XpuH_PBarp0Ton2D1!%!f>#8XP`6N=8gXkyqWTQcHiiMz12)id1B>boz|nKlD3 zF2v-H42Nv5dzmIxlH&C5N!e_s++nM5*7INd;#a3~3M?a@GvHM`51$as-{LmR1Z6^F zKltU)MEcbP+0;%|Bi>hiog&y-)+g;&1=-{rY_DC&!tBGT>@P4HG0cljX~Cu_{=;AW z!&dbmuF!#3gV@4cT7T3tgsMoRnaosJ8Jn-p-#NB(YS)Ra+4rt91x)}w%Ta7%HER@; zmr|}MV13LlpDx-04FY0+*Q3oKf-0^5F#cVgD67oq6lOjnnqE165C2qv*zCK8(N2xj zeHUxId)S+g2f~`-o5T@;g$Q;XI;@4_0oSe978A*saC1kq1w3k6jBl$nq7IY|I(~JF zeSmY7*mpZ0xj^G#N#W0*lq>2*ZoiJbIuBB?A497I_Qi1N2Bo9Y+#A-S%euYPWF3g% znqMjY@2_&0uju5CxL(>Vex4g@&T6ih-T}f;7yOZy{g0bzZ*9zL+7T5Kk+IOK?!B_6 zcit+_-hawuW*`|{u!bCyIq2bhf|h}lufg?C5-#!;`x%$~hnLAbwX8rBV#=4iV`lr| z`_6rxJ9L8r=52pt`O-HW{x`?q7RIJY{UKbBF!0WMouWlssxrcp>zLEp&oxI7w51PW zUGB4SQLDvp;;9Iyq7&d~<0ZPC7=|4@W&t|Liy2=!C-Q*u*$YE4we(ZAS!>0HF65yQ z=b4ui&f8ZsOh{z(1q1{l*D{s*vVP($&(}vJLJ&2nYJ`vb1f~?uX(w)%Z4zbeplG#(WmJb{neu$N`F`^Ru00!S2iq)LAwv37ag2OV-5FHG!~kIocf zFYmjrK~y=vxIfF(n}HFBaM}T@-x%-tgq`FFNvi&fZ{e2i$I}b@I|%nDJiiF5vDwoY z);!QdiA7$A0z2R3ZSrUyCSpjVg{cu^dVQQ_!cZgao*oGshRJ(25lFpem8@JZ31yg$;_1&JiCChHqNg=u4{Nz(jVccRff}8*g zc`yPWP%apUPuq|Rhoj#LQfbKaR|5rTs-R?VD5@wpO(LO)sU-vMyQrFkx1cmVc{QY* zIFN$sPjsWU19^kacKOVLZt}x4=%eIwGPiKY-^}=bFv%+f^!ktwleK!P=2fEmn1!%( z7)MEN&;1Q!XjK=k;d!jE=2pZX20xDr41%3~2uc&vm9_kWZysP5F?N(u;^|Ycjqu>p z{Pv6b_iA<}=;ncyPI>=a=N*_Yks4wBio@3CScloW7$UE@CZ&f~*)3@|vf|zsBGlm0+Mab!yp&i! z=)jJXr)B;4T)_ZG2&T@!AB7Z3(S#tXTL%Pd20zK?Ajy*RY`VwjTRWPz`xsy(Li%K`TtA%gT?~nFfcBeIrS{M)e z_FO>VF*n#<(q49s+&sf2No%Eg7cny)ju{aUYIgXwX-%BLj?VZ4V(c)73$!k!wMwK` zPzSMKFOLl$v||=2353nZuRCWIf;beo2#(O#K5GUSiB&-(NV{iO{UBu@phO?ZNj1*0^{uwL9bl=Ifg0i> zl@n~j$+mI@Ikf(fu^#r4gb-1E@M&c!7Wz703w(r7WK0Ek^RC`H?V&RS6d9;xd_hbv7 zEHe9mD^@7*X%{4r7e>65IibXemb4L<=B`v!%(A(f+_Z!B5PqU@#PO_#U2!c@`<`|! z5XCZ*nMhdtEu1*Kg$4c|#g?#R_$$SbVajj48O64(J%O*%rpftc`Q>fm!OqoUM+U-s ze1o7;ofB)##&cj1SM-4h9;_L?h{$|@*_V@xCkbb7$uUq!)0N(VMET}|pDI21KhWWS zrtq_b-t!)x>ALx0iKhkgwRwxC($-nt=qw$EeG=6m*G3hQt-?xMKcH1+LkV?Q4#^#c zApy;?v{&<4a@EDq{_Djtn!pN{4D%Djoq^9sV11zcp9Sj=N1Vi_WdX}CgxR0ULHbIhB9S?P0g-6mCWc^{iB9#M7f;qZ@AvB}(R6qEb}DlmCGDm93uh+r5% zjp{1z3CoEbKR*;A)647fnUrlu*ImM%fim`jvI)Q4mQU_x8pp6>(@9pgXa?4_6-P+{ z06S8C^7@X|&|GxWGA%JRP!iCVuWUy{CvE;*i`3tC>#}vyz?W| zh#ib7Q0^uC-#PAkb$-B}X9gaYrNtr0RpYYr+^7zfZ6*uuaHxl)+&6qFY;EBWeJHIt z=DvQJ*Ee~OWoDPLrzE4x4@9?;NF;plc|JT?^-o$8{7eqLHzeEZ$vmJz@=Lg`f<3?> zc!WyD<^Kqi66!YE^j#Yd$obeAWx@~yRCMW*7u4W0R(NaZHC!N;>_=$#IU#H^SHME_ zl3WLYnl+O>ST;*)=tgv_;@PpTie$mw?6?lH2&%qVYdj5eR ztHzwMz=&nSMH6Egz!1HLy(Ml5AF-9dkr`|>dH_qnhqU-k4|0S7Ba-nG)%$b7AQfLP z`JwL;@E<14nA+=Ilh8urjqW9+p8N2KkX?gLFCn{V*QYfaIvLQ5i)(5g=Rfk&<>s>z z7KWF?nbU*Li5PLynRix0y+3mUKCc%rb%2^Gng=uT(rY(>2cM=C{!n5;V)t+%O+CZ( zgLTh1j8|b@HPIV`uPC?@`Q+xRDELKQ=;rP9fHMd zrq(e8w;K=vY~jEI%gfZy!YoqzDL{T>2UFKp8G?d0idswQA^Rts+fZrXi&kPN= z*O%qzWW+@Tc)K`QnHcNosMkEGRVquXlflGOiD9ljtjA2CcJH4@@kCKDb}5b-klzQ= z61bat;Kn8{GLp?7ibq=3w%j_9Q=_~TeC(FIqdQN)6RqKLszRkDRbtU`cHEo0y+2<6 zEiM`F_!*b{-u!PsQ~uo-wcMp?7E^f5vL;D32A2oT--)SDCKOrv%F)4zU=`f>z~mU<39tl z&$jI^UNAAB54qSN_d`r5+bx1p-4f|QX-{`ofv)pf&d=e7rsq>5nyAx*$Q@-bt@Jh7 zSZ^4&D#v!OxX_4_Hw^4!((_$Lp%}X2o^w~XSs2~3fnRRZ8H0vT*>0|`RUZs&fNIwb zB$EoIurm)#qcnDMp1Be@rt-soc!5~1GTiI6h;RuUNSlDxy2GCit*Z0uaq zcq=T@%NbXj@k5A3YUNz}$#ck?rtRttGJZu5lKU}V6`p{#a2X;4933iZFzW;-LrSt# zfBs^5pE@cAglY2F5cF;@NPJslyU|Y}aXUpN-&a{QdP1fQLOB$ZqIa99(u2v1J+Qc@ z@u=R#;YIS{#)?5^yAvg@pvq`^5q=Ov;uL7>$E+2boJe0WNfwu=)0I(qyJD@I_T`&v zgCqR2aONZ8?Prc_GnP5f638dgT|afRo}LBjVGL>=#3{kF+R-c{lo2Z9zrDpQsdH*F(SKqNi zZ)e{uS{m@Z(OBYPj|u&2$OrQRiZcZH%+6}lM#lvkh1ItKH*(PcI-$o&Z-uR*%RIA- zHnKcYGx7Le@lPx}p-qDyBE{vW`rXa0-)xG#+3I*lrWePJ?O)aCU+B#@=;vkcyQOmP zBY!JhMDpE&MtCNoEzW@;waAC^4rR}S`%7GX)+HwPruv#EjUaAwNusoB$=X*n{Yyjy zz7LDq{EEK*^tIgoiX{9eHso@#o8%`@P~M|iKu>81SRdbx?-Q@bFoEK!(q8f1$UIwp zO6%j3IBucfXx=9uA`%w&&h){IKV){A5CT^I%CuWSDnq7w-V*;)$gARR?9IQmsCPzi z#_H?EmqKE6Qga?410U0soCE0s`jCEHncl1ngn^F}Uo&oF4MJ`4S{I54zN2|B98slV zO#*k)w7=YeYDI4H(ogZU8IaM)PonJHxB{~8AHA89HqWwJ*&=;E9L84{6x$+!G0Et# zN;kn?t>yiAdb0_*;x~eKk3u|76&$gNalC+?J_idc<&HQ<*w0O>l})AZZ7l%!FQ`!I>boE>f>x~JA^R6;BRV5M%Y11qp z+k}vZ+n2!2Jf%^BawPCfZ*5IBDjzJp7vAx2FFq>8Iw-72qsZHK4nto_?*d;iSnI^pC@oR`0#lru9 zl8*8o|)*9R}nf`3ci!f zan;9ZMDw`Nss9NuyqY=gZs%9+1qNpRjn&Kf7ux$*qs0F@_$La8Hi(%#`;fGknLod^ z#QA72HT|Qf5ysAi>NAyeCGN~l;bIh~o;eJIp#86`BiaXDJF{r$n>L9c-_RGN{Ryc$ zASfa)<&_L;0^NU#9{<_{B^Uu(u%M{5FqKQTPJA@edy#k_iKNO+Wv^pLE-!RDPGLAJ_X5RP2VVOP(+K!V+D_555md{6;Sf%E1qI5tL?tVRxA4QPp9}uEwv*W1vC}j&w zI~?`D*|cY{tlo+Q8@E>N29do(Qq7IJ3wAQRh=&gC334gC{{RI=={k1XoY(bH+aWY9 zjKYnDyoWrPG&7&ZHTQuy4SqA>_hj`ydr&5}+x@h;glCtjZ8T>#VrdGIVHl50b}G91 zovZ50ru7G-d=~+xl16`<+rH^b6tumhWqn5w7#;0EX{ZJ&C*O+SL+78e{6zG6ua8ysqJ}LV2mLOQom-R(Z)zv_B{G zDsS}B6Nc|(J|oXcuk^@Wn$FaZ@ZkhQZ5YyCs&bL>ACxZ~SJ|T5p2e^_kgO{q9l96a z3W`%W?XcT8`k_sK=6BTkPW&n5dMb(5kFF2mQL;W;CtWO=yF%FdYRG!5&0j&In)4xP zU+LhVy(977a*1Ms5Yd%7MA`r_x_95Zk`gPKXQ4?*?vycC_QVM+GQi)*&H;;ru_{8} zia_xpQl;a|D2P8lCvIvW*|X!Var4+`GTML!kP-JVT;OEwx@5yaP3%v;C;M(&0ALf} zr<^{&53igO+ev}p03&$wWAVNdDzL(S>abgs$6R07F-aO-ok1krJ;lmMgU(#T{aLFk z-u|hF9?4fPIX{$#-ZX37twDO}Ca{lMz0e}jOoHIhEYmn{(ioKMpd?Eo=OUN74*oeB zFW4a1fakOLV7|s4p=X~?xtM+yqOp@EZ!Bg>%HdABc0{*;V&2HXWc*$ zJR}SLud2h}0V`aUpNxvwS6vh8HYVxk_9g#`EkslQtW(r@)t4vy?@n>8=pcKvmdKT0 zlRcI}d@Ao<3YXKuJ-JT%-lMk+pIJ;8uN#~zTdPYS%njvVw*qf|B~6tse&iL8Mp56? z2?HY`j$97EWz*1MeV#9uc>jAYy~lIKv))|h@4Id4h_$o6C*7=wq_+EKgY`RvJ#s?d zcyp~53TV|q39Jf2{&J{+jhG1se-adC8GVU(1`^i?=jGmA`ZcSmtx z;pe18JJXZ5@5kubcY-WKKq6=1eNNe5`+=jUs^QHW7dT2h4+NS+rImC`4Wg;F_! zajGqPMOGbc2PVGKYl4j+;~QUZ0g`6hhFXLjTgR;^-th+_H!vXVd) zmsDJn;ry}bK+BC|P5A|N`bXOFb=e zfEYyJQ588%iAF^!M`%PViDtZZ_Ahno7yhbW0nKRR&_uLAh)L%EZz5V{K6zcbzOUp0hE9<~i z5lYNC<*NLMW(d@}M3bSeErHqL+X9Tj*BWcnY|vUQNB?3v2M3W|k*EIVP{weK@FAGr za_q0B0DpPye~{b$i}Hry~g8sb@ z?++jJUleS%breK`_FQ-=_7&=2zNBO6!&NxP1er-%VO9^lX1uKaCbKt_IwDsQE}b0z zuZz-{bJ~*OiTNOBsDc$6i{s~Y!sE{M+t%y{Wuf)5JY7QX_tR8*EAnZna1QB2;5 z#Zu0&FfC@@U`7ATcNL~V2}W`uUiLl=g?uzcHl=ly09SbX3$VV`m35~9W_A4FadI5( z=ik;x1c9oc&a-ky-NgU!Waf8aGvKw+&6Y2YpoB~OkPLqU`FrW|418W0)cXib!FC_x z)M>o7pP;<0o4uCk0HIlrYjj%bD`J_-8F9vvL?7$-px99EJXvaEkPsrgl)P^?bxAHTuy`^ z?6C_<%;B&ZvRa=5hAs=Nw>zqr6v2+}mXj1gQ(tj_W4@sb50a6(X2;A>DwUTO6V@?& zIb&VnfbW9eyCdr>#*7$KhrYYOl86f#Lh*iMvh!{4cLmX5QKuULbBrX2)eur;)8Lk< z+>5=}v$u6FPIiK+xIPDK0 zFT;;?)}L<^@A*qc-RBEI`I*0#yA7nZ!eNS7u^ujlPuO@gL%4trB&e`1u^=uu%W+Gh zyOQPCtSj#JbUvU*I)b8S5>GlZFwF2K_zny!s*$XJ=tV1bXx46g(WMxHSF-xCVZGZB zxBi@7kRWQ;CQYaiO(XW*{Ln6R_7bVbbz0NcOntEA}k1u{vVkUzAH##JHVe{db8rgmu&m^)**w(jD;2E~-7lIbOY^dy>* z>f-9_*woa(Y2j%}5XG<)3ca27WmbE{A;5O2!i!p;l@kH$Ouyx8Gp`H?5C|TO^^L=9_qn?UCttzF<~+^kP0M4?AR> z^aE6R$oKRapZ$DUzhq1h)2EL!h7FH}^xV~Yqfz=>q1nmcrrbxgVX1C%Qf_;?i|Mzr z#l;>3&|d)QHRsm5*}N-4McT!{54v)0WZOXmps2U)*ydE)rmxmDmJ$zw7Y>AVOL2Y;$jS+Zph_xJ%y?BtVD+ z&5v`XXhLvVTGLeIrL{?H%Ptqufn2$WZUo;}W`D6BjvtIF(ro&!ZXUoLJ*lFNF))Dg z*k*;FG_@c~G$FdjPwhu)s+8s*H|>4DQHnxinqe5@lsU-sX2^Q7XJ_o8OQrBf0QFUo z;A5^x$*B(;6*ve$a3$MbB>lB+gUfLOCq)w@if?QGiJ+#$Ut~zQ{>%WPZ{kLOIvh-~ z+?5C`J99bSU{!TVHYa!#5=C}t*xt*dL-z~QAY93 z@^`TgF-J|uF4dg3N{fy#3~T%Yp)8(a5=IHnSKjF*k%f5k3bh^be55(|{PhHt8c$0W z6Je8vnC4jt7uS1>NbH}bc2OO0ZHDa)v$}M^7xSjyBb`Dab01B8fW6A&&RC#K4Hs-K zFL-FrnhAJWvMbT00Ay83ebO2}q|S*t>WXVkn`w#th~)@J)!?^%`N8%OX+sFr%FmrL z&{l}YsY12TyZ}eB$?Ai7plLs?9L>uoo<3hTft}Q{9=me7!ZX-weJA(wNEk3AYMC!* z7UlSO0fkUB6y7hD7fp&I5s)zTl_wv!pnc5N9N?B=h$s0Cy%+)xDPmR&zuP~hCD9TO zKzJ$XC&G$Io@1r7`{SOwZ~K?(jJX-Ixo0f!oBK!L%fu%nALr-^yey1K-zS@LV?e31 zvR<%gZb;ShzuS8f`kiNf7noAG13;||&*+LLUH`W9=)Ns9Me@VqRWSTsqem3b&vOf{|<1`G!f ziq)A@m%blSKo-~;RO-)&u#=Znz%N;fMDPBhID_bCJQ+pcbq7_k1Gj-@mSwgrAw$BW z@C|a6{c;M?)zc%hcL=}dNV7AA_a2x7=8skbWnV~j1 zkjNGyjAtN~fUwRi7w4=E5CUPvM&k9C6@UK(&dXc9{(>T?YBvBxY#jiAeEz))bRQ&M z2mt09q7EJ)8?>47(MTFF*^)b*LVEMHVRV%6ZP4&)^CpCvxr(7zVn*+KKZc1JV#K-W zQlb7m4)Qq*kg*u_`R`8vaKj|F_3}Y(oDuUm1HxRE!FErQr5FSP_51(>S>kO`a3s-1 zdhtgS(}yWqQj%^Wh?`!292W*lRF16Iy+9gjqk&AX<?nT*5;6GIGPXZPKn!cK@`w7BX^3Kj}r!peysj{A?96w? zUPXdv(ZbZk63)0lMT07KWr$R04%sxBLn3?>I$jnn1(Wyq-2mttFm||eM}QwO)PfjN zG?TC`?F#iLbB?+XZqU*lEEL4trZHpoWoUEnNju4nRHQVZ8K37QoF6?>5pGY{-L(7d zlDfPmsFnOVNor|sB%lC}gAM}rU((Qx$9N@Og20&NN;}+f!kP&P0J+U%$*qZkyW4^p zOz&pI8_6}gY(iY#R{dzzbenj;c$~V)(dARm+-cZhRXDA&Ybi2e-=}D86M3wt=hiG# z%nw`ww$6Un5(7snf<{W?8eC#6mp~~EIcuV&Yd6O0Y|C!V$lijFN_EEyS;krDf&*pD zwQ`!R*c<0*X7ZiR3=4&(n$nnFIr|#rSCIslsqkpmDn90%)K`J*ls5wsn!}`)CO}KS zl~y}n3!QPl)dmI>_78hpy3y#a+%13R+l_w+{3(n z&A{V|%9QW?aiP;w-NkITXx{T~G^=Gf#I`tlKZ zPoAfyFn?$fc%PowTg{DKK(w=QV~p*T*nQA3la&`tA~Pgm!5C8~b6bgRe`e_~?EU&8x0mL^Fwb$@>FApl^g$nG;@) zppTzQxX6pA-LS74)JXWjKYsYc9a58d+R3m4D3!bdcg!s?S{5{;GnXVlwY{(=ApW6L zZF|_;rGL|49ZyFV=oBwz`u)uI43T}PF&!T{AxRXMgpE3PJ4SJ}NI-AE|b_|{rBqhTa|gHu3lrQ zHR%iZFiUt4eadFMl)&}2Gw_1A+@h7FAVvXHhxBA{`{6>s3@^`g{S`*)`H6=qNl2%q zV5y;WODuO|;niKX&g8RJLfCBH2k>M8P%o%nsf(8ux$h{z1yWN{{u)jY8)Zp^bu;Xj z&_pZJLE?7?fVig)MP}PziTg^V3j7$@ek$yu@T(3|iSf?HmaDwPZDGKT;Ix3RCa-|5 zuR}P_vW+A6mbcComnyY@AN9&O8FkhP3CV^Esw#ufW&trJZdL6;Th~Z#fKuTdvjXYg8aDn(Fn55g~=PJ%X95}QLOWc1|L=h9#U;iH{`m( zb@N{E_8HooPrEghPg<4_R1}l5O_h^x>@kx zo#P^BeHP@R6F?cNzjv7IsJyYMYvLHAi>;m??78z*lt~nTUtOuI+|FH27QMxp37cN@1ZQHiJ%C>FWwr$(Cxe8C&US-?d?{_-? z8DsBkWg{cvj_Bl!h&eA*D=wcig0PwhO8dCyA@DUYhODg`C;`zQP}-MI&2_t@?9#q9 z85HcS3Qf%Tioa~O_YE46lOkODCOENcf+n>NJzuLoUreO|gH+{QbbwOj+K32`KIq zf*+8?Vf6FtLv@?kUMy8hq-`qyf|^I*wFPM~yXfR(qOG1`E?OdpPgCm!*cT>OKb|Z4)jaAx^*?Y{U3}#eIqstFA=MCX= zV2xn@A?602z3vm>maOGOlz@~i9WClaK!8N|mFOd&tG9y~+D)wMf}u+x51V~oHtXGM zOA*bKPFgeIPq-63*N$b#01+Z53rzk;q|f(!8cKUU1gR&SbXhkQcVNTQ(^D>3saF~P zOmp}=B$D%zucjJAb8a$c=uPuOv+TA6B(Fl1S$NfNn zwWSo#2Tr$~g${r=v#jmrSvnQx$4X z(55yMJO`%}$qos2_GejLfH^J#T69uw#?YFw04Z>Sb<6H_B-zb=ivn1d107l#Qyy&z zs+WA!+V;rF93JKC@3N)~oK+4^!e!giMhpOQY2ha z0JR}NwG~A4c(M9q1A-$Z09$j?V!!{C9wxD|J@<)Cp_23ahMwpE34gI62SLjH-p&fl zw4f{$(1ZmVlp^24{y(224oL*MuV0P^z07&qR;DALXLJbPSr;tEDp^?Pyh29CnK~ z$!VDki~PrSr4wPb4aV-lV7%NX}79}+CK^VK$>HM3`p-@1}d zg#x;{&|3QH?QXVFv~3a%4z<(QFS6~$BKRn%z09d1B!#V zZ658-YphJgmB@*LiYaX)GKz?DN`!_A=Lbd#y3=rR2a^h?8#Ua>C~BSc z%=2z0k}+vqYtwCJJpM@}<6eckHe9foOxQEkaf&!{PcNDyLT)RZ_Rjd1gI^8p7=HXo zBfYOHi3f~BEDlqM5YCc<{?weinV7z5uRRNHY8)c%qSv+djy)q$jJ1ply9nJ*qcS+q zdi%qnL({Kb`zJzE!ys&79`F;Ym13Y87KbGRn{y%P0 z3S7E%XJk1oeSw!Z8B3xfLL?9K6_w%yX7TA&rD&ZFIls5sNbBj*9adm)IHpUKfKw2d z{*@33NmxV1Tg7ff5HgcrithpCCWX(zJ3?K9zMhrtT2ev&X0|Mp{ZKR1c9ylzS(Eln zlFOO*O1T}+;IUqJS{QqlNPg#n2Xjd-!EXe>pVE*zghC^7!G}zcQ5)6lk@s-o5Ln&T zSXK6R>@WlaBf*R{cpPn*_K3o6Vjnkm-iD|^gG&C;aF$tl2!@CpoZ#Spz*JIK1~x0> z^ccgqk~6Hi`x9^vI@*AAnHWni1Zv-V3=IxKwv^}fWq}{Gi2t(ejQ@Vd&1B$-D11HW zDZW?`%&!jROUy96VvofLJXZw}D@N+&0Dt|?QogO&%A$EHZLsx2;wp;1w8bM);46ti zNfsglB%C$|xjuu^Jy>l>wI0fBF()87KIMn|l_jg`(8#e*AKnp-lZ=RXkznRI{D}j! zBdx-QH0dZ}8GmDEKHa>+V@Ap|ftLmcN!L{G16ZlzKWUP%%`;#x`@z`;!b15Vr5y-* zGn3s;U_~gPGK1*SI$P1Vmci617;^k5m};lrv-c@#z7PWcHVu$m1tx}6vmCEkz;GMI z1i5avXvFEm1sTAqCCs;tZd2%pvN8A*Y6vYrw4qta3CIR97$i?G6tUsUkp7Xl(V)YJ zCGyiV+y0JGwbk!-y1i#2ssb5&I20d4k5>Fkd;8+b-wW4oZLR#NyBuQbW?KYo6(k8z zGOpB4y%zIS`VF@KO;xv094kw&wJ4wzjOM^|vp?+*WSJOFH%+(~{4B9geKc?R|1 zG#~I#S8R+hDth6AQxAh1gAQ$H$vtsEk5+F#lYRbir&v@`bILjUtKZ1!>wewHFrRKh ziTVM@*G(z0Fzoe~2nOXA$FTz#eKU>k^)XcHl7A5LkIkvisenq7>n*_BZkve)6 zRH^rbrj7dXSQ8WZ`w28FbJ#M(%wMh|!E-fNy%hSof))VvN zbfGc`%7OE!ENjTAV}HVwty0>^^e%GvOqs#m9$-AhCWr;wQXieIrcE1l9_P&-rr@h- z7n*T+cz0EszkCj>*8+mE+^Uk(D@m{C)6aNyfDeKLO`>v>}namP?4|M{p!X zB<2+Zr?l83sl0R^XM!;G(vZkr@(xV1;Y9^PdVeQ5e+n|NADi3O3u;Umu{@wTiMee! z5x%YL`d2KCn2c+Nj5JNee(Znx)_-Ymwg7H04_t!%qF$Nm+5J5^DM=_VVJjE$}1}V z4ws<=fc)x})+wK%U;O~BwjLsUNu_tV=Kn^>3m6;xp zn1m}vSwzFg`rIPcDA55iVi+Bryq%mir+@_TNPGt6&GQ=;2E;f_)PRp)1kMnYVhsCI zk;tC|PLdDiQ{V#P)8OQKq^~5ZQ>MK(GXENB?%=tv6bZ=t`GT=^ zn;FnS5O7Oy)IcDhRkbt|x_`rjW$u0)lf*Fb5$V3JJgW?4HT*a zq9}(125Y?(wsrM*!uAHyU~wj0e0Y?O-HR3mH$KEva20N&dpEG z^yIP*ixI=5({}%1e>3g-B^BN$CQG*)h*54mo|&X{2#XRI!eo!|t?eG{Dl8-9 z;))WnV+8Bsw+}Mh?Qhj1MCcA8vgKOn3#{8l(yiBJH)5s6d9^q}XO(y=$SY#a)?0qO z7(uwHtq9ZIqx0PcG|a@Cduk@VP1fozqg!=V+}pkASR;k~??t~~bnr!?uAs)(KHqN7DiQ0yEo6_g1$h#D6GTm2FxtUXrzm!n0n z@&~$bV6;1G@qj9)yi{PNDh?^BJIYe$bu!#9zTxKTdR3Le44jq;4X(`w7>5mr&1=Pn zo$rpNuQ&&1U7t(YOt^I%*@T1pdFFB&FAlSlqe&Uq-*snYwi}WB0V=Y}S*-m)q{kD| zru>YnjC=r(Ve~hziNlLrw$-$_h$Bsx8ok}y>dSZHay>y@*^bq7Z9@aOo!t?9X2c0J zifq`wt(+*U2&V^m4){>%uID0XB`^oo)NY4@fI6Yfrr)ahBWOJVzkrwzEuwpfNgUjD zyyqsCs$i!T45T30_c$!#O31@(-%Bb_hMn$G$9O$zd8Xf*uw<)JTd-_G_KuA4JobN_ z+dSMXdQ`C2_y++TIs@o-UsAR4;uznsN_``u(0sxKoXZ*~m9SYH+Y3GiVYNJNj2%-k+WzE5^D*LlB)Ff%g)MTjs&Nlut?$9BxRj=*;S3&S~K;lh{r zm$nz9P4kl1s=Ug|489SrbljsX$47=F^ObN8WWfu zn5wsX4M!Dwn#*?dX2uVKwG8oiVlcNKN3U8*h*#B%K^d{3q8n?juHaH3f7KEgyHRu z=Kk#Aa4K2XR91*{g_Sz?ye|tPHQG5MLS1m+L!tpp=oTUA8@%(ToV9wRDgAb}jx7@I zs8sKUE^_~;QXC>(10MgiS&$^IgX^@Non%#I81c+Zk`2|r=|qNKFYgFb1xhAop%ruj zF#tmgg)GtyzR!c9&9~`y#deP@;dd9aYqV7ZNq=cCZNf0GTM4rndMZBINSBxtxg&V4 z;Rmp*?52aPYSi_n$wjp6-ZeGggX8pXT|57pzSVm4BQ;I7$nx7r8%(*sI%5sLx6>AYMyMut7FCar`wSw;KX`R&fR;WzpwSJ8ocUw@k%> znUgiSBOuSGv=4Fp1w7gAFaiH##r@(k#HPB#VL2Vqx>#_iqx4Y}a=_qZ|ngPtJYrg8-I_py2+ zBmRe$Leh&#{bN0%j+~?0_0tUEq?v&)dKR8e?CJ!hy z*r&@O{)rLG<)4K+Fi_4VWl~W-*VWe&lGg$Pmj2gO^T$+!M%vnAAfI77X#gM(y8}S( z32A`>mJymnsCaN$j0rih?4F6|* z*@AjifvOh3ZEvihmFJ#ezWhYD54X;(rRP{IM7As}`*)01m+f#L`$>GL&9i$T8u{BU zyaMY7E})Ce1vFyrI+cxB4q@M64Qum$JW&LLL3Nf?ceQ;+Co)yj{h5&grT@`O}+bpfGR5qtOBXv0u-p<8ETti^Cud7l!(o%HpMXl=Bx zetk@iNv}`_>+j(z@50COb-6e<-D5xLiITac8v<*SA|p><4w)8PmY?SkoTU!l1sp`$ z*Zfvwma_EfGpcZYke3XAXgeo>1I9FlyJg|!y%q!uRw)5pRu4|Y?ATJAuIQT4b1sjA z52gkcIveb7DWET@VlO#1dZ7^(XdtW$1jW|X~jlqsSwNzk?U9J0?Pq6f?Bv} zc>%T~T8nY5{dwSmO->e5QY_R$k|U$c5g52w-n{d^n4M35 zYjCaYqV&cXi@Fm70`Ad6VZx?HL*+F9V|Ce$MsKhSTr~EVCCyBaw7e?KZ^ff4Wr;xD zEGFTKkcf|Zj563XCw4<{q{`?F(rlY<^Gf<4j)Z?!%++MUY}L9qu!WgJ-&S&bUlsVC z8oUfWH(|iHi1rD~tWck-UN>LNs{_I&U?$RTG=#LM_s2h{8WuX^0D|xn@pD!I7oS&g zPaI;r@YtHmh^H>zr|77}ON5y-gTNTC+u?+pYe4&Afv}^ob@cY5!y(vw^J9+CD$tvn zyf0Vt)qv*0?(PiqbxjW==MrqpHrqSoAnZRJKx9;c{MzNb9|w;^RR19V7&wqyIoS)B zM_`xOJNTRJC}x286{6>odvpj+7>V;jv)3tV#Nxh|(MM^ly#?tYR1$A;_So&O;a@5P z0$+F8>hzVZ>W4@sigvaW#?f+2!x&2DnQ79X&zFz$=K!ugt=kdz<#R!mqHdRwo-`~r~v zs1M+e7{+i23c? zaeWl6^f!k!OA?}waB!tQE{VZ?hppP^+) zBaLPY(^BAE6z3L5M)AiS3ZxT+hteyx3py6cTFoljMRonn^@jvHcL!h zW%M7fs#y#C?RQc}G}@bUiN`Fi^J#2Q6r^c!oGI!gQvOg@i#`f^WsH7TqVOO85p}JK z((G_roiPE9260TQAy#`l#tv9=)%Aa#SbY}!XaYZrtN<(yd|gW}rG`H0KW;rz4_s4q z*T+mzVJvP*C;KWK=5Ihmc@(}REjOJ$;4sQIV5(S`G&wvsQrw;gLFwqo@3*=Z5Kg>j z=oiAzqHw`;@)?3+Dob9R?JjLOP8akaUJ;p@N zI-X5>NfuWPf78Vzex&)Wkn>^NsV6(P@ldWKL?`761zTle{}V79-w(5G|} zMmt4I{yta!V#=L0c51{gXy{fEJF_eLL#2dN%i;-5e#Tg)}qS3mx`X$ zBHBT11>^CyOE(TchXu&WxeDuM?vkbOSZu&ibS){mHsAsD3)aiIz<_`WCGD~(`AUU^ zO{<(%`fnejl_{Z#+1>?W8)&u0HCdeo-(IhC%A$KtU=T9S3>b-$nS0!stH^hlG*O($ zG-SMhYFCZ7W1ddLYi^w}@228e4Q*OyS~)a)RCFw#fx?nxWTN;K@uk2j2;3SY^2+Io zvcN?-3B+CuK=PwMpiH1beZ*h>=vt?mdVCRDeiekT-hv(!%gd(Ca-PykQC3-fu-t56$aEs@ZK-XLg?7$xrQKqIdo}~6!kK;MH=X$+a?VRdb=oi^!8L%5(~!^My#>~{@k!oaXlO}py=?2{2G zF}sCZPnzrGhLk(kED_nDKql5S8*w$=x8Lm}jj;Zyr0`vUu%s12llfwD9}Sd##Oha!EX=yvC3O7*cZftH6WkZ zmpxC!H!iaVb{?4krV_c;91W0w;O{(qDt__>Ts z51{WkXzYTL<^=f{vybMA|A0wUuGymn>`GQYWCA-$Q_%jt*Du*%YKF19d%EHJ^1Zbq z&iDu4IqJ&OGGlnRR!Mcb4zk$JFoFDSaf>2epF(%EpkQdELP%F`*>B*JdM@Ick9pfT zqa`b>k6EoDPYYoFmA#29#DlDsb87#YMFoEUZKQ-RCJGYZGC?uc>Y2iIO=tpjy*dQ) z>Rcvb?i0R=(E#0r8%mfG-|vZ3)F3BB_iWh)*`26(^~7&V{6Xp^n2QNX1Ov4m2ndC5 z23pX&BiNf^LWd!Bip(XYl99ZD#W@LX#fH6Wvn+)C350sP#;$5dDJ2Vns7+O#xjgA@ z#C~Gl4*TN-4kifMPTd75wgJzjn{ZDwel!Q#-(1Lvp{fkkhr3OjI$^>0$|Uq`$r|0^ z!0X6hSC+_wFH61Pz|FHR^#LL|70af!Z1v1&aeu}O4(D0+3WnBpoWBX0r*Ov}D&Ga! zx)Hc3i$NQX%@%g_Ej@kIm}f9$VM{Y1hh~~|y%~ZVL zhX^R+r^#UF(XF(z>m1N`a%(m28NFiKTphn3CIF+>ckd*j`FX!Fo z>I9bWslDBj;j`~1_h4Cq`RspZLMsFiY(@zPxafxJo8y0PLt>wo(=dE2ZItHu@ z9(JwYr$!lAV&L53`^X*dc}<_=CuqXPjku^2MTuN!x(|Bofk}J2M!zovEh4gFsfqgT z!Pb49S@pXY`b2+M!Q4>R=1}5TzdlSoK%)gHNOkoArUfas{t9> zUM+GpMqIuhG8mqn+l!462A5^5Tyr;P2ns{xT4<_tSfxx9Vf~}^-S^(c@<{9+e`D2&X>>a{E8YJ)!cFwbN>faVW z*ke!YJyo}r5Dt5Yv}~e#tyFv98|!Vp10pd9sK$Q zB$ys%!Az8y!9&`4B_-p%j~YG!Q4_x(n@rQm9J0j!aScMfx9&cV>Sd6CSOVN;p}M%b##tc*mD}GBuq2R1b*J;7Z-O)Y4l`W^U^;i5 z(=2STh2$0l1PEih{;UbpPOIx^X~VCore>|JhQkpaX&i8@4r)#s;d>*4KQL^@KxTdi z2x~F^}B6Z-B2pa)S`)5 z1QIN6mJ$v#Tzp}_%E6CkzCO)c+XX?J0p* zBKLqsx&Wx%wXL`WtL9;f(^pLo&S%V+V>f66f_oh6DXA1djzWRRX#^j__Ndn>;lU zt^_G3WCwzH-o3-Tt4c~;=BqyGK{nW%g#JTf@ShA zxYzFTYNC@Ta(cve3?J_slJojK8qWy3hepl7NEJ3y5a670B?pBwguf|O!Fm&N3DZit z%_Ytelfi|WqGwOaPWMoT4`k29?q_0mS-l59_hs>uog3?13+Im*XBFs z`slL1Gy;;=X`PuuKiExH;!BzsRpd?U>he(|X?(kRQJz^@EPbbypu^e9S@s6Mb7UuG z+K@6@;QUu>6wGDT*2W_A0H%N;ezZ?^CYz9DS;Q!gI=16Z(0qyc zf8*jnmg~F`wm5<@$4%P@0D*I_CV^Z`cgPBj`{a1sLDscRwR+mhBCnOU3`?Pdj0m+T zKi)=b{D*`2-<)v&H8Dx*uLJ!7Db#pQ$j&J6RqnC~O{4*jRosJ*3g&qk1o^MgI4cEG zfkbWUOqj{obaeys!N3GSJ1Uma;;fMhC@Hs z>Kybay0iex)^x`{`rH4CXa_5b|pn$sE0)i$|h52cE{J+W@=joPvKM&HT`N)D4>5RkwuX7CMtpBaK zX}Kxwz~3wMRq`ao42^TZc;NxhQu5s0dM0TdQ;DI6k?uA1T$VL46X1#i(j9#sZ57Gk ziWW{)5cH`pxW!BvEfgs)eA!YX|ubmz;IJyj}K`8FIBC5W5ZyLJKz|w0?xs4x5buze5v^37_sBAxpcx zk;)W@-@RA@s5L?;7|3AeHG4F_NO)T^w?OpCLRtnH3hf>A!K!)S<~F?So6+&MF7?zo z>=1;WtaNg(@ao?SZYBjVp5IDN62ZJ@*O4|WO+A+lqMt5lqF=5Pd{y^nI?|A6a-T-V zjo&w9wU`HReS$Sw4~vEEYLTrEF6<#)Pr5En`CZ6SuF?b?3K*6hO1WHa+?QQ2i6Jfh zWH~3n8hMwH(Sc6b z2+XXzx+Wi85H3(f9eYjOS2~8!%6WC$E2*@hFbflU7Pw;uf*V(>8@>wg0MgF74kk~4 z`D+v5N!OC7ROwaL-l4g z?pI3ihrWw`-CEA6UTA}wK`aN81TSMJ2Jsu>H1lrdL8#92Q7AO<)-*-+P(S$sX6KK% zqnK}=a0YrK#}hq~gv5TtQhfM;YF;<4m@m;g*x$pKS;@l8*H!n*{(Z*m^#1pkmcxlO zVG!43nfK&7emd&5RJ*i{f3C-9p6f3lQ5XgUse#Z;DzID#-K)Jj=Y$qW{tD3pt0(t9 z3qR0E!uMkI!e-!^{K~d2gA<&N;Ni)`cfFwFUtZKde4Kzneu?qILB2`~URHKUvj?{* z<1iO7=(}zkLhk(Wf=bXVf0d2Fp(baMcM51cw#|gJOGX`f_Q;>iY4rBzisTpS$2uA~ z7k6H>-Q&40^(VGrRE%&J?gy3JNR7p_#C(OuhrZ0P#-Zw;eWTl4I$BRim<}}$`3;>4 z3ZGY{PWt*0_V*zQ&)30Z@2ZWqVt;^7fL0bq0LggozwD$xu0mM+4k~%KRtw3s46%X2 zY3@aqmH)*mBkELXY+AbiYXA0HDSm%VXL~EI@MctpA*_Twpkt?A1$(`GxYrK2$sT^` zD*{^n_0C>!&_P+hl}B))sMLJZR2HH01I}&%Sm|2l0lmOrXjm;2N9SXhjxi!;PeFCZ zRlF>7w_nQ&;b3H}5$+gwXAKxZqz-`>(*-Zqb>ef(GsLoi2nC~{plk6P_dVuflBxpw zFM#s#m8&X^=gw&{yXpu*m`F;vUR#1(VI@1c$E+X25Jq_yn2n(EtM&dA@@@>rqvw~_ zKrZXG4Q>04GR4YWUK6V8A)_*13Wu3m5uZn`UBckHH^l=)hhJoWiU^bHRIKYl#{^H6comF;8KvlYi>iXiVa;fT)Ill<%)AtY+XPv-)7(j3uEBFor@LspYe$ z##OUo^i_8$OI~d6{V{PNqcO|w7s)>F+m7yKQAgRE$7%%XO>`cU2cnUw1atsbd7-CaXT@d8#TCwdh$vq6&$tM{?s zpygL%K>GFaBpw*y*6_<33f=jKs74YtEhq${5Fc3)H8;LjIV7W=X~8~3zvVa(lV2F< zzrOI6&;aCoG?M(){}L=F3VT=0UD>(|I{`~wEvcQ+GgRzhGkNe9$xVp+%3JtdgZaB} zAyd&Vr7{^sxN}J$*ka92f`v7s!HV@1Hug&hjudUIC|1J^I<`=-7jT3)z3w0qKe$*2 z>`p=u<|8tyD19bL^-fsS`sYdX%F-+Vm~bRXk-wMK)sTH%S)X8kg(GB@|_toU?9A^6z0DX4S4R}Tg?Ud%eY@9K8F|s$K zF9bxe5sH#YgoFgA!p2X^v3MwmPgE{~f}&LIBu;_o#K-*882ZC<4Y8~hu+ZTn_>uXR z@fU#D!JNS#zlIpvL9CL?ft0U6g={SRw67+t5oT+o)RmQ;koE>NzdPt*MfKqhGiVUt zH$b4yY6YhqTBBGX+vome^cEY+YvCyKAIacmkQX^<`vh&9_#uZ-qOZC_J$g??!OANQ z^fT&Pd%_y7@a)qF5Lmd&0{{Wu&VqlzfO+N5*s@Ns`oo*tgA3^`t?Z2~yrk$d*mPT& zRbxvtIR4uG#2Ff`-)Tm!uBW}EYe|>M0D+hW;mD_}{;>Qa>q)3|kNS0r&8#IdOFp+C zsxk&`A1~Do4IhB?GR0oDZNwLGH>&S8yBD{7y0slG(WlU z^aQS%k1H=NZ7Pu2y}?^IN%~Ls`$O0pQJ^O?_E*RmUn?Ab=i1$Zsw_TqTKzy=wv-8f z>`Tg9K;#5pl29IH2rFOy%_LF0Hf%)2w)NUA45J!U?n$x)Zoj*WA}<2WpZIT zs<#SN(ghm!bl{~ufF|&Hxx$#OLXF|zlnr)cBhQcd7>DxLLeL??6HIm4baIy#Xr9lB zjujOskt$`qqpgSpJV?h>J+Qs!{zl;HCJ)glh5a*pXZBd*SxEVD87Y?}Q5VA@a5$4q zRmQ`8e}cE5Ex%kl;gR@6#)$4PM5PWA_N_S{0U~ge%~ztu*_BxSylZ`U6H1yEAzl6L&db1C>HU-o6^5Ad9#XR{ zZGT3Frx{&!NqRngpjnmhuc57UsEj+Fu9LbV`d8ygSJgJu+n&lUFj+^oy@jzpfmL%p zoJ-Wl;7w-iX-dpoU-at5hp{S|?wx1Nnv?}aqHKAhmGZ%2a)il+6gtc9V$fzViF#nC ztZEq0<2hj1icU4fa^sf-{Lpkhv%2!9r2`8mi}OCS+lTZs7IRZ`gBn?b{dt{JN=}U{Umme5tL!KPd=9X=~B$2 znvWtlj_8Iy-&;L=DbF`%*y)jF3v{$V+FIO*DmPtEipwesUu8*f-*>H&=L1H$FNy~V zM74YL?|ddb+;V=j_omy!0j@z%5Gd*-pUI`9bPb{4P3F&e9TiYe51)3X&r)YYEl$5f zl52EU#}G;j#$f#@a-~dRx^Rky-=Z73DO2?9F+QajC2$0%pnS|1fOL^QiJl%Z{DJxsK;g3oAW6K^pXbzQ}Bvp z@QA)u;4{b}lJopHx)O&Fu;2KfyIYUfv>C8Kk-gLlC#S^ViMhC}9U)0U?yC;t^WR>i zda0)w$XCm8Kk|dn*~8o3~<4@ zR^VMHwQyU{9}~)=@Nj2-c>czjx&wQ?ALk0V4Kf)_d?PN(#V9@4M zp)=?~eqlnkJxD{S{N(y2M^wMwJV7O*bP-*o4x%9+0l+1i6ZvMWUop|1cw%20z+ii- zf~lO^4@{#VA_0S5Yvb2bwQ85<( z0FlPZB8T<4)8^VeF87aCnFn;0RVqMkRhQb3B|_i04!=gW$s0}^kTFyTGHEeLAi7`i zpG(MLd)N(qmD5tUA88WETen(k@A4T{A{Z1Ur4a`gB&b4^3n4A7e16EX1#Ubd0{F-z zhL!R8GuD7fArznhggiuOA#wl$A}dm@#}-$svX_j>dTYL)>Ga=TCChEo8n?c8|_N#QRW24-1i z=Dz90CW(~g4z-1+zRtXZ3>#mC1!AhFA^&uT2SQrmu3)+UGxBkO@rD04CoNC~CaPss zbt3=ml~wrNjLf&PCjO3UPj*@%jFw8Y=#8tLuZ4?X^6PHrp*XI9O6a!v4q-!{OEZxG z&2l0-58gI++p0*=kuD?Cm|*#SV27iF7AGSd>q&o~@1~tf>T;YB(Hhs~BbEzO4v&gj z^%hjJEfmIR8cfnZWVGh&-KWjHuprKDH(>LH!GDy#k-)0eFayZeMrJ7^E$%9Vf;^-Z z_S2eFy-K^22sGx~-ugCjJijgwqk2BrL*dJTFEqYx`S2jK>+)Cjtm1n0vgA)7UTJ1? zQ~$=37(uGUM0q4+*>iCBg;}_EyQTyIL`bCHBb%i9jKNCT0StqoEF8F=8Xal$)FJ35 zoxc|)*OK{O5LN%-gD^&$XmBr6{ZncoU-wTMhXP@Pg7@=-F`(fJa!dbDFEWaOJR;$V zy(ct*@ZW(h3GYhEC8Ci(kP0IjO&-W)Evgyl>?WnyG8{o^T6SulOY{Go?`n9Ko{)Z6 z0Ga>ck$z&EMd_W;O0C$R`P(UdXfP5Ly*d1wX_ax|d7BTo>+JeynY?oOPWUg;1s)uz@DScldu)g?_|E%gF*t+|sz^B6LOzz@o&KSuZZC}*F zIV8mtxCjrUj0i$c1hl=7>C6cRoN*8x%yhck+8WE2u0P6CGY+J{8_?!_Gd(hhQO<-O zh+QLaK3JkYkMs(gKodOO?zVmx^yIjeHW4!ZI3ik8tZSiL$o7s7nru5~Yd?GZ%w9IFc?pl-w?xAaY4xLXA3g%^i)nVZt#G}i`qzRE zJNDA;1$)3fzD7>k*_q>B3)#w&F}#aN$4T@?}eOobk#eTPGo zEE>EegHca@Bk$$OHUF7Yh-eM4j-p@ReF|YELPd8dqr(z2; z&L*-hzC`@d?o1Nu1*^#WU?p1asROYlo98>;xY|mAC5vA}oyUZomd98=^vx+q!QB0< zBnVP@H;eD_1TH6PhKNgmq#IU~nhnll55#a4any2Do3;j3#MS7_r zlL?ai5@}G&D5V{Zwz(wCa7FkVxz^>#1joqjwy;^uXQ!2mML5B#=`Zjpp%TQYq&=*0 zs>7t)#TEEWu0{n zd1S0`#rUgPb`_`u=g2J%@R`gfmt`x(IAIzYABf3eQlu&5oviU{Bjg(;T=T)M6oPo~ zI2z*dn|wTr099{8Oa1>zFJi3v)_kBJ5yQ^jEl)P_d#^lI|8bRiTny zVs<$ohYi~E>O)HiGd@H2(vay3 zgIx%L;EUUa*ycS6(Vf91=S{W=#zIvWe3D28>Hkgu9vAwtxQV(C+tz5A%R3s*DcVbiX<3Wf)af-4PWjZL%JU;cuaE=BCpft1#fqEO2Wm&5^><2%?h{9;`C{SP8s@fT7OiHQ71{_o zMf(iKBa(~+F%W%FQGb$#9fJwqq|_m>U*6K1$4*T2$vX9H@c;hG|Gzg7Vj{Ci=*6S5 z^dHCbx(GEUcaErRT?_KkS2hMWHCrHyekvFji%)THq*s$^&&{vN5B`4=)f}Aai&(8^ zBn|fnoGtIv&z*CFY&Z?RcrHLm%2dE%x39CU3)9%1!bFNutWcv1T+e1fN_fcqJ0sa!F*g zf?AKg<536oFt#yBi^uQ~syYBGkZ*cAq5jj_FH=nd4joM>Iim6PBD{GVqPGJDI>SN$ zdeTfz>^+P)VrLIVYVLUM-$DH(>AlNZC}*i-B8SW_-68RBfWAxaf~iUCv29l^Jf?p` zTH7>ydWL@)w+S^BKxYDs zHxgu(KEWh-9BT|#!72pg$vn!>d)7Dd9yfdR?0y=&1t9R+rxItJ5(TkxS;!-$76|C9 z)a#(xREol1cgdqG!GJ~>XpOq%`|Cz2b>rjUkUaQZQ?+)&nqfN%st)qLzhj%~TPIEz z!p_q*Oe18wfMvzOOiKL&05hCKGx8RZX1p3H8Cy|N@sA;!` znGGKy)J|jkf2jLrAYqtj%du_SHoviL+qP}nwr$(CZQGvNKiIe7rdu0zaFVX-PVPNI z=tiWP67OQabAK!hJz31oD}MM@<|0o)nditl(?MAenU-DaSqwXTSj~Z0H#g2PcmR4BsV%1R)l@T zUo?y4Ls%%XLZ_Fw9;o(={|Z!>OXG0wZc z=&JnpgmblFiUqB&ez|ku1PBuhDK09t@hYaFbsp#?dL}0_kJFW?HK^H8+SkA}(uJfG z`a29QhclXbBBmvMK+zf4g2sDhl5h+u=M|_}YIyQ=k?~5aQ~qEAuFO z573f)so)@o;WBT!oJE^2NoJAAT1=^F5&EY?hQ1sn>Uy;!-8JHIy$mY59cLM+X#Tm% zjGG)+fosq1nAO#Is_Oz_G3{%MoRUoL{kv$S=xrv*GFtxPjVqR&eK~PxmzZgF=ZhPP zbENLKH-0)m2XO{A9bqf+b19zb#=*EYoo}00JxMLlzVE8)bCw)P+^PZNQb0*J=27}%8%ryD-bYbTBgk6Q<;m494 zVsw5-+NI~$W9Y4`u_`TZRz#sKHNJa4%dUIR&WS$z1q94^Ixq7dTJ{uk+;&lu00b+3h62@eKs$`QX3C`iV5P`9nw<{T%WBT> z-cvCwUq?(N*y5KghMI{WC}zqPLa zStP*$)$GHt$93zu9obB}xU|=IXbI8wZL7H(f>A7f&9;|=!Jh?7S|>P#C%7YzBI^yX zOf}_nDg5>G0uIp%q&*bxdkd%>0Aj{SsL^gpVt}*Xom}k+!qBGb;w;OLs(xP?$J}cx z@Lhi{6*nKD9TMVFKTV&(>Opov^?Wu zPIyk{?qk`f_?mEnVzQ&CH)HNl-jqVrZR?FnQ2A=TUxBY7wkT$v;e#OK=5vyeB7AIT zbizUY0x)Z#bq;alPri8#1xD#oXQ=?iN@ib-i#w=s$zP5acJFWAs`a)-mT@;xqbJr^ ztpdmodRM1(8cI?YA!d)6$?KbA74{EtQEo6Rc+X%F+cdXRFh(H;KITbcAu1&DDqBE}v-NmkC{Lp!;>1@*B}Q4e8+zSL zK{$`?F~&gA9@`tiIKd-NwJLvcEUocQn0V_1#OL$7Vr10 zvKXMYBy|oBF`)HtupMv<*z^Ll8mD1SN!hZW``nL6c55g3*6yvy(dX1WHNg!}|1wQ0L+^^l?S2Mrp(?H3*tqj=Db?K6d_l%D2a zeQS(DvfCKr3%*P3@%jI&)O>B-K-}bwZa?lliaCY5V&2_c6IF~0h&b%N=5)GP*F2l0#WmdIq`nHsUegDi1y$dY{PXxJ3Kw88$L4=|mYc%Gwq2|%%&Mq5+tY>Vy(gGp z^$)v_yOODI5UBvY#qGAKq8UL^yXJ4eshM!dF2_RTrSFZEz6lF5O17`BNZP_JGvffV zHndlW4uHdBsRZ~Wy*Xk%A%{j`nW7Cqgry*{roDF2naLJl%_%fIJZ13?SfJa2(8 zsk@vP-p2S_!LSgZ39`T=-yBq7he68IdMiZTzuOJr=lVKzvr-Nm<}g*HWc6$nl&{$W zG3k;$)+F=3U8a;X7zNIjwE@M+_!=?@CG_8CWdngsM8o8Mpk@V_xK{&3I};Su(`%4) zy&`-;B(hWtfxSSrf7wbfw5yzk)4-Ox{4L{oc1xe_2N`IzOB>=3Kg8(%L3{xv{nzqd z{AV*YWzXs1Oz(+uS@PEO7{E-ZT9qoNR5)upUdWlvK6kDX7d>>TDx3-UMsz~q0KY!n z*6+3rlhAy??LwH$o>>drvur8Ctknxw{jZkt_paa$qIMRpeuo3(U$O7e2GfzY{sVxv zpfEZ~AkIm)@d0_}r!IowTjRfDYmi^ip0v9aVpYU+ z+ES#4OkN{$tcq}2A)V78>u{U*-~)>ncm7{1`r(suIed(o%{?ptXO<_@V(b1*2QE&Ueoshkt^jKV21h(5RCe}8r?5?Zv^y-E zHCMU^5(S=WcTRR&J6;;!?kff5Sp zjXM|mwp0%YpD9VeKwGHD_nFpfO4WIJ`$@;wG@0sCmQO28mzXeMx0KVCJ;kpKlL$Nu zlL712m|A8gHG;sz^|o}K;cUNjSf-clYDuA=^nd_r;UVr07D}H@76&@wC7fOok{>(W zH+stpfg)riWd**RuwH9u2gK(}oYEPl9;~H1@OT|L_iCGb+##=61mV@=-n~R>;-hPA zgMk{C=)_J@#`$L>BP<~=SxdB%1I5sg9Z zk%t;vn>-W5+NBr52OXirm)LF%PwTLF8L_Qa>Cyfn)GswNlNzN&e21PO2?~yH4sKbIXywzgPlLMu}~Zp zbwXM!vMlev8e9e58!Hpwys{$4!^|aIAC5?DFrM4dvAHyREM1jv)QY+85S*JKW0C$< zJ7nk36N&M>C)2wuqY&gRv*xfAI47)*M6MFfEe;$Fgeh@{CXE|m?BZQT7Ov{>`LnnCRJp^&oM*+( zxGwfyfv3$&sfvWK>J$zZi||ps5@6+d47E-#ui{p;ceZ0(_b=13VVk5$e|;56gl*bj z%bDDfaTWtn^mVtY(Sc!I*!$J4DsW=T|+rPE_F@QR7-K ztdDa|x_pC0(AAU$;JI`)65LZQN{wAa=m2zWt7FiVs8A=5pnec%>(q0B{i$Re)IsrV zbANju{1oha_;Uu(7sO(L%LgSM z+9at=Wy$CQGP_-{9hwfV5eUCh&P0GCdrU2cYg0cSm?G<+-cRloT*0Wr&6y`h+M|4j zlk56zaoZ*ZbjM?WplHuRcUiz!$cgHX`Zb59l*T}Vy(pOu4(GvUS_o};ugJd{EKbz> zJD&E1IlK3gZE%6~=fe?F0wrz(ioV~t4U6U@u0fR;+IOQCf7D(cnKKj3FubcIV)bpj8NwTw+85=U&=ushUq6!M~JmPDMp(^O_+Ld+|FW zW(+Tezu+5!U>@WCf&*_y1GDR#F$f{G0pT0~+0iG<7^|yvCmn_Nl(9PS(t9_2iz*LV zz!gI;-wby7+zv05purF-PTFMqDUL)ycs-y}_mFqs_Hk^`wzPSWdb+TXnC<+w_Ts)Y z{y6ypU78Xc7LP!@|5;NB2ZA$Rg7*J86;iviSIhw|bjjcz=IDnND&kw79&r48|7(&2 ztW!R5tCc!>iU6_34I1c5coc}n0t5;S1O-pFW^zvZC}U#6@n~blA?oV>FVI9{t!M%v z-)D|vq$NR=$y+O_?MS?f=A#FcIz2;|YtOX#)_es}047%uDn+3&N9>)R5He!u#f5**9}OFz!$xpBEQ zzWfKkD@xX1Z6T_z1UucTu5~2`U%&rIR-8FuKxOklrnb2Y_k&hfjQd_RLhoRypEk}z zUXZpUVyu|et2;tfVki(3=Zc)C>od%&Ol}0pPF_IBnwqZ|4Quz`z6@F$3fFIKytZXFm9%fpLSH@{k#Fd)^<#o8;Dtyy7doxN8n18 zLE46$s*KMn9umJ)ha6uMIw2F)SR*)8n8TXN$bv68Fm#SWT?=;Ggci!IQnH8Tl?LVO zd;p|hGQd*UFf57R8zY$Og-eI)C^Q{w^ zz*>B!FH+PXQHLcY;yHsXpBAmm^JFtzMxuYOm~C4s9kmhv2kIfMwIU~Ti^D79o;MWT zD4@DywEU1lUw9L;)4QfPt7yR|p`SR1rk@K~%v~0F0&@Y1<6;NNaDGOt84#{8PA@T5 z;=yhEqne=4}?TaJvQ`N=LSUr2WxlJM9$d5%LcKB zUFw|Dkg3q)gP1ZUc?Z0&tB?s)Q4vn#Iq8$pHo!asezKB=O;%Vb&}Q?l-u^-|!W+W6 zp}4hkBc$bvM#rL}ATxcZ;A3<9T2RvmjgCcCUV7?Q-rM@-CBM2C5*350tmMSKtf%GG zlQnd^67`Rwpr!?fO3ze(d`3Ua%ebn2fwFBxp>_=z&KGHFSPFUT9wZOYP{TiPo%(YX zv%a~VV{_D*7mm?6aDC@Fl2!n!HlMMCmYokP<>eo)|LG{L$se)ZtmSnE6`FydLdsup zneLTerhvB9t{X0`l6-s5zD;{>(9Ejw?pI1=41!@cOa8?vvMFSNgm2oS@KMU1a^e~? z-i(bDt4a66lUob;&m9!7y46cT5_!n=Mx8emhbnx8B7sYe?JKEtB654e+ODyRYqAkx zO~=1@l2l;zxKCcc;<{-&&{JAhHU?LCK&ovTQ=6P%_>M2)X#Gb36x;f~2H7?gLitz1 zdS)sbc-^G=T)XdCBzF;J@57&NmDn2X)T|W^k=L z2A^e@HsX=0mGhk?DA`f`$3x?xGjGL36}SXT7kM&lkvC{X!2gaF@aHdbVSj0X69#tI zTEWx2<9>0%O`z<0NZC>C=Q%MMO1~DB9MoX{!@pER+mv>^O2S{dx5_ulj{YxID=!(TUNk`?KWfNUCqJWc;P-)Ah} z(T~MiSFH;Z+%S-BqOq6Qf7V8=)cX5~cv_oGP@>xVv#j-ZGx~0OnKgSsKM-$PvM7pm zMnSfWz@BIRTplu4?&-(lY^pazh;A9kwlLUB?Y`=wSL*_Ng}rP{Cn?b#0@&6EdYJwG z-&}UZW2ga%Z18%Wu2NaJbkP(o!6l7L30EH0H+$aH<%027@-l+QyL=>BM_7IM={+Fj z!GXQ-aDoe_QxTzNZ%tMt-}b<|yo1;&looPvv$T#tEKYeY^*0q0EZ2EIA&$e0@&*)c z&F7*1!-}_!9eT;3HhAm3e$xY z#m);lM)U)#Mcu0+_!UNms7cF5A*0m`xC6L+0|EAuMo^w5Ijh5CNlb5?Dl)D{CBH6X z|8wyC+y3gwdW$bHI8lI%;(bV?qZE|~AC)`Fr*x%M0FM{y)<~fVc+ET=Ry4p9ZMp?{ zPm$2d-02XABjG?7lXd}KRL&DYC|`gUPqIDR4nU7orCZ}10Csq|2ru%pzlVDQH1*Z* z&n$@QUBfMim=R)@#XGkdKY|uFJNN=icWNxQ=%Wzq#t*P) z;8HLq;6$_FKCC_k&5HIRODUDxWAfD>FkJl9tNvK>GhNsQq51p{o4&|c&o}-R?Z(eK z#T@I%@-I=*SVu7wC^7u0olRV?s9J=h7}x4h&(5*boc82Jt4}bLjk8U9>$QABUy8(nb(0e!G^KtJxp(> zyuupu29Hh^SO5oYo*0Ulwg(_T*|A)7cjoJeeBa!*1O2# zqh~>?5Fq=)Np{IU;&>Xi4pcnDWtC@r`$#4?L@Cj3r?8pxk#at#G+9hD^(RS$lIwQ28Kt}sK5FubeMHaj9_5M@1V ze&l2aIo27lAU`$vPl>9gQUqkce(3w|nci)%hKsL{O{1uYZpgu%2T=hUVEs9n1$CX? z=g7pgd_g8=28e`HbA5&|BhBdQ6YtFj`mBf0Fn9K>vjksfH`#_7LWV@>a(^Lo-tyl- z;?WWohigomlxntFRoq8?4anZLZd6OWc(OOweG~>YO8d`Y4XDjm>JnHnGr5;Gp&wwH zrhTBP+y;A$;zj-ztbTP+#dqczSu0veyfOp))64U2#RBX?XwUnub46PHLD*z z2}>~E3GC4c!La;nn?yeLuU$EWPIG|oHVJt10360oD{?D~v&p9ls%gF9EoWJQTclyy zW;)Dg`QxzZD^L%Wg{%Vp2M!&sxI0K_HR4mV4^ zluylwO*|3@0stYphY}?U9@&`b&P!s<)!D{&s{c&tIu=4zkSTSWx>ouq5KKR5pV8b-J$LD&&`OIcZQ%2Qe-X#vhA&M}nG#ITT}w-v4k@CPx1eKC2m?q=0o45iM` zU!}g$>%VixRSz$6uy|%nV9I!5lb0~|+jwJ>){X|tw;mXoTsWK0ED&y)m==*J z(*f(OYByHq3658DsMh_x9PWT5d{J_s>Cmb?Lx1b*=c-$kx~_vmUTU2&Wy16)xz3mw zI~xnI(7kSIX%M=`1h*%V->0FQioWXv&erzpm;h1VoWa3DZmc?@mWIf6ZP-V!G5r;a zJOP5vsy!f{Zf7h7H+!`3JhdPjoGjSL+W0#jh&o5LZXTnetBZ2-jD>~n&-%EHL4eG^~cLYLN zQVF|w;952!Tb7U7*zF8>WFRAvdtRLK<gqsAHY6bP8gc4C-ct;z7E zOKD5nJ&ZBymcyQiOLBO}U(>zx(lLvl?N8ZcGgOVy!6Pq7KimJ|m3Tm4#FP{Q2dMyL>RDEIO0D*yO{<56#m zyFbes&QJXUcvO0)kfJBoKInYL2#2aPXw=eT!6lZG2~B*khGp3mXU$(Lr+L zf%ZNDG+@GftJ5^2_iH&>Co#_xxu1LVkTgwU;Slw5W^L?@7*u}WGA4T z;~-VH6L0(jtRrg?M5nte5;G;tJajf4(=v-QAnwAPAVH2X|vzAF~|bo{2pB*>AEz_hcF*wY{Vl8i@G>Bk?A{3JhL-Kf+b zvlxyI^rvnwlYMXPQezMFW0-AhdC5MWlv7a(PXaTj((wR;&)&-$eVum#4h8=p2009l zhp}-i^X05&t^h(ZK3hOaX0m!0G<9uo?QKQxcGSVBYhRz+&9^3^7!3q_va}KcX$?%^ z>K8L~i2!-=@nl$d#7vLckuuvK|6_Tz{cg_b(D>!sZac<9KQRf*WggY9Wi&H$#Qb@f zw?uF+Qc7*4wKIyAgYs=$9o|=PaoScg#@EBZC%}+r(s1LgyKRlaQ#Bp7LJ#cZ?VaQrKQ_P>V4ymJu!a_30TTfJ1Cqf$q z$#1+)@a%`S^I7E((!Em^!!=BC9E7&hHW;!1bfD(chO#LKXlVAG66Arg!tu}%ILFHY zV}#+M!*Pz40Y(eKLx$m8>|NwK2;PdtD|ke{eFNG-&=&X=X`H+pEN*6(@5MT$;YmNc z^oJIDqg~#oG#BaS%=;c8PG!1iw@98(SuxTum=V zqY+|zZ%U*_E0R(BTfWXTMVc2?9^khp-97k3X#TlFO$&%up3!sX$EQ~)0NPbS#GmdBh(ZCB^%hInh z0UAQMtmfM2yIb@5Hy?a{*p2=;76OkanUPXQX(@qK)2?9c#AD4FY~U}{?|h;fvW}G& z|GNfot>%HWP;3?3?XF!Ro%L6#$E8Az+{`iAZVvz2#AgCwHP6yD09QHDGBn06Q7}4M zjWdKONAiLG4?ip39%BT0>Jc?M8Oy&|YqK#$VrbM!`y36xqe)h!vvN)Q<3c zfYvv^U}6EnCUS0KETs8oalj^Ws5-@YjBR!-AbnI(RCTIaX+zX z6h1F{o6Jav6m|<8BvsAq*Pd)7^6w58;-!s09p`0-*Y2^26tISLWl}`)S~eOAyfPrZ zMQ&@uHsWi0LSDBbE+T&2WBKaM;BZa z5%R;wEos@z1e5Kk<(`ir0+>QAT4>4 zas+{!%3*|xN?X)>E0TYV3#6c6hE6|5r|e)(jii8sCXuitM1jppTt%>8f2iRDGUFXm zurp1mVd}rSgYm^>3C8rj<#X$|pGi!O1NnePP1@jKjbG`)HALTXXUKP!R;Vdjvx~Rq zv572*PBGB`Y8nPK(Gu0LkF>i>bW@YLHo%c2tay?1JopwrAo9eTn0ZI+ z`rbMq;%T*jGN8@o3gJGry|+B}dj>JvM`Z;h=2^d`JoCbj+K{4EeC-yLx{_ex?1>~N z3~_K_mL33WBb#Y3RGH1t_(9;$SBmfSR3e@6-n0L~f+7+Lf!X=s%w^o@n@LmSkaFv# zs{7>NNAc?5=uUTiMBc^_D?HuNAY9$O_79Em9-8fV{3j-QyNzl{MwbjLCkT`ByAl-^ zUZkO{cGuCX4@KIwX8;qfW>2edEKrirtGNc3Ek;6f3|^NiAYP

Tm$XK;yz2p;6oql01QxIGu$`*U3hF8$3}6Iaq05QpD>Cv~tkN9nHBcY#sf< z=9qM~ei8Z#a^{a90cN+r^8qC7o>aq?DG$WH{h%g~O5L>^T2Q!K8$>9on@5gW9+C0B zyzrYh>mCMn(kDMV<6IYuzxH)Q#=3C&wkSmaekEL(C=-H6X{OhCTk}U^7Dk5DQrw6O< zyuo7}{yZ}f#Y9n5R1pemR{nBk@)#DQ9wCC$~>+KvK_=XcTO~1ge%RhyAGl~jdR=#$E! z35rwGDYbU&$(#2a&mODIp_>Qr6Xv%^`Uhyh zTUc~}=WupKB$EED_ntb0ru@x0_E|ubLuTdikzX9NMHYh1iNpQhq2waB6c~Xcz-Rut zVZ>!}A$p*Lj6e(qWK%YW^4yfE7el#!My|z$RDK7U;KjKE^I?Ds_5W2ZlWm!mMUo1T zL$nTKv(tv@~$nmGWiJ<_%d>_Tap|Gj!4{@1< zc%Udi4u3ZOIHpc+LfXRsR9-wlQS%~3#)Z6GjQSCYEvaiiQ++2;=ErW-xd_dhYR8Mj zk7nyEbs%^{`hUSqh*kXm*%2QeGp7QlQ-j%`PB>%fCLY9{=p1nbQ>4_9ge9 zxxEIg<5$yc14JR28)HCr=*@4lL(H=>)3xpWNM5|^T>oOY$tC&80hWo6qr!b$(@TL0 zb$Re8G_ZE96_4noRV<);dX~a6IFgHi3_pi9z=Pyo5d&?!TS&v*>&)NEcq`ypQ-mo4>#OMJ$H6aEm&aEu=T* z*LGD?=i3%G!X_O$$kEXPAbfSX%B!o0%cFi}OtqJ~O_ThI?e?@LJf@@_@X%6``8kv> z0CSLJ=C%c2VYyV7jorZ0QG?!iBL8kRlB@nBh5Zdz-b9o2wG2QD7=}6M)(Y`X4k3%= z>11vqAk}lwywE-34&Y?@Z@I5>M=s*pGYx|);L*bgu?TGpe%fyL=vd9&mQ!57&JwMd zb8KXgK7ttzld?P%)Eu;`bx;GaPhXvhKlQlgEFt-!P6Q4l_|kqdz8@)DsuE&v-5xwC z#A8%W9~0V%239NNvqrGO9DtQ^AIj|y-Io(%yR_L-aO0;>NrV1NltdWz~r!8zESh`$J?$+NtsTeg6a%BbvNZtIo>!ZonU z#a(UNKIuy{CmI8Rs;M%^LW?$wCr3Z7e<1POMu4-?=@&mRnDk@I&)MYsM;H`B{<-b% zWP0%{1P&$l(gAQfv-BMbhmv>Y2so2fW+?#nSy!mu(7Ak*?$j~9?fu6T)QzD1_n6&< zu7vtrPtzqMX~w|23Pc?cshk3^$w{#y;ZvqT>y=0W3P~;Au1%t*pK;Mk!fXiq`n}6> z&djoeLYg~+_%BV?EdrI^%jm6j5b_f3)LG zvYjpUNazokk8YK(R>-+Y>h+PAcp5hksr_)F>oa+79wjhDt_?PtNip`g9^DC)VW_S# z;w`m&FWTRV)aZm%>jwyJsj$suDgz+Ma{}{cEz#{Hke!hvPaCl{C$n0z-=U}`f@Y6mq`S4*A_>q8)@u$FlG5ri((TQ+o zy?z;$xdK`~gwgluYXQ)Z@L*%hzMK<$vpMW6!IawA?Seh6W=7bZ_Ll5jp+@>>8m1|i zcD@v?NKz%Ch>?5tZ!qAe5km$&sI&}otEtLl`3>&WN+^~Xq~&{9yob;Kx*63oHQ|sv z$8b2T61#4muS%ITymwvPucpZBUYHOap!pz7l1tQ>LmqJV*|hg|Nc{@P;bWT2W!iU<(XUpV>{~q%Ob@l7*v1YVX0-?WZ0S-wqw53AT>3J0UW#tS>^4rYH~xLy6cA>3LZO5$#&cfI{(*TrOd7%l=FE z_4}y@l@+bVn+~AYE$g8-U8LZ7Vx$Dx<5&I?ec?>Qs8uS+RxsaGl<)+V0# zZyE>D>UY8t<9-<4mp~SPo2#E(w~DOhv{i{@=2 z(Y5F3sXt9GY6L08B)ZkO{=o0l#Y=CvV(!Jo1dERDaPDQ?(PcKTl^0cO?lEgT?JuGT z#K7fTubVVFZ5($3-sb%ImSIN+RQ)P#N)9z>CmB9riQ`1TqO~)P=~5#1DPR_=6hsxl zi7ff~nU*p?C>4(9?s9*JLC6c+%Zlc;-b669P#xk3)pl(I`BG&A-jx&b zJ3UZo-^mDJ-6M{RSx~QryimxyRrR$PY{wc-A7g$`f0w)Z?)jMVR1eVUH#%hkr%-GX z62Byb!4N7n>=&bBaDlFSt(|y!Dg3OqFmjmY8J~`s`7e*ciFa&}#!TWkE;+w55((fu z`i*DGQ(&uZBWScf29eo3PDItKdpVJ&aVD&*`3%BI_Jvw;`QFJT1rjbWjix3I0$tA_ zSbjhX_%VA*VQ661*hZ4CiGhIOmu9gS9KY|?--RzC&bp%btC^IZYlEb4m&$uvTRNvs z6Q11tPzm70`~C0<(e5w2sr+sk394C*$Co390}|sXM-S8LNhHw{ z^{iw32Br9|j0p7&#s6+7J8!StCODClQ%KzI6U|2PKUMJPOGvV%oRBC?5<;l;V; z73HBsLpke_hhVftU@gHkQzgOtQE!H&RPBFNHvOEo}yBFWwPSsLMGn7tYG7M@CJh&YX`=0 z>LR1(@r*GMkX9!VBIyW@Uz>o_w`_`~?l+g(?siY!M4d}ArAK-FKf=ZTeTD)YP7TVT z-P}|=Q&v@WPUVK8{9t&^t#q7xZ1U>cnUCBa9lVLLA~a=E6ot zc!r>8nWn2SyT;=msc69uK1(wB?Sqw;d zY!A{z72g)S}Pl`I^|&!M?G0cM!DNEsTPq zfxcH(ss&;L`Cs5VTS42y%D5QGyFRR)*Eeq1g)s*`pbRlJ*C3xq=LByM(4f zkX>Ui9Sxi|#l-EjSm&PtPP7a3h%F3aUWt@IID-z5K>{(Qs5N2s^V!X*Q}YjIY$i)I zM8}_G(l@ux{tv1#Inm(oXnV@A-Vd(T$_5p7P0j%C=P<5gkSBm!}dDt5~cfG3in z-9%oGta8yR(IMKD2y{z{)orbCFNz~0ezzu^6!&Ff&#u`))wge|IDpjmIM8c$q+Uq4yp_x>Bg*%OJw)p4ZFGXv3l*GX*%><%N|S zU|3jEeZL)0GXZUR1aY7eVdE zyRGv}Bh=lbzl4xe-3n||_Zcd62#o{?rOGEiRY`t{7)II#o%2S3Xq0f#`Zr_-&~^#hdTHt2#= z=GSwNN#IO(O~-1u-{@}GYTK|s949ZcTqgTernWZ5N`LXoiIP7;(&+Gq2pk!OD6su0 z!P6y-e@DW{GsGOe%jWHG{_iKT3E3>i0R_F$Zl*NA?pL%W=02|TSnyr;U<7FRT~q+m zGyt1{jtDWTy|%VfUM!k_s;RSN*=YF!CqSbC^INCs?E&2}+fB7rqDCnByT6<9?e88s z<3sqlmcB}xE>4f+qU9njWoASgTk{nXT_cb;f`;n=)v2FqpJk=55EJIsIkeJM_D3ax z`8Ca(dz@4TcksOnlot=~j(;m`vR~UFp(DMaZ!Vupv?ffM&MF3 ziU9fM+gE#lS4j<`h`=qTu8ceEL+z>&hWu#A;5pf^qS%i;3#@X|1V<|hrM<#6c;7Ed z@AKhdDJV>!PC*i_yVi*pyt%loObkS%?gokb8_g@KT ziDSUCp=MB{wwEPBWGxXLM>wu_X`eEHEQzXuV%qhI^~Dk{W+ODR_G&CQr@L;3+Ui=8 zC3l;IK<2D1-zb6Qq>9j{gQfwSaG#oC>(06zrcj-9=5&70-DRKL?rdu>@Jmo1H9j}) z*JQw{6R?uMeraamqK*gxDRPe?wf3Yws)whCLfI zrG_18csH)(-ZzJC_)eu=uBL_xF*OL3{Yv;KJ_xTbgCuP(7!DwFYk^34Ahd<@QnhuX2bOG_DWNdCtyY zvWsXDincFq2NJknXOBO4_|AF92bnd3!y%3|5X=LHAbb8k}V?AvZ22Od;3KMxZP{0xRi>d1QB9RZ5Br*vu87kbj=9;z8&TfU}edq!KyQ^vIt3}6#S ziOGR@`{-XsgV(;Ww~u@<#aQFB^ofIoVa}F=w^GMf5&wnw%k3g{6G@ty#ir(Ic%zdD=V>=O`k zVug-E9b-gdY3fV%E5eiX=??+5>5+*JE~(+7!l;2j8IwaKCq7*IyLIO+#|^6qkT_ud z1*O1ZQ5~-+2e&i$PEaCe(rY9BFYdl!Nf;)IZfx7;H@0otwr$(CZQHhO+qP%&rz(}o z10-g6K@K>7)qtj!Bkch{OMv3zt-Y{!QPbfg$Ci9dLk z$;v9=nxC9^l@X{QR^+bInqw*fgx^^9A^}q<$ulV9*g zkah{+5cWR|X1YkzZeCLFN(AjKn>sYmQd;ZwIWktzg#+ES_v=F7!+Q<5c6Z9ne?qu) zi8zp4D0EcK)xSaah-4~J%5xw=xY(YQXDnj1si~AWCUr4tJMlvS|IG)$VZI0!A`J4M z+(<3P*~I2@eLT4MlmyW;bjrI^mh1$v=SqidXm)Em7Bd|Z+v@q#Cm~0J){+a+5m}9o zpi@8N*SiRxpGo5OY1_phdU$nL`$1dXRI<@G&5vDn*PTT(b8 z!NSxx?fND1b9y*Odb4R7ff+PYL_0h5JOUW+r(=()LE>=}FM8)reyw~mz^7CqZ*p^DCM zYTb@sTZPC(cvF@~((}yHW&<~bp|nzbQZEN_LU$2;32mfIW9{6u-vD=O%&0HJeU6ej zM?!iX3yRT|D&Mo@TLaTkA=5y@b?o#LjemPNKeuU*9y%xdTsf%?SztvG!h6U8L2MAAHP ziE7e@N~thEvSN8Nw4DXpr|7-D+zIFeQ9b6Q*f!AdmdA;J$t1I1RLc)`n)wmrEn*w! zxtm(&`$e*fOeOc&44YSFbH)) z$NvAsA>~9n!^LJz7>g{A`~Coc$}W!YU$=0<$Yo+*{U1>!>DL)mYS;LxA%LQcJdGWy zhyJ6;a*!y30XUr?bF^!hdm`=94$&$VTaE{x`pKp&PPK@U<{(M-+5lj)y!fJZ=ub_i z^+lz!CdF2)NdUki;wbG*?|we7{TiCH+p7M(g8MBJf11nG;nFe0_aJ^}l>Lj_~e5T*|-l|iFHEW!MWiOOeY_*% zChCA4!dIfcLb^!meBoS{y;m5w`rZ*Y!(}MjRGIsnLHpkLb+Jn~Z_LgNqub3fjzI$? z8^tX!l7ilgy!82|0V{+S7U5={Qh46ynK<`g2gmy9oBS{Mtmrx1>~?kA_(Z>EqulL= zB)_^r|4p!-_7OYZGWncEaM16C2I2p&J-V;mbqvo`n7XC#W%Y996=^~xm5|5~8RbD# z7WMyMHU^f{r}EEx)JO8L@xI1FOon;4j2vKHayYJblHQm` zA05-+Qm$p1PMhB_yvkW&>VRtnrN-0_ZdV`k+ykAy#Jc<0OiDoWmG|$mp}ULy#lKKH zT+&Og>A6hSe{6c~6ihnf(C8i+9ZUi%=V>PWN#?q0y~N5?zIUJtQ5|uC{n9aIoAf{- z6%^exSl?=ckgY1i%4sTE=ep43ykqsmgoZq$YUP5K`_&3}Xkwfba)ZxY;o`R)W8Mva z2s*OW4r<^^YYh22TEC=-yPg}=>G;Ytq!tAoFs%)B&f(?JKi5~ZYsLPgqyrO}ZHaF> z*)p4+U19-P=c!ILK*1vgu`)!aI&2d!lUlQpTjdbQCkV*g1gJg4YD0)jnt4wP4P!)fgE7ANX~u1J6w&)I)-*Nw*FkWqpwL z;2AWlWcZzqxd~Bjx$$%^ZF1tb-oW8iagIwmiGIsrSc)4FLg{HFAJXA8!dNZ@C9060 zehlu{HATSKYQp1Eh{xL`mMAx1VyN?bJhj$BsHWa6N;sCphdz3Giq&i=MbeJM(26Q$ zBHbygwWj6Zc-C2i7p>quSm%jX;UU@e525j-WM)4G{mbQ11jQS-FAPxcBMuHn$);1t zP1wqxE1@XJXupS!JOt;FcJ#7TN>Fcb&iJW^*JL%#jPKqA z<)O)^gq<++hT4_maKWpJZspX*8%S1jK~;O2e{?kLA}Gzc%K$v!b6y`QMv3*Pj|h9+ zGTmxd>5wu(oIWG%zLw+ot^lu%cWosKHc`@OC!~I1<}^!$#h9c*@MzUk2y`Q8CPUq{ z2|jILH{k>#MnQ_q1-C545IX3M&y3kM|65H_f}^E*{e42xBdQ{{4efVPTXN7^zs|QP z>?Jakgz$@9b=Lj#--o z7G{Sd{Flw2_sz3mkGnh}B#wQCfx3%9Lb*d3jfp0z{J@X@%p7+?g%%lvHO`FBV2)*a zQ40H6+oke*dJd3vUWBPV+8l}rndVHsKPjJP8Ira`!RRh!j+KO=mn0)V=yc8zFd55x ziWMOtYhy~Qf(no^RiE?IDWAJaGVj0$ggG?|?j8MOdloW~vAm)c4$uGJyU*s5`^jR# z>Ow4M!N%dtjg_ z|FSUr*4J8Tbrs(#|8(I5S;s06WQ`Ie^c;p}e>|>xJ9=X&lIpn_1bzfw{UZ=aS+kB{)>L?~DI_;v>kws@J7Q-#M1 z_BD(udjaU94qD}8{C2q|UH>{fidDiBK&1~E-6j|&oK~m{X7l;y00xFbo|-DoNuB5Y zIG8vLsnJBvc#)dGTWmMCOwdeHg$iY;oke}V=0Mg`o(-)x8}*O_=|5@Zo{-r&|yDm_jTDJgR-UTaika7KNKsVHY=uxpe1gBlmUc#-?y-l7NAeAby@SX3?T~7 z$0FnT5rw}a;fzTg4Al39#2Dkyv=_d z!|MmQO+UzyC&%q-1>d}&{9sTZ3!7p7kcrZ7*ppH;-p!A2x*3*r;C)N~X{;182csba z(n5B)OCvhFasVSqZ($#WNw&X)htI~6QvibLxY;E+bUd(P^tSR(a|{~X(4;Zl#Y<|P zaMYnJEC{-jLPq^nsSaEEn-?;<;eVe8M|E!RzQ*zhZx91)8J}f z!gr5SRb(FUDCnngSz&zT$2eeWdx|&$$?gTIz7VWSP&zj(fHRMPg(Jv1Eyv;|R^*$U z_3C(%il4m>De2stQ*htfCZt6B_UD_{9hU6@Q~?Kzyr2+aaPO=WB)qdp16Evcr0Uro z;$UkAXH&T^Ox6;{CYJMMXpWjhggXG^DLe5%0Gl-l!J>EG{QQkKZH>>=n7Zc78$np}^-tl!BHPqy-wA2E5v{Urt|vCS><1nZ^K;6d4) z^|KHkOja0vN`JH~Xq%%6UUB@(zM5y9{7qSg@<}))N>9*IG2yR0;>xDkKxn}KT!_J( zkU*Ny)Q5GT$O`C%GYX0&$h>4OcJIB8YhJbHnJxA*ER&8>lEGZ1H?FpHQ?I9eV+@qh z5PM`B+xo$nGPi{;mRbw#>EBvrdh9znY$rkK8#b>mslAh=p`Oj6DpB*4{x2Jwghgp_ z%(>V$VJgrlRquLm3Rp`h5*O)tNcNEle&AtRBaZ>c!@#^#&Y162)b0-})1&AxuAAJY z*$?VN0#HTJBgkCZ7)Ux;o!iWij}UxW24ka5T|Ip1_@6R@!LyfhhQL%ZhLNRQ&h?ef z-~;V@&965A&_0u3Bhl`OWs6TMDWscob)>$-d__bL*>1))3$r$(>i$6ek;RYcp^R9& z;9Ezw+`c*Ex-$U2{lFnM0b(QYev@!kTTbOxp3X1}p*Oivq}LWzHe|6Aw>-n% zuJ=j6LY(%{eK7IBM}RrhQFwjPZB6vI*vUEL`UZ0?eCRZe^b=KE85K4;jI5?+^1P>a z%t`&f3Kyw<4n))DHi;{25Ebi^UqM=1o((vOh8aN<8#6pi#_XQiLX_ zCf!7kn88VoT&gL@7bQSl0nZI^E*E!cOu|!R2+2TKROTcD_G*}6X$8aO$mKD)LWrhM7`S#?Tk)a`7B3@#;zVC*Fl`FigHs_YZtg4A;X9ECRlVmw1FKZn4 ztCo83GyQ;el)cwH#DRJcljKdSFCqH3YBEiGjPldrC_gV?lptsJ7NU5{u*(bH{XyRK z^GH<2=)`#Hh`clf8^=Zj#3#gLgf?n3Qa|oW44J@;ans(B8Tw`mz+{k}jgq=j8IwVH z4|NF~V1)T@?K>4kDCWKY^egWA8gRYlAQ_IkX&$ekp&B_+fJZ52wEkLjK!S;WC@wOS za7=~HjCeo#!|bym2oOH=t^NFeu^81wt41U|qsP%{N%vAf!pK7M#A|t}qXjf;;wUH?TfDrnq zPWWLisMGh5`td&lLO|mXsY~8i{IA~^O)ZUg3b~SVj@P zQ)@`pK=12nP$6dL^XHOZ{{ba}_1YvNiw>GQzoyeYzGeNnSxK{Da^TZ1z^j$+6oHnu z@sG){Bu!^#vI}tW^xOKeAuvv_R@58}w_Ms;$V$V4XfVl0@4PMA&SpBXD44j-Xs`d| zby1N2$K&D)<GX7E z43H*%Jc}a$ZRjAblY78!SeV8&TBQE>H`x+S5py%OUiIA2ILkx)p#xXWGNd5H@o(Y3 zrWZ4GixFYV#n65$?LIzP7Xoiq;lPl?o0Yb%Pt!bc#1LD*K4KWC7UL!hW3R8&@zG^G z9u156*mW)<2zY5z`L zKKC&G5l5|mr=lk?q+?iE1Bo6-+sx%2bhN#avOv^y9I%(1`OSIC{D9Z?A~+yb|Ic3@ zV;iwxuUQi-(F}oAr?2Y;;(Uw&g6A0sRc2;YLLLkvV5p$4=#aaOi9f+!Pxv&oY-#!O zc_z!8o~&{S1Jb0{j*Pa}$GvjCDgO5mpY!)vo)x-ibLv$5m_QVQri07HYc3x}G}xhz z)$${Q&sgCgxp2A7?am3utVyxEPICVvEx{hO?cM{*a>mJ{AvkZ{7@4*3SXj(uz59jW z$pTQU-E12$FrxL!CXjRP(Q(dEFR22M4e{_w=duiqRoV96z_c?nou3<7gjn5U9H}_A zl1EF0c`unrFfeu!|R}-KF2Ot351! z{sfBh+mby~hfW;C!XzRRdb+V$iuq?aSrBq{im0$-e^si`7I2t)56w|}P*!%ao+NBU z{jKaO+BAV z=tZyv!-qLj-GHEa^vlx?Y9ThcO~CH(Qi;@0$QNxEf(6TMmbKplW62vOW#axGBSw3T z|D;7zZ$A2CShc=-dl8^7f8SFeD07Wr!D)hxdY_pJJ8F9wPCF?Dq`{w#W#j`((0daK z0k?FH&v~o-fnKObyQo)@rNr%V9slNLnCAH=1>*jui3C|jpTUW*Q_wwsG;Vbw-4JC} z28tvf8_uW8P?#b}_a#V~(RIzDn&L2I1w>>y@tkQuoITB%uOHvf#c=KVrDyMoP<4_e zP?;DKH+MYjRMK(;#!37|*vn))rO2tmTs1b$rTS*(La>7A%?b25VudZH6%q(siu(31 zbvp^wqe8f8^2xn5;klkbz3pOXKp#2WYCoUag7WamMpTg!mXbDq4Zlq?N};=Fb{JD& zNASuy-G8RG)Bv5(#HPT-@#%$!+KK8_YFR2T^*jeM<1y=)VJ#VzZPrS8f*8b z-M&nEBNUk-uN*+yh#)cEy{HGBbcG+o)92dsF`^t;@}tl02~!9jUFJHnaS=pdgZ%O| zEObk0y@vbBY>^7FxRXq-4_V=l*2WS?xZhP#vZ?M)v+#*yS zbWtilL&i3S30~e%^z$ybbwkO&3}*>k>cdDL>YCf{cue`U*Rv2D0*@ z8vewpgh|P$qWR+9L;XhKnD^JT1PDV4NX$oTDY7N_q{b@cpVAJc?`F}MYf-w~XNAS8 zsa>MS4F0Yci@AtK;TsvS=@Lp2tVf%t+o4If`s-^YsdL_1Ht(6kE@wVY3y^t^U@VdB zp8>X3sVjNVyQA)W-D$Pyv+?+jJ||1@_gm+r>$V584%ukEq2< z`g|%EY%$|_E%AGe?*h?_;1+gD;cgp&FG<&y`*b({K^WZ# z`Jjj98opZ(I3G~$yFz`lU7>QxyeN>$`P;>upb%$*Qs;j0?$({ru1Qr(`^fwk-L_1JHN^GFAwfM^=>8DHNd^?0&C3YM)gL z4BQVx7KovGejf3B&8R~+p8+>H@D0V|;(lqG|A-r$X6TD3o*(4zW}!CN}+*$uG<^)OrFzZmc-N)&~|%47W;FrVpQ z0}&G+$@N9vnaN!!%mR`bfvl70Hj`-+pgEBtz5Nq(JOLFOg^xndblMPIk-4-Mv?UHw zBA)tD67A}e)?nh$QO{iVUEJT0ZdT2KN?>vaCXylYy|b|^pSXAgH&2|mHn)u9j?B^q zB#UPbcxtsXOX_q*C1QIZRrpu*l;$=oQT^haf@ z``=-Me97~RRLXs5ADNX&l-uKb>#o>gakTG|1xN|0N7DyH>$HBsQev;9R7URDq#I?~_3RZ@ajV8QW zT`yzkucoSN%os(ef#6@>&>V(^D#oyBm~#)Q|KNDpql*%8E%PN}45S`uVXag5G+x#} zos3$Dl#_Nj*o|Q78#wtyurs;Bg{%AE)~W@cGSeN9nnq1kM{u7tgArL9eaM7$j!4z} z#4BKy8ezbT$pcB;1msOd)5SkPrr~k>VWQ4Ro8MED>>FpMvnK6^H(O778U*K-Z2-zX zEBiniZMsUb?vr;Ql~aH6Ken>r1XLRqgEsbPgIb|?-=4s&H^65V7PJo$&MS`sVzC@Z z&4TGtVhhxPx3mtm3Qn}vnuYQnW7!!AnxhvP{KsCW37}RX>r{_UPynfAA~qq&02 z6i}p77&f9EY}#UQ{=h#jD_2R!xd<_~7~Q>IVl0Q&ni8IR4W-YijGX`cpPRrJ(6wV_ z$lwLg|FV31g1Y>B31+2cffS@nG!29h->Iu^Lr^sVpq@!27iw81vHZ-=L-`muEzwF-=^o*PJHmum+FU@Wk3_qe=Iu;Q7Z18#-d0tpwJ*+il1`&F?GV1mi75naQSHG;98+!!V=G+^v6 zS8yH!4(lNAcmm(d4)lWr&>}cm(LLlq^U5EU43URJK6!&JVxASZFeb!cSTn~Lc>4Y8 zTZmrI&y3HXK3lgo%FqCd5~DO^FFF&QH~n&XsZQ~Yr3XamRD=mV)>!UDrA7?_1+pGQ zXhwoyL1sM;9$;iKR6U_8N8_{I##j`8S~$tw-_Ad+&egUUEX~xrJeq-q{ra(=LDDKE|1F zUVqVz8k>7uQD`&Coxssy5uLWm0IJSFh97B>PtBhk02M@TnT`0QdK>P8-q7%@dF7(+ z9Ykm-`fB?ubdMC^X7~y{Pw;Z9TWwuBUw;#0v0VQ`T%G+@z4*DQvbcEy%9^kayW=HqSO=>^!((leXN5RSfv^iPS-3b*ilxf}sJGV?YA$8pPyrPRmajH9fTzY1cN0tB!hW)y;KGuFK~}$-(IZ8Kx3l8 z*%$AT_8^deY&;|MrRI|zZxc5yo3XX4aX|0!CSxFZa#X zK(Od8JKMw2$-7vdZ~oib^r9^Sbe)MGV=nPOzg@k9hS^4VBX>4~W2|&hi{3bu7)b(4->deyN^6xjrp(^T+hMNsB&m!*o=}lm3`=BOYJx9$uD*bU-Y=azJ z=m(KAU{Vn{Ev;BlFLvM?QbicWpm zy}z~{DT=~_s^P{3GGnz<>QAIBzu2zwQ~V0DbF1G1lv^45ru=zA@dIm~mcSaL6J~b- zXU80tT+8w3ci|Bfy%gQ#`RhfzQJe0&C>`~xjjz>MHAZ|>YRA6ryo=kfE*j}cNw{a8 z;KfrXrP41gJ)(crNF851_>yEdA@ARGjg)ZLwVY`*Jh^ld9l;%f1|450} z#CLu!{*v^h04n|EaQ*&)zvYy!aE6;&*%-s@K%07~phK z`q$5(tMC`VoX8jpOsPnm_j*(m;}8cVPVrwsZ(d#lUS0*GYu|?!NONlSy!&1 zRxG=yRrl5?j=|$Mn=0`0pUk8_Rm{*x!aA;xmaYHXRCWXa6e4r)uRVz2E$5YcQiMEo zbu%JW?V%x-Y|^5ENM62+S?!GUXGoC^wPUlbrK2AVg#Z2+(-QX4q$^-?Pz51tjQs1zc!^dp{aoP<_1ZrVNG9G(AiaJ z4ojnq5S|D{nU~9U4A$CazEmu@%Ku+Tvw|t_TE%Dg07lJi-|8I35k@L z*Ety^nWo-_zb-qMItT_|Q~3EGgx3g2D$!Wf66Nm|DW4P`Q?VBKmbjRA-0Nz>L(k`U z&bqH@3FVnv4}e*lEHAw5#eeVlU~od483)1UCR=nROQU?Pn(UX8y+u#;6=Rgjpnl5} zF6kM6Pj64@RxG=C@-g!)TBWnc7O~=$0^!=}o8M!o}_sVNkxy8eg@-VzmEXp5DtIBj92I5c=T9R0z;iLv?O(n;E??R^&l(w0<8lszcH`Hmi+12migY^Fu;WEK}0u<2qGW)xKx6!Gei?v$aMg^ex7=jKo|+S^NKQ# zQUHKqaqje8avo{(t4zm>QQ>YNvWxJ$Whx|C;QXQ8Hof@J`*#UKYJalrv!-x`usKqrpFepDwqu>d5c>6hA{>67%bpV z#YDZ{U#TE5#&a)Ss6T^;+-x=U;SK)0HIXeX&euvm$2b$i)HMqk8(>#-ztt3a6OTp+ zQyUwa zldMZ9*&MD1`Nv*tr&td(oh_{sgTBLb9C!MK5+a=%q zK5;|;7E5x+-eZtW_=}_99=c$iWxnYL*&2VBt0jy+OVgJtw%29>*xsjtZUf@D=~5iKptJvN@E zYTulg`kcQVQHPxwd^EfZui~T9;_dzi2}kXVa9|3oXve2=!pz-dy>F&sRTp6k_u#M@ zMoqYQB+iDav&+xhr-r1g=$w4s(1I*5%Z`*u-Y0f>hK|&3QlmZ4YXZKosrZJGtuMse zOo1uEasma0m#~_%sKg{j${s^$9tqH<%-wPJW6F`&ZDcTmJ_$zm$B^ZoRL_QfHpHg3 zr#X&~@g!~4PLx6$$x-*Eq2h@6f;TF+Nyjb!){@1jAork3YXCZQq4v_{c*IGV51Mq|D(*D2$gr!lr2urPJ zhP8&>3c$NIKGPt&+)k$kMDOKp7)lBroI}xpNYGg#2M` zG+s|7?IoXgb?g77IF_j#y`FFjHqP8tHOlF!z)4M5my~y=kSYQ1t0DI=oae1eNvasK7hWUMf^Ssh(3&`QdXHL;;+-nXpjiy%eBaXt@BjSY&!?F zq4n>1uPt+tgBwnpX6^q#mCYdvflfdtQsiq>N1xmk>2h5Hf#zZO_XHw}f)|Jj>2l44 z-ch6MJzQS0zIfodt(pHY?zPQmYH(F#O0fZGZ}GqaLZLKrDJxLYec`lsDrwykCEOM; zW6`HV(Rp=r+n475#Iayu%I5$L1YVnDC?ChGXHar5twCAMlyLO_dpO|1`MH4fv&~NH zzDe{A@T&xXJ+T67gNPI$_8JqxEfCoc} zmp`T=vhA=vq?-Ld-CElKqwGNO$ApA-pRV+}jZO5GO5DWStG_4cWW)j^i#`?aR#6}3 zg&{-x&NaiyY<(xr!jv4ancgVJ87(e2>pj)M&SCBt;qg5YCNp<2;CsQvFmvi1Df80)LG zwpZ@#eGuy-d5!l97c0_)Bg@|d4;$h}f%4BiGUTWQsAhL+-c#v2{fZKxWNph(A9`y% zgI~cE|2{pp%8a7#35e(bXwbj_b1F9l#q$fYP9qlqm@R8FJL33ZxM*rR0a0R1JCk5W z!DtJ8t=2#9;>h_NO^2HrYW1VP`vb`<)nzy{I1?_Gk8w8B3c<9XC?+Xw5AB=f)e==& zSwWac)q$a^5`ILI3q^qvd(^bc`O*1VdgpyC$wQtZlo`-06$@<;jNt8v|7yxm?&usO z1htN|8NtMWz;G?;?-whE>!n_h=DY-bXOYdhmnfcvUk~Hn2fE||C7=@6;e2u5HQXcI z+l%8qFb%j_Xr11|{gkEJVXa&&1UC+_9FxiCwd?XxGsfdDtO3*+d{{#BGK!^q?$Mz8 zd<285hv`vE0K%e&#!6T2&rN)1W6e*fW@8fd9EA2dJd%F}5JEsIU^AGejzZcAYnA_9d>4Z#}y-KN{F~CUSP-gz;9N22)Oa?FZBi2dte!noeVzxZk zq8nC`?HI@{xz3Ln&5j)GXRP2*a!i6&b%j0J4%R0yS6^T2gZrTHu^ZE4iCI87k7DA{ek#De#U65>LF=UrUGhkR%m*ydS(jDc>s97ES1*itL8*uc|X(q}Ds z_v{$1ilLJGRuQS@Q9ro|~EN zRiGKS-0q<6tM*ji`Q86I``9@#jTIc6WXkKDDFh_X$1GpRN%z=BunL~s?K`vS15l8_ zyL6Wwb|QRD7APQ8dG}Jde6%fs9I5yZsU}JOK$~anrVG&n(w4@cM?RL@CWTc^0-?0KJYr zLM?K>dGAJlZ7}6y6u{2!b|QW~vOmpI`StL7MDUc*#r1B2v@G=K+HHXzFGLa{yPhbSX_H7tS!A|vCR$TK=mVcif1bo$pO6Uq^iY?*oGrG}+O5Awp=1n5lM1_{gFEy|fE_1D6y^DWEW zt#7Iw3u{pz+UHgIi1&@hj7StH)nFglxpKf6uJQ1~~EKNWDvaxxYd{-HXC@%_DiWC&#Y!TBqu9*R@nnSmy(&|x)HGkNGN?C+Scs3&e zhw+rr0MxM-m35(!%>55meazSEt%7ixGx$EY4z7c}Rb%O%tQj{Whte7CS zu8bttc@4F?`1+yvYtoddVB^m&yv)B^L}7;rZ|b~pP4moS)PalSAM@+D9$VAcG8tnC z4tU?|+MQ`WJo}2nf1%slIWD*9iiYCKT_~D*t0vB>xXJ`wK0P8Ac_LRvlR%rLtk-e` zw1l+$`b91$DF1yuKlc2*v1I|fW(uNVD91!?K{Nni=~?HpNO|Hsdha)GRpFORC_=U_ z8_JBTGC*xw{Y$@-O!Vou8+MSq3!`^%zB@@slNQ&1rjJhRjYW#3*Tb0X6!qc0XnA$5 zzvK2M+ph95&gZPltfS@wle#`i6WFNiSKcitEm~yDY_Zm(AtD*xP<}*CfWw#Wu6Pc`G9Bo-M8k)zE7qELqFIkioPvs$ z1bRF}g4_k^@~4!JUH9VO=d+oWF~fPi1BVTOT$3svwqg;f4Mr(&o)omNK&`>D`M7-q zny_f%pi?Dv%g7BD$}hqw&KJd0NR+FtPoczKb7r^ef%r4HWuqy6?=`SZ|}uz^=aKkmx9|Xu_l)^bnY5F)e4Qy zjD5P4%ZRIec8o|D#nr3J7ET)D3h^w9tDKk18N%Ii*FA%{&O2s`gi#{ejTjX) zaXd?azDqAq5^HmrOD&+l21>JrDvN5{p>h#VTA^FB-cK9Znl_$Ij+LeZk4X;IESV~M zVC~yh+WJFNF!K-Wf`y0KOWsZlWn}jQn)+?-Td9OmLv9CRU4bhnvlxO8%fv;j^@v$Q zZE_TJx!O)CzZruH685uLy-knfqR0mRkv_Lk0MGc;1n;@`zvkO-M~zkaa3;2YzI>NC_3>6cLP-wj)JlWb(-*{Y<_7=2FQGo z-78-0(#WeC@cW%N@s8MAm+Aa3^_}d(287^(_Xi~!!2i-XJl-M1`j!kMeOKciTF6_#Na5u)WkKL7dNa#}ZG)e?hIA}_15hbQ3XhK3GBa+Te@C%EUdM%J4lF-Ze976{R5zHEm7dW%RMo7<-_ve(45Z6Wtdapgm`0Cc| zp=N-=RbC@Xqqu2!iRJ>{5e`(z1neY!H0=9_r|Z!1oXBkd(^l9R5h34y?ec{r!fGs{ z<4?s~KviA3&28J3>9Cuu3;O{Qn|+6aNdLP)KDiqnts?sLl}d(uWt9Ii4|P2ZG}_!> z0dR`^x`($mUPyf3Hr{}~CXYVO((qgoh$w7Tk8)?1SwiwNE)J@wuK~V$S^@*f?o}?u zKWt9lJ=qtA31;q%0O*#_s1XN$Q8{)8pFG$uR#4h-U{|d3rLAa z@9N-R0{!(^XSr+ykrMifXXKZ>sKIZ_?dECjEuS9Pid{cj{48BapHJ+4hKQPYP9f9D z^rO^g^?T<&C%9s45yKJF{-fbRb7m)a2G-37-QE2mQkxKtcUa+KF03j@fC2K80bHCE z7*Zl&36H1Hj?PghVJAt=LR-sm7zoT9F%O0@>{|}a!Ww}=ae=mQ$)j8upBuNU6Fi+O znJwCi)5<;iWA4^T6ltUo5iR4y^7Sh4Z>&jhfX7jomAQJ3^udq!h4oRy{V>O-MmB?+ zv$tCw_psG#ui^A>Ks~fKNgX`R^qFA8g`L~K6wm-dG;Uy{6z?P&anQPB9#_^iNd}2} zY#T%xITL7Vy>dU&cn;H$pHiT6hL5i|k0ktBu0TwxXdyr^MMF-1d+KcyWtseoz2cxLrKV&n&!{6DlNN+trdOH zaQfj$&LejV3osAhTN3fizP4rP%Q7d;t|fVv6W;BNbcmSEPHY!jP@Uof%J?(>$_1^c z0<&-ocI;ZmopL-nhIHFo>}<(K`}fW5uBes-D$WJp*zH(-4gKP2;8%?9*XdmzxMBMM z^j8$*RpW;px!&F$jc+}jJ=*hXCBM5;=+63i-~@BVp4+m&v8YEY>oKCwM&j+>Cy~1P z?~0>MQb+s7w4z89-p2xk^80Y~L-S-cWu{Dd7f}<+iw*e#=_E_OV?c7;UE6&KG>+}| z?6U-FvM~J)BfD*}*{IO^Q!pt|ubtOe9Xro_`-`+sC*scS*=fwBWyy})+1Yo}6p4*p zUg$XggSB^x@rH-PM8~#m+qP}n_Pk^B9nbv7_Pk@;wr$(C_Stkg4xUX0YQTtLQEm`DNe+cT#oq z6z-p9sxSRxZv+)In=@0qVpn?Oo(F?@fEG)A5lj?v5mL-2+f4#TD49@vvvj-;GXo?W zOQpy2e*p~9qid*RKHE9mWh2aWsjZ!Zi5V7ZiL|(?CD#!j%Yz zd?JNU4j^w|@bC;>bIfw?(qayGrwaA_%RJtlj)b3JCKttr**Q@-p>8x2K~5JYm2Kp^ zcg0ZKet%tXupv^%HuylA0zFC+#6e-T=di&0w{19XuQTH^lEXjiNg$Vx23o{(hQ}RH zD-pCZ#`*GU)zN6ZD)fHjO~UA{L&z%-JdBl&i#%Vdu_zR7)ttJ~9Luzc9+jI4e6vf) z5w8cfqI|C>L)t-5ETN(m(O%|$&F%pwpAJbB-f*%0v5Y~@IwpNpS4+uLdaM)X1Ji6X zBruxJu(){w>rerT3;Zi9JXsJu@JmD>nQa>pnxU96A4jj{p*HdumFBc=SNhi=YzLCX zenXDWBBPqx6p9BeszdA)=F(PFVyHEADApkmuj$L;vH5Wdy`t$XVuxw1RzqgEHN?J_8M`Vpb?P%SOzUM2MAx+`{sUbCkZr*JVMfH@K?^1}6Jb?GcI&y|L3iDd1K+RVy=0&3nMmz>iO;hV09Pjj{ zW!{cd^nGZ%(qLuI?y!0Ntx%47z2zI=6R6C(_U>6D;ixdl=c8yTzWr~}o2(>>V4t&2 zI{yB`%7vMRFm8Y;0l>?Xy54pKd&?)lHgaM|3JK=F zs3me!zL?XSkBc93|9e8P8dB2NjM#&V6XToYhtA5~22YT2JXqi{Or#HHF=tJ4!Inyi ztddGC0DH&+%$PIxs9K^tq5UrqT4$Hl&c{q1{3Lt?#yuZ*`68o2$da0g9Lr zUGd*uK^J*c`REe!YkMWtEUdY^MMOU64g7d`Y1mnKoYmXq)~>05vb76`jB4lK`tfvV7?SA>#gdj37yNS zT(IhaztHCu_12+^$Doj1KtgOerR*VE%-p(}Ah`+|aibt1xBwF8(}9^;5U(Gg(>yU| ztNiK<`O%!qtNgCkXg_16)?Y*IB|$fBJD@UBv;P=&KObW6YwRkV+jxqhqlQ4z0`W6t zdBm27B}eT`mW^gQ%_j9?fssQf29P5l8HfnO|6#Ow1E2b|{`vl5#Gn=-Zt}U-ZWUVX ztsC)(adbN-S!;3>Qca-Ul}Xyma!I%~gVafoNgdEJwGloi8Q^r+!s4^2)_JYtLEq#1WG{eTYgm0bcm+DyaW&X(z_Tmr#V8Rp(wQ{ z5V%uGtV#ao1fsAEE0%opsA}p>DWLIpQb~8Ic!WaKwL&-jcZh#LQsQ2qnNl|8SXd*; zIeK6cKj7&fA%NV*`70d?Y_JakI}qK)#yu^BStxmwuB@DBlq8Y!CU@;uwC6ogW3Ak$ z#RS5+ofRL9LeL*oyDKLS&i8h>MEC@ymlKpZ}6D-1mhN&h7Y9= z*yaM=V+(~^oi^jZqEGE>5EWsR!84;xugro_%Xo8bVQv0x{@1eEzcJ%VA3 zO!|DX7mL=U$LEUGKSt^9K%M)&0&e*TP8^{3Kbx@XBngQ-axsGbhJ$i9*UzewP2g?P zk@j>j`BDE(0;br&LgiJlml~j>0`8$Q`yZ^ zLlt_w*E~=PB9p7`F6;f+;Sk%&9GGZ?qx!4EtT&|T0!}Uo69rlzszbDpGYBf!YP3Ky zo?%AsqnJ*@>Sz*Xa$mhqFTZAtaEchHJ3C#JunZUawA%Kvykd^}?UqGD@twGop&!`y0}p^meKOFrO*i7&ok5XJx!FL zjr5cEa>xb?`Mt1h<7-8z@hj<%G5De%kX2cIS=N4oHQ9x?%ZhB*CVDrS$gi=IB*P$r zQI}UGZqs5eeQGU1Jd!Hw%OE0fE`6l#(QDS z)ExhL?!9FH@c88}!56P>PL6=TSF-eW(Og|gdJpPg`(Sm!_SpaVBkUW3XNX#_;V{25 z4^jQctrYOpWECcRaQHJZCZ|C4O}II|a^lZ#DYX9;rU&@%Ny;&5ENvvXi%uT+>#LRu z`hBmWh4?!B|DmL;Zj3T!@-xJoVXA6`T@MXq>9$+se!{D3p=U*UwZrIoZgW6dTJtKN zLT@vD`)T9XsF-#BiwKLRQAv6mamc8`&>9euB*zFoI0Mt(= zri_*0)Sh!Bt{3CPNW=;XE6HkPB~U@?M1Kcwf&&pYjfkJ!?`feaq3nOgxYyZ58+89L zQ6L#_{!z`I8!9p(G#}<{2U|M-@DS?w`)zMSc4|18tEf~1%3fy2zwvKbShN$O>t+|I z+22a&*U?QS&ZYHO?GKspv#iy7*2E7$3F9AOJthZ~JwFIMSM-e&QEE2rhPEXubzdUxI)4sPPFcG-jT1jF2tk0(X zO9=SbRZ2E(X>KSSprOJNbLDZrD@511=v4J1q&E#aFTpiStM-21Q?@IVAL7TK(epBY z)m#yLAU?Ls((&VKi~{{*QxN%{qD^A|NR;~%7al8tYT!44xQUl~Z|a0II*hRyS?!iA z1*t6;QTO$}@^;fKJYM&&JTx>N{5*sP%|QLuf9Jw6=1ZTD7PY7 zEtiD>dsl&IsH3@}A}hcD|9;Am9Y(U}N+Bp@I>b>>nJEu?ewdAe^L<1sr;GMr!};!vFG|6PoEek)s?ajoV`C169; zB|kmBCO6DO)+g7v6D&Y?3WoE~FnJD9Bh+Z&M+U275A>Md{cm-ztlqo!zEsfVkW7uS z%s}j2tfry{?Q`+idP#H$jAx5?mFpC<$K!QFv%OP<(&tfDZ2%1$%BSfbl^vFOc{!B3Dj;&TyV0M3T4`PG$)cAF;3Rz6v= zGO16~OT%<)U-1zL3od*>)@nX(-up_#z;&>$c2y30$dl=|-=hm&V1xGidL~+9&Kf>g zFIw_n)B+rdxN5bQIlItCV~;;q&w37(U%=0Yj54io#wi8bN!2;O4?IaCZxU~2aAgvr zn)F4>C3cBCW?)^Zm~$IOEgR(N!sA7gFBtjigLBCh%BkD??6PX@(wA1=r!5#Y75!Ru z$!8bUGm`66RfC$qs1n%Z-OvIpq7N-so31FCn~T>@7&;fc9Ns#hjX;MXdUWL0 z2KzYkaL1Ul!sb?RvL1Qaak8;MEB`E!cz0Z09K2nV2DpDk6~>s#d!F&Ku}lku5Vl|% zTGPNlm{k;Ji)3MU$_OJ<4AY0ct9ZI0W6rRljS(TjxXW~s8Dns=JM?J;@Cwen7FLeKx z43(MAgQpbR%NccZ|IGf01#{(0W0x_|&)ij=-WVzC=x>|HTFy@EXwixK4$@PH{1iJT ze^oS}XIM`>)ZW$kUDoiRFm3SD@Wu0Jk*USV`Cm5QpHVN;F|JmM*3tunNRJ`FGy6Eq zD^XM@-@8{^WIOTrEmgQpk&hxSV*{93WbBFqOP6)z3d#|VjKE(Dq64oFY2kUoN4lAP z+$?*RXj9K9?O_^A+{Uv?Sk}gCN1q#@Vk(JbX+jDY!6Q`_PLzVGI3>1-P}|JQba0dyUP`GPH1&% zdpIL0s(JwbBR`xEgNDYzX+y4#8rNEU7j2kq{N^>~ICA*HD_Hu)j!$Cc+!<8DP_>B0 zePI;K$NAGq)Bq3r_3#0U@1JsnsZ2YC7P!dhGu5C~=S?waCb^@oYl= zSq3Lb=!lZ`@W8^>fpA}%XyfDc$`HalDKfb}FGtA&O4=9PmJfa-C z2QhO{SLUP$_)w6ir04pMA zNPG#d^hbZ4SrML z@sOQ_5z6&rLiv|JS{+&y&R=lqDJ>_-FK8N%I2*#rtK1wTsYzjW>l(H&HALXe_%0k$ z#=}LAp^v!lIDU*C{VBZoC4GQ}H{7klOoRJB!h5t~e$teVu-c`tN^@)M;xeKpOK>Q* ztYR>=W;P3jbVQYcPlAgQJGS8QIQG`n0 z7*E1YN>IO!pTeN%j|vFO0jBfEavggyJZjGqkd0}bpe2H_#l!J;FCJ37FHD7(ix3fc z(*>9qpV^KoMyAxtN9=-dOUQs(SVHv8hO02=bBtq5z$6(`HQWZgZURL2}`&yL-Xs(yxI`=Eu<%T`94oj^5bI zFVw^DA0>MkPgANjc0PAv$V!lc`hFQRE!lTh8o&O|w-e*t!qfBLACZ+q%1oumbde9R zBv_35VTGa+6U_vr>2g9&;egujgUwOxM*gk#db-dinUfQ6sm1e@%dV}AI-IRd`vF=Y zpDurcU|N_ws{7TC5wCpBWMdhLp2XcJzk9q)7+bsXe;h->o&%MR48mRf3dxptZ=J)00ozj%6{;no zujAWj&X@KKJbZXZ4hPkKbFUm?bLN`y1l{j36M%JvaI^Z?XML>e6)~Bi@KUgW$ahd> z+9OlEgM5Xsg z_})%}OlzR6VNZ+>_Wi^tT$JsCyQi86+Jf`NCf*9{A)Adj;|pw&D!sMhHk}Bq4hP*` zW&qJn(WU1E@?q1^Np3gdj}~tQ*m4c_Y(~uyS8%HN$%egu_#?I}TsFMCAM}nX$C}%W zKk3g)2Qt{IS2?am&v-i4Y$>*c2uhUl5(J8(gPe<3hh_fF@w(>y-D8H`RkNJ#k`x<9 z@1cT+$oWt6WUVgEP6_F)xCppQd0`5{kjvEED~>bnP^*q;8n~(x*#NYG4%i*DSzP`* zRtJ~oka>CWim@gvme6G=Ox1m>d{0Yzb?3w#R~zQN%i_!9Xs@r=hNh(IK+Syr!mZ~8 z0ZC_?)O^zYP@tOt=~BrfHFEdwNJdIE%%1itu<_;(p1@`q&7q=v*tKj~oa5Ho+b^?Y zOWg{Yt9VvbO*&%KSDTU925r0BNrDFyi}G^#GFVylSaJ$yss^5#65Gkvq(!_a-qyh) zrgdW_p}>PXLkYwzd?TmKXzpmF5<8P^0_bV}SR%_iy*PjY)tJ_^XUc@}^r)=n0U zwPojZXR6N-j;aSUOJzxTq|Ey2>cH(@?dUjw^%`(*wERtC4GQl!FhzTknORXh%4g-^ zA%ZA-MpJ4nA`)|oz_WLCm2sB_Kdg%3vFNo!7>amzwcp{WX)P>)JVQceD0R=1l*0w0 zAaYvPnopq?=n$8!m@;WZ$=%|yFdY~^6CFzLEL89?4=EUrwC$8X<2mfXeO+&6B!O$hFY@2LS@dx-O#p${)i| zkr5|Ow(1-*(>4|)mTM(Ho|z@~=c}J>QlkAG=%F=KMl5g033GLa>N=y3<5NnB9>l zI6J+Z?%8Ql)?9Hxp4n;*7X3AK3?~mwHVfF9%EF+lAaoy(4||L5!-_#1j{`0R#y_5& zbYLMhXQZ1ow`DD=S(-go5EZ<6A{kuyGeN#RjYI|+{^&NbQEVy!Ox*9(y)24Es3CR6 zr46i0=*|Eq{+`%>F{*QHuYefSw9Bq$*nOc%26zEm4wLaUb=QE4z8R%hw|TOVq&Fw2 z@d+A9eXLfT=+}!KMrOrrr*RsyTWfH9PZ_SpkgOC`Anm!S1Q#;cGv({$)gR_+&qSt1 zA@Z=g1~sG=Tl>!{N*5P#Ui|e1gSFBwflk#so=I?duV$TR*v3{XB1nr~xkg^=$g}4-!#$TtMr3FRDv4p#v2%3@> zT9}21Lpat8JvTHh@D6_<`uIWy;^{Vu;4e!F7oktk?Wr&$!}zQ9>YCosPf z`_=NoRSrspKf8Oc;t6_==C7pv>Mhy?kWK_k=**6TR^bXkt@%aP)WHmBMT|x6NpFbv zn;mf|Xv~%0AOYYn%g(Icf{VG?S~0o-L4-R~{0eiS71+1`)af@vEb8oCCi2$iQl$Hm z`YSL1SI%jp7oqmqZ&OrcYyzLi1iK!UBf<`mjQl^1)~V;Ozp$eU_$;+lEb4IH^?Bib zeS2+N24FkaGPxZ;jj;?rQ*ImTpMr)96a&{wk#|a9DP!MzM;y#87?O5sZJ`>|+c71) z_)co>NK!{V{#O|jn<#e~bURC2?}VJ3u(YoZwC9@p&?&}U!^#AL3asnrDrkT!qrOjJ z{Do7jFwZ9>tzVH)&%~=-u76ckxE)GL^ghe(G#%U2$g1v8HwM@X|3DG->V#dSv0yhf za~ILqEZlX(AKdTF*`GhRb?z=1&}_?@jpyLVzXQLL{f!!Nz&Zq{lMm8cN`pmNgGCEo zuBbBM4-)=<{8tNYv5@r0>=)c?v!>tT8p|ss*~@@aBdZX z#r~YpyaQ(P?6gH;*K< z!XEZxV9ET`yH| z@+Y`g!w36^IMN6E_N2a+{#~>0fjl5Af zDczrD8Wibh`twBPpBbbr3UTERhj2 zJ$N`6oys?7$%`J@Utla>Q9?_u!52~5&PB;W3pwyr>C{CDKa5ivw;EN$ds>;ztrx;% z*pN8YUj^r?Ko5!&Qu^Ca3spGe#sZ>%i4Xa}sX#Xr7x7lQ0SKimej9^-h##vQLy-JZ zA0bz=gFeS_CwX7sLb>OG4zmYkTCC?rQ~@tp;AobWoDP&@o)SHj^t|9E$L1`&g{9C_EL!V3^v1y^(vVL6P1gy+rdb@gdN0xDFHPa%TrA zpNe-fWntOGa;@31SyIstrokdSHc($4Q^^w6Qs#-|&ib>!be6p2Jreonpw z+(6L{@5fqM5EKertuTH$E?E!H9rhFbI}TK6DN)$_D*e5MY*=|hjx8Mn z%%hF&wc1L0<=K(AykLG3@;~O_KwuQT%7x)DK{+Wn(!U)Dy|fXc3MdYqwQn4 zS0>H|o0d1X$Z^z;h%+)<1RUp-TE9r1^=UWZVR7n28Zqe_tg{q~ZDc()RVuX%hVmg+ zi0{4Hk+dKpSGVb4)&*L7kD4n&bAarA6N~^AplCiK<3_Fq3atyN@hXzMH?SYj+HKp( z@b3&zncdz7vHy&!E&cf-7%*Z0=L$qPoV)d0zyyf2rq-2_C>|{qUb{wPOFY!S7Af?N zh#1{Ww##Uq8b9eGd&y=Y5y>c9qRd`ld4=;x;yVFH6Wx`uE#Bmsv*y-F0# zFiKrr+d=atqRSEjC!^aAq-=E@DJhJO;BSmFnRBL%#Yc41M;CfRW@97nEx0efLp{5k zL-J5&ab!0WL6yu@nqfAim8nfogrm((GMD4jJ}Oiyt4~U~!H{<~FcDMzFx(n}@hZaf zq(5vMyeW!6P+6+krOmGiIJfT^rlUctOLl#36j{EYF+K9^en}D%r9lHCi>u-KTxV5| z4tt&~&hBZI|5#TtIr7a#PITVZZg!q(QsS>)9{31A)o-kyaMY8{#Of8~c;ZQFAT(@T z4f-jtzFLX2#sYB&>(l`e=Y_5Ns+u&;@=X9lUGq#zS{6K_EP-1;>%0TUxV@u;K@rr} zruA=D$>#O~EXuV5cBBHxHMGAPSG-@J)xw5lyvfN)4Lhw1SDilz!wv#+dQfAF7S`A` z{{i6mXW~$uAtVuxNq!>A_R6sBL2E;Yh|PbH?v}MRUgs0jne*8xG0FY~S~qCVarZVs zbTB}ml2(*&j2%AfwW|+VsK$BP<9F5(*3W+h>gpQSDvBoOhT9@&xh{ym8-$;a>>=q` z{qcDw#x;aM^;rv(CBEUwCWPhmT>q;4W<%u0&%@bv&HaQ3PM*9+f-=I^9z@1wfCLJr7k0C z$~J>uPUz9f$Mf)vFuIQiH+kk=KFrv&Sviv=-!YcYPVNbJ{`x7YhnoiaTCg4qIP<9O zMYwDBE$sZnC_77{Kc5_`#VsLj!bcv8SRBC&)^ZL6kkS>~gjY zYAvBZ4wC#wBGOmZURr-cYCedXgcdSPuyo(gi6H-8(ZH*|7wTqcN@7Hf>?%78b6#Oq zL`woNTt@|kUA8@Buhs}ec2*c@HG(gTbQsKe)Lz%hrEe;NC{UQi&a`oxdDlj9K z?Xsf2kOzPE*n3&$hlFUckrgO|iaMNP<#fJ5DcTV|NqIrN-_lV=VNl&09kjMu;a&ld zMhy06C|(|hLq(vxD?YR#;Dq$ygijB7CXU!E5^i&8Gu!J7rF95<+QHec`MQlD) zE3o5C<_TQ){$>p!jrL~w$YW0YpDtY*KvH6emzmRM$!XbF5^ZZnI!&8U1dXb*?KfHP>1T0IDC}e?p%kB~LW=E6l_NrA_X-@n#b)dPn2B`t4rkj$MLmB`=fqDoyjHLB;MUSatxY;GX? zp!^vy4Jm7jXv*orEa{ zq3-u*C4INT@y(5&*2u$%wk#qwDI{X zDGPB_(sMByJ4dKs_HLH3Id_~qk_MZTFel3un~ibBwD!_?aWEHWCRe~ed0Wl@oJkQ+ z+u{W&O|nzyOLeBEVob&{p&cYwT+9BV7j!cKWHROc7#w=QI>sG@J5UCss`lO#Pda`G z$_|Nf4txe3vliDz%Bocyaz>T0##b|p9@NJKWFTuV;r|ulDW@H#Zf9{?<|?}vpWpvt zw+Q=FP7>+__~7wyW`O(}D+7pdNqyt;F0;qcYyN$b=$l+2mi#7%8I%%pgm9sj0N(Ha7;g34$9vz&g@#jRFT4v z*c<8u)T}(&JXh3hrf4BF{b z2QxY(50v5k@roS{G^}x0H(ux};qy&-!I6Yq;p>@kgwm&KhKH!7KPJ3ne6p3&N!=HG zR2%<%(J(uqta(KOt(vpXJD$9OuZb`iQ6mUsf*rIbifWR<2=WiU6gS^zd{{6_0HKzw z!V$iLBUiQ}Z>=7|D;(v+|Dx_O1L;SdiY44=u>}v?wJ74|fAgX6BNSd6AoV!Djy@TB zi&~sFvmM=W#EgIUVZd;CrSTcwFd=~33+$aBpwARLmP;Kyhim60DK=rUm6pa7v z@C@e9d1#Hbo!hI$-1yzCjT}a41^BWXrcreH=J6BY4ZrmyHv--a!G-=|ug3s~s_YXf z)PzTLz0C~nb87;7HD=`Tj8ynF<>TY08+oPDUNiN=II#iCZbSjnn$_4YCwxq23rjZj zhpANPbfF6574v^X=2nZ0PWk#uC8ztnc^fb`pEJ>?WOf;J%PQ*8QNDed5%&KCo@4-p zQ;!h~ZaqOFDRNXXc=1uU{t9@Uvl64K8Si4%V{f&hbus-N1lg?H^RaGyD5ukk-)TiD zbjiHXo67TdkIpBo@s|Vgk8`4_D*t?^e=&NSom*KwvfAyx1v@YK2`U|()%k$8co_v zoeDl&p`Z84A7oMr3Ch>7Yw{T6vIfXAi(u9*KmM2(v~ADPfg6!6etd0;TCKEpOV?Ma zpc0Z~q6GuaHJ8|1Vq!%dAv_KgBaJ^KKZ=hT0tvX^jN&#e?)m!F4>b`k&Sx~| z{c+OldNN1H`Eo%8^i0?3-cI3I{3&dnF2|YT0b<^)ENr3#) z+>3w+%!avi7L=yacX=AL$2sL|k2_y#Uu9CjUwm!|kpp^)qEVh%^_%FnV)IB&5!r3= zRT2Kmo-LRXlxy4c;@KgbMO|zajAwrAWzC5@)t>4u|95v0b4K0PMjxaZk+!D2a{PqV zSI;H6BZ{iV)L!WOllhtXu!X}#c+Bs=m?q4z)pYriw{de(7zAr!sDRCgRgdwPV|Dig z4bHL#5m5A`gn6bnxV2%q5Xl5vl~7PSSxtANCc5O@OS~9 zeD@&Q?<+RxV}tS4#%V~T91bv-sM5w+>E5*uYtHYw1E;|M{bfNulSrl<=)04RD99f> z&;#q`to2JwrUMDs=(_X8csAN~|0ZzkjWd@$sr$aB#QF>kd^FkSj*l)xdOgt#;=l${ z11*|@d?COvGkXjUCOl`d)K_qdyYVBDG(V6LD$K$rQeaR?MDA1iGGYc+W+6&{?R^gd z8tL5WoFoKIqQq^}b7z4Ow%c%NPg%r;`9Y~mI+-VI*zDU%D0Fc;2#nP+`;32WKBsUA zDUjcdCRi~t;@)f+o`2QP$~8czoe*$x;GE3<`#pTlpntU%^0p+6H(&p=WeuSnG2>}< zzt7FT5=V*0-@e6fxfHNeS7QISUh;S|er-j}(46zw^hbS)aS9KN^|GfvR_bzf#;?hbtGt*+thm;rh!-rT`*QQY1e@0->L}k@1y;^dmfV3~n=$PM}MbuDRK1426FvT(>=HOo< zmlaP%I!muyhZ=JJp<%z!M!kYay9R%J;yNVb&V}eA9-CgUs8wTb;x(Lw-yTf8-Ny@} zd#)o|(k*lYx>=`99Mej0v40v>dXFB8D$-p0jD|v*$Vk!at;s z3%ArSVosXh6mMbH-EY)Ne3t3b#ncA4o8XKMm43ZW4jSP3dTd0F&KJfb-tCwcVjY;= zcXs3j)0v259(}ZqEQv1`aT%BjR1Ec5ho_(l4E(oWnnftsX-m+Ka>Bdb(#nS)s^rS1 z`3u!S3o{s|F5eC);AIL^ZauB~bpFZ1k7D-Yn#q7?yihszn! z-X2-$89b@huQQjZ9h|NEIG~i*-vBe!R|o;C%gJQMIcEHDWIrLXc=AZ&0IA(V|H`!I zQQ)C*W_50nz(}=znBu!eaUepNMyOlVGJB&mg}wZ@>Jd*zs}r1taHo&4^e%qGfAK(! zNHCgE5+s_`(M7JiDImF=Yhb8%N;dsB)IRQ{mOOG9?A}0Tx{47Fl~J{oBpKmG9%J@J zrE-2Io1}E3LHZC>FIID$E5N%*dD?!no}nLO+Prm+_)(osn>idgKxY^Z5Vz~GbQ&$0 zKva9uk-RIdB;Y`Ofg%o%z>$hEph={G>wZxT`>a#Q|BPj7ZOvWfL*9FSdL@y>Fe^n%@P-=1V4CU)4;&mBr3gC`A3~(!>DgaU~Q{}Zu zRZ>h2eK3}sRsRblSj)J~V>IQ#J_Rdz79fxY63?%;hrCcQ(fVb;QLfp6L9~=GgT2;Q zAw}xGplmrjAO@7O&RQR3OXKQfBzcKYjaYFQeLFD64)J>o)Z3|yj@={t?FVXvcQJQS zz-C_??q8!X;S$riKE z+$3YSvjuA#NcZ7TUf0&Vm)m^PA0}zRs0(4%Ug*MZ!pNKt1wS%rEhjj+;Gh$1ZaV(#Vuz` zt$w~{JvZR5=i1vi|9X-oPiOVRXXAQbHQ`|Z89ZC(KxL1Zznylm^cZFRMi{t$JrFkf z__LHgd70H!X5{9@*Y8ivU^kD$EMKd42CaTeV6xuYchAM(t%Nx#7*dh^e6~0^J*`EP zZD&-I6DXk!({QtQAyry7k+714R-<1bywdao2ZfzZ7zGc)Bbj(Nx#RP>T6{AR;GTfJ zcGpwD=Z1Vl#O=CmsAi{pV6&cxMq?m34`43qXO1PZEzgBDUne78`K->K@YvM8>tRqT z!uOd|VzO`sl!mXEe!4y3nIP%%^wF8wm-o}yuDeNkvp&|_QIcv`RUj5+?=!0u`dHve zCDGk_v*F3rGl+bGQFa-%^e#P84z{(EholjI!^MU*(AYhCeb$lb9GqAGZJ$S5;jh`J zGe%;&(D5Gv9e*1Wjq98TXO4dfM^Y;V$uTp`ESH-8{g)c%k0aBB#Gi*Y>mUQ`Dw zv||92lX?IOFJT}G#x%ncm5|G;d%&9-lH-)~kp29V0sowR=a^CI+u=SyPp@1_D#eH6 zA?!Yw^UkrG81v8l)TH;qqhU>Bf zY8U3e{#w=!)&1VwKyDO;o5EGV^MBDn998?9n&& z4xNb$C!c^05BF^l+#j-yq$+6&7Ok?7TTV>`^iNurwZP5+1s?Hq;?HC7c0~(qI~~OC zjx$)5-d)N#EbAt9<$sHTb2^pOM&L~(p?KETJL3z&q^P8WU;%K{-qVh#@f7dtN|l0R z6LfS%^ufDXzv*b`6GKt$1dn!-Hz?=Lpl9sTTzOW6$W>hl;`*D&FimA=31kOUcjwGt z4wBU4`{h=HR@`mR^2_WB`k`i_Fk%j%{2;`0I^-cFsz==!g)YWe?Btq&q{?LqqC*7U0?FiMgzw$LP$Z!OFcc@(lR;Gi@E&hA4hlXFrgvRqbvox018vB zO$^kz_YFV?LgbKYh&WT0R2-|#WdyOy)4Gc7n*m{qF*wX&D?>=9Z5X;(fiX^q5IMz@ zO}qe%Qo;7?=Eo}T(go9lZC0F1P{TlBnAf8IXCFF@Ih#zz9;4B;{991 z0U&G+_E2mqMiRmlMVELn0SBl-|D&nb8n$i@vWC*Dy&>n~oRPyY&i;)<#A;{b+0vmr z9R@s1BNCxNa-h_?!ToK^b!=rvNvOe@mqe7aBXUH=Sfj?dUy?(TROpnI|Pm!z~)!IAHJ*-{N$X$REZsE}Ihbk!g0?jX%&< z47wA}ir+%bP}NV$uGiWA{X*qFI)<h*Vs?OBlG6R&9UGcOzTvZj z)Nr#eXiF`LP3dQH*4frD!*k&~iKrN;ESjtoNnp!CU}=G!daiQQ@-b<|Ou{frA-Z;x zidq}JsrvSke?p47>wQ`yhCbKxnu|Q6K2p?E;zqvLIkI?x>=Se7?^$L{2dO-#4k}}& zBP$kUv{2x8zLk*y@1Mqne32h-$7%%S6JTp#C~GZeC{{1<<;xtw0o#eQ6 z8N%lKf(zD($w>nrQ!*!kq!tU`DgU~*zOSxhYeVIa{Jq!@g+#@D>Ui7J{K@bBA{@ez zAtBvK4wy$CH(3f*xREa4YyW9FWY&=9RI@$|-5P;#u0|7{){v{85CHzpqOk;ec@F!F ziQIY3=bE*`@WEoGAi7uHVH)jc@I~QajnaR##l$5POj7|Y`??g#w90ucNMt}QTO1ko zN4B_)sNJrIk`wuQq^1cOnP>Z}xn7$E^p=?xE~xRrSBDX8`Pq)5f{IFsx^XpEDStD` znEv5~{4G-xFis{>lT%i`;~x_C)@+8JzQL(iwj|m`Abq-wiJ!UcQi*3%Qfi2mteey5 z7rU3yBkB0km*dR46x!i+G{M6rd4&*R8j9S>PFqQ8%np|GR_lrdf!)GT?hj~p`lKI7 zC(>6T19k;JyT?PAW$}~l;YYXDaiemOm&*T-ve^W9rl$-;$)QVBn^UI|Y}2oTXFaoT zVk$10B5qSGD9B=zefl-S;hHPY*qKMyo;D%ZvYFMxqX(|TQ5EhMU+Dha=pzCIpx(xS zu2LP9)|*b}HF32a|D#X)qMO*O{j3fKddG#%le`r=;QYXlUz+Z(e2cxiC0Y(FSgU4^ zMJf+f-=nxsNHIFp>ABGXqb^!q`{YO1BR=`wjVY1CXBD5cfrjD>uvAo*Gk~1Cj=XZH zoIfujepsNSFJ)uWNe;1$Z?=`FvOVLXi*T_rC^V6!NM_9&Zy9XV%~zs1!_nQcM~mke z`OBo%yvd7eMpP@SwD_ZdkoDCBv@}2+Ut&oJf6GS3d3HU{FZb_0xWE~a6=-WJ@RO1} zRlMslM^mQr9v3F_X>Hm>KJr&h)i5{z`byk6wl6ooh`4X)!>6y#)OGD~hISI097j2N zyvHiFIamviee9jtA^&(B9h88XwvVpPp;pZr_k)XD~K>bjQdF5+RbGWscV6}0K_SOkQFh+CgkD8Xz>Z5-M-81Ht z-wEEbFTeUfC2Q3K5}&Q6BOMbM6VWfpgUv3*q@3c%&>CD)W((ZRsglH*D@++zQ80?q z_O}67;$>O=E~qmJ9M-UntF?3G+e&biXkL^xscSu}Alk~d+i#v1=icnbl_>D1&!ohp zQoyqiEWL=bT6J>Ws7!mQFHKRUbV+jT#%EgVQOgrRRsNDHH7&N7;cqtP0|btbLhQ>w zc@O`Cw{wURMhTX5+qP}nwr$(CZQFMLZQHhO+xFZuvzXP}zGc;-PVOpCX2ci6*lblF z_KB0bRSEK&b%Z|krf})$ED0Ncgyb&+ak?A2?iVq^>;|5r@O$mMoyUFY;d>7aqCcl? zEhCfu8>lIm`czz+Kp1vbPuNeXhf(s{%@pG%633&{X(NbOV{S^2_NTlqYW0n^fL0`5 zA;s-1Px$$oe$4oVXO1Z6r= z@kHV-!hUU`Ya^HE#7HCNgXX!|N#YUSM(jmiS}HL&C&RbJVuEP9}n&s2?Q_V?HxptzQMu^fIP>NzI}E7G2h-} zHX|tj5j5EK<{)a#WWG?H^>d^(KPMwA54`+`ZGZ!6F#{lwWqH3UOW)7(tAI`x6(ekn|GX1k=2$u-`}Zc_^VB>@-M0XpAkUBfTf-d?*PO2)*~ZjVg^sd>@)~T^ zk-PMEYn#7fGRG$uBwZpopYuavhS}~zC)Mp~pKhG9D`xDguR&x$4A{KgB>VVoV#Vmg z{3g;g{o1#osi!H{ODokYSA_3Dmf+R`%2Hk=(Q@K9L=4r3Aw4FNuRB}Ma{%_h3X0L z9+hx+1-E&Fhv%i~re+RgIO+3r{!C)e=MWe87Oce4od(JmN=^{uO&f!vjMd4q0&M71 zl1UytYzMdGE*)wrWog>7JW9n=3SHs|cNmO_McoE<#3W9~k%vvfP{yhlFz?D@ye55Gx7PhNrX2gB5 z-@!^DNX?B|1>0~LB>ibNHVDZbn&-&MjK1zjg{jfo2Frcy%H>nmeOL^!V(n-JQ7lx!c}XI z9(4u6k*6*UXB0&irh=kWX3KXGTM0xu2SOA-6Z5My4lnL^CO_w2#5HjluGJD35KEme z`!m5C^2yrEZv;=@lo$tKE<}XXOIHsn8O3PHfBvD?er!lrouj1>3U|1I831lxt^sl? z>cJ49gE-^fsxbHoX>F$}FiWja0VAf3AUnR7j9lEFtWlJ?}Hp(<<#tj z$(P6;*pB>zLLrIxbCo0ucDZx?{LcjmJ5%RFs;mIht-?e>pptI;7-MQD(&8UXEIa;v z^lIV-qT|TX0m$6(3GX_3PNjEMZFvabLpDTtq)?2L7W1r8W4pCBZ7@@WQZJ8~xyH6P2sRza4d#yw0q@2SQgH{EeAIXg!K_@I z17h~E2O8hy4#VC zE45c~#~pOrvW}w#jEM7*`~ZJad!a5(=eYM9mSg)?_xBPE!iKDGn+Pg7YU zrGZr&hQWS;#3EME6N=dAKc^j`<*I;+1dr-1>QKAgE(g>>vkX7GywrJu>*6ul?a(Dr*Z}UsmTLb7Xh0x90>T}{m05KEbY91vJ54x^xJbohZlh5sA^QlH^2YZ&^h;kK~C%y}Ui8SVqT_;5Q-WJ4Ge( zErg{DBXLKK1VPw%g@8KQ#GU%Ca3b}UPb%EtGVnXf(33mKdv)|r$KHIAb&=VprBLVuR#yl-Or?~wh%*k*m$P1vY0_Y?#zpW1 z)+9b8`7S9p-AXFUo57%TUhsBRE;r0Be%K6V0P=_@WW*1>mUC)#e+|CldupWBAQ|Zr zcA=(lGeIvIXTyIPE-p$_DhTkroLkfW9EuQ-j%&|B(%abv;3+f|NuaAza9d1gwGeuw z_F27psWC&Q9|t|A{0*6NKyW*m|7{6rAfkpx59VnbLt;O}?+-ZAcGF^_3zy(q%)TTw zKN9A8ny-(X430`G$Iv=MMxOOOxdyS=@3qX67eEv2Rn`igK5Xx4Y;iVz9lZRL#}Qfk ziW6Ml1DOu}MnKDZmTHt?!R3lvz|-+l$#yMkQ|hE8fha9yXWpf7LeJtRBXJuUxlnkO zos8`Hbl(#i0sHNgGAKpBT2@c40MTsx*mO9Pyp8c-{qc^qGb4Mnm1WO?T&l6^K11i6-33E+)oSmG0D&W6+B6lHm16uhD z0LIAxowjnJN4~t{Y)suyuq0CWIqVog=riF$NL9y?fRh9W=^WYE)r|ymdU;>`moBS{ zeoW0G3KK!H!JS~Sy_*>Dca&H-uA%g+P^viohodb{2yUZ5fd@$PL+EUzk(ELuxIe`F zH6p6OXzw~qXkBm63JT7iJlta z0Dr;HZ2-A3GyB%mv^AaTk0L}a-WIcImmnUc0kk^FhM~;Wb?H!d&t*? z-g0GUdT0q8`R1SB=U*s7(M3h`LZ=T}a_^z9g%t+NNrFATaT3In1ebe>AMHN zUMY?ykt?9-QRC{yfNI4i*FI@x=#uF3zliUAyBP)X?NVHtY8DW&Z`DwM9@#L30U*4K zQDb|st4G~Zu6{BeAbBYF|Jsn1$%||_W}YQ{z@s4Z-;R^UIH`q`A zE$`{W>^dRUt<%tA?B-a*mawCdByI6Db|Gd_!89Q8I9BLSUv_qTg&uzf>wrSfY!W++ zE>lpnWdW~WZN@P%Q#fu|$&Jjl@FB)6ZVbs*4fd;bxIhG7RT#PW|9TJjT+X=q69v_- z+NhGXS4%um(=`+-?T;m&*t_?E#5?VL7Nfh0c?*(j9eW){+cs8gyJ|8DG+uh@k4QdLh=K!Gd%$uGynDf3;f8|rp0zl^}0sYZlKGSD5 zteN_!ZIFz99;i~hvMpU%F9+udE6li=({^rMNLcDQ=cUqmovxJDuE)^g&T|_)81IA@ z42C9-y;~)H&FHUfH2lrwY#`H2MGn?p=?6N#W%boH8UN*THj-(kBZum)_5mH;vHI(q zjs5XCo5(cLkwbOW_<@e?TK)FU#Q*x7j%S+b$f3GxeSu0gz1+ot3y&O4OG$r31nQwV zgDVq?I}M9t4)YT+1aRab8N1J3%m<23+|VH#&uO<0P0buKvTr~1rRxxU)IvOvAq#Q{ znQ6_FajEW~UlXrn%^b=9unV;Qnjr0feq9+uXrVHe=FFX-f-jMUWC1&a*XG1ir*4T? zLqta?F!?`U8esI;!df}+Du=9J2n(Ys)DaaeLS=%a6q-AL5Tyw4c3?e*&+^yA;kd=P zq#^w}-S4uk(k5Ho_d^Z$TD#OW(eGMlF$bJkcOf%=^x5tbR;KUwRjV6K=HChNU)t6wfGRTbJ7QgH!N3P;(jD`jYJ~Cyi|YyqYQxp1 z)EV8ll3AD0Q<`fW9aGalJBNeBR!u?9BCtM(%D; z=u=oa=nlGz_;VTW6R~^me7Sm!baWvSEKL( zN*yfaoV3a2P^n(VoQ+x%s^M+>&di5cvoEfXF(3Fg9C&QGeB2_sa7fsQ+Y^mY;dzvL zjp(uL_#Rb!%VzN^B_7$(kPpXyUBhPEPTj|QL3%z6o$)SIfA-p{)cc?Ln1#~}$-Ill zR7;L({lK$dBpbtVyL48S84-npmrKy#&+_QKHC}^iN~7k4TgsyrxkzC7aD zU!vTI4(E0knX-<_z$MmzvL+>^SxLJ@`OuO(1Vi3^jOB5!+*yQlGt55GCcJr5nQJj+ zV^xSgRD^`R2a&qdb)`N@a8`1(-6AhBdNz63s~lhO_UBm02IFlYuXU`r#9ZM&fAYXa z?q&&cWm6dpVkP!4%J!}g4zZePec^fPOrf-hSav0J2+}d$r5GeuJ7JZZ1VK2@d&EdC zH3W7qzc~uu`TyEXXT0{GR!|Ptk#AV!o-542h&9)jdJEh`-o6#jVd>G+Yk^ISFAmx%?_;NZk>pnkj~-Ga1g+WT)CcUB0J`mFXS5m(Qlq;U}^1L4L91Be6IJxtZt4D!L&3zpeb{i z6TS}_dioHjqVK8=sj;j!O$`n`eTWm$KhxS9yPGn@_IP1uZK%ecHR;ZhVDip;-zJ>q z!f`+h`bJ-`zIMq*3<8N&KD!Z4LOx;-1CZeYBsWFZ5xLCaXTSHf7DAR@CUC!|*KZ~# zACtRanV@fc?SCq{?nsIF>IrXcZs?6iyF2T8Q}N#JhTe3%H#?!%?f>JoyOLh;KgK@? z8aMKm3_iK?nT+3RSsf>=q%>>%saCj%`#ILpScMG8@=q5=-j?|T>n4QHc%tdOg74o9 zHFu9y5@^SM+dyNft51*bumZEppAyuDeBgYQw9^A3a_*VP%`Nl9AJ!&U8o6S2fJ#59455pT#PvzgRMpS{#k`hZqm+1Pnu7dpLhJc9xzU!+5n4M3B0w>0;S7Tg9+`%x9FkS*)_v-?( z=UtU=(Lm}Qg)AGjZy>PpaxE1{E$8GvO;Np75cGsIP76WT=Xo;y=o2CGWjTATkmM++ zz`OHG_WG8SlFY|kPrz`mu~y2{Wa7^$jYXE>ZvUeOoNer!9$B#m^)@we+1J3aD@jG!EC75i!z@N z@2`m`ne4>lep+6;+Ixs9M1w#a4n%fJ`Fr8o3a0+e!?FeE6;DZjZNmN>JkHAoMB?uV z1B*^VfeglQ|FK#R8zH8y9HX(Lo!X&+bkM3y$&G6t5pp9e4MiquE_|?BW6haf@N*qH zUP7T5K9#Xl1wu9tv9*bnpja&$iCsoD0WcB8pwB`7%99I_e}+o}gUuNru`zRy24^tEA|G&m=~tAZ6^F>5HfOEaskJDebuJ_o1BBNQp78 z5%?q8EJ%pSgl-{4i(5+;i-BlD4I~#7T_G-wo#T8pW@t*&)%PPAhb>M9D4Ufb$C{@4 zO|{?!i9?52z9@bCRF)w&3gSk<79=)F&oopGBn+9sic3p~f^h;DQ@ag#s?iBR!&+`j z@oT_rbpP$5`&BZNywz0u_~v=au6Pvj(kdM?Z@IybP8gnnfTx;Mx)%OC^tTrwrjj)A zcJVbd6Iy%n-KV{oibIQ6pOhi)adPBTkJ_74{<6^;*o6F2Z`&0UK>um;NJt!F{ zz&Ste2>YXHN+Mu$b8NUvvw7C8Oi!M?0W*5+>U`lZfDaYh8aVd=D-eTT1H~ig?Q!AV{ zwB%&P#gZ45j`R_V^GuFK*c!)en671AQ5`g3RB4Gj_Z68ei|I(uF6#`&%6ZGBMa#~DB#l`!sgx^7f=ppe*0{{!&f#0g*^w8S-> zW`*5P4ZK-g8nMVEich5IN2==!;=W3oL@}bYtc%A*{rDw z%+ukxf)0u!^Y7_9A1MlAjn5#5*NGrA9njDRmIBZ@=@B~hlhh=s-&{9cr9xYPL;&f{ zA|#y7mJLf`sMvFnU4=gv+Nri}Lc&Qk@mbcFVB8+Vv%pH;j0Z=O^Lxeip46~_6wP(r z)rhH(eiUqcaztwPKnFx#&kFMp*ncKjtRNtGL4I4`jvFjEeX?}hA{Don_h;kMCMQ1c zw1u45lCJtIonPoM29RMK$%fcF8IQ8Pgju#*il&Ez^P=C+XQdRX^r3rrv&fqLp361^ zf$xhXbexd|lzEo=QK;$S%kM&RTDvz7b3E$y$who9$);X$(%J#+vF*S&`bZd=(*=f; zHA012HwR~a#@^1;>(N13oK8}$YY;0O1BPOOm^P3BFi)r{cn>_rS;Mbc&Ozo=El0>k z5ynd)z34dkYG|(l`S(0sG{A_iw}aHPTy^AAcuHX>@s~U@UATiC;T+auRBBqTnVj`S zM)&65Z;dBv1rCLDuYpE)j0oaycCz>zDGvA6e4z?4$MvR`P=zsNX$BYaG4e-cZhXhf z@q?>5fWbhkStBWS?;maKpMHq$DC`<`?b<(;+Lsf?+O)XSV$n+)mlJ(K%qHYvXI2qz zibwv^$0GTJ-&1#2af-bHnh?3puF7SZNQ#ZZ-MYp7rZa%DHP zaL#1QU-|CwSGc|IH>K>6HFkt%D!M7-46|PY5HN~?D<`>*OAGPe+*g|JAN|Dvbc4`} z0;}Y0!i8lc(f}Z9*y6e+D@@!b&9nn_K6o*Ppe=66$sz~nvgvt&WyrZAgG8ZPzkSij zOdl(foDr>Eg!`<1m{Qm2e>wyL?2aJbt7%xFkiK;Qi*8jEUiC@nF!+rE@mTc8+qF`T zpS?soi1o~(iaI4?0b?KBFs9QqQr56B#MtZ~eW+b)q@rnSbz0a%naG$khVemY6{oL! zRT&EgEm$qVy>9z5O2;@ZkpWr1brk0VWlJ5vi8(gd^0sjGOSa;BWKHj{cvHG%@tRSVsl3h${obqrKM z`ZLd_`%%8M4mJ6k*ii8r2)&>sk=?nK|Nb{{C>|hFZZ-(^(Z+gzwO{* z&46wIyeC+E(wgHt+8UC`SEy)MtD4%HzQbudfDQ-?Ik0hkw*R2} zW>LbIB9~d=qdrnRYr*Qb*^MHu_o3VEa?^R0{%eTrftVN#zplA$y*deBO0r4g!ohIV zoijYMFj_5gZa#jNyhuz+znf7|59vu{5o|)8tpHQzEZ#;VNVQ$4wZvxV;1 zpa1i0H|9q?{l_f$D)?+NiUR;Yw)KUfB>qg(k25)upl77gy+#umCY;`_pyAWJkJOT_ zm|fR(Vrz33b1;}Nz``BcgUx4s&-oC)i?_}5?6GvMf|=uqFe9WnBLRTBZ2XoM+7@Hk z{3?#PDs||VbO=yDRkDt}$U`_n2ugiu9b{OIe;(g(`7VIvei% zkD`v&RyZYR{H!)C5PaLAWGEzBX~WdaZ@|-^_#j;`VIUa~SQJujOrMMsMbs2}_mP6& zgnr-NpYacdM38(Fjj<2%sA8xh8I{u+lrQmsCJ~dD`HaoK+orp(CN$IO#Pt|Iw?V3* zgzcA0*zC)%ks@G_!+J^7Zf}1pvt)4pa5uSInP4+3@3jzZ>kIAYx7h6#o#?%h(+_bH zx>`4g0u6BM&szI58ih+%yv?+knU`z=R8oJ$yeH+>d4yJM38d90mx6ss=^1yrlm1T) zZj*?SL0^opP8sYBoKIV_#+!J+A%=7`24Ul4T=tv{7XHI3N?1B-1N^z22O$zn`4u$V zd}y@4)e~LIo`Q-F6KDXNh4ov+?=F}qd86iJ z>Qau8XJWmR_c(E>x{F9q^rPC_^^A_JfTNoH(rGj+ivQ-r{g6Gr(h~ov{HaDM-T_9$ zY1rOn-noz^CNEtKYsW~i!3qN7q(BGcAft%0NFpCO75Q4V>?XCT>kMG{zM4TkUC0f3 z0_6xnaZsH3(1|vhm<%`u$E~+?#2&49!xdH8^j9}2$3}T>AE%}uOOv1_-Xj9B?3naF z0?{HZjKT5d_Xb*^eILLCbg%Ct=fypygetnDDHoMW2`#8p#y$86{bwmZNSk`e0J+`h zXH3tZox@X~`AQx(KlK^NkRATDNQcTU9#+PBqgcVZB+9~Oa}tkerQXQ^Jhfp=6@FKM zO}35E-v!UCDE4NUp+?k;I%c(7J5cl|K%dkRwc@tRGR2J3Xa{}wZ1jgtpIRFnIwG{I zb!C-UcO5%ncG3>jgyp?V?&J$trOVgqClKm*b?QoLSx_Fro3eHS#?funsy`|xyGd%L zKtS$$7NTj|!gM(#YXp^$m}i=XEm@{f6xOYAR!l1o@g+1~3&c&&;e`*!R1LdF`aW=0!H!(ZK{P8>z zM5qRe?#HZvA9Vm0XW2r~;R>{(+`k@Q6XPl;~dHElkUt}pi~P8PP}2WfyY4$V-y+uq41+eNW& zbCdJ0`Ew(u6}H5(UVO+VkTO69-4>LMnq=46RTgt=YL(*AYvGrSN&w{#Jv1j)?;JIv z(dGN|YeOc<%8i8iMw02DguPGlfV$aNbR`;1{1DMtkHE_Do0@v7KPqde~_CaWSaC_1cQe%v<|_3YtB=4*LQ{bNJ}P8QL{6xl4mz z6oKMz&<@FWnZemrI8CLT(!o^H7uguf`Ok@T|17i^#Z2ED_1RCja!&vhi-E}VM76vF z&?_zbjN2#So>vORJ>KF%#)IC?hk~Z}slZu|t@zuK2jx)WC7>AC&Qz<$Y0~(y8N3Oc z&-JWprrsGiWtOwR0wFYj{$AG}3-%a<=NBLfj`<{Tb6!sMwGRutJ?5KUA{uqA)h&j& zJdU(cNJcjWMnQr5-!hkS9P&_d(xEW)X$*O%koQs5j+U7+<|=v-DEhl-n~o4er<1$= zuzf9o^rfa~kvbN@cP#^uSS30v{DhF8DBNHa^U@TA9A56_-0fTpKeeN6{8ABJD)~z( z=tFQ6w!o8gv!$tOauqb{n0hLc+QtIX0M8q9zyh4GP#EfI`_Ko@tKh!z5FB|>^g=u>sj;F;6p!YZ3%Qkg}2jDIv=Sr_-x&XN(^`PoyS$eJOXkLMCmJk8L zY=XY+S3s+pd6`kje@YWvi_QD5rv;XKoXrBui|EF;S1r|z!YfgvDNwDM+Y*JlZqPi- zqCblAGEAvDja9{sT&>D_(mQyQ7(>7j3Q}9l3H2&5op?o-N+#wwt73|kg)a@I`Mmxd z#aa-{IF>xDyfl|e9_cfg2`0V2bau(V}3NU@+lI1f`Ob{3Vn!QcobdC#X{@q9dhtHu_N6Q+Mv zOD*Wq1vdYFAQ~;dnJGgEb||T=kzq9C0k)`M^esqr;y;Wgv$}38!CKE#xp)6l+3gbHYlQ zIOsHlt|F;YA0j>u5?IQGKt@+go@=^!)5`)-D4LJUibJe>|QLu7HSyMDY$?9J` zrLx0@wf0U}iFy!NWvG?cd;*O+u&?gw2IxeWn>M3x)91|LGfb8q;g6qi8=r4eKYl3q@deG|i`+xu?u_oY zou*wMXo7R4<)7gFl(~h5`q_{oyi?fQXd1&JBKaN4kdZ?f&tF8n=bKiHS@0f%`0{On z?UdU(jkN>-^e}rMA|hkFe>-?`rI_Oe-_;>nB7-b!T=fYr?Bp&?XsrOFal}D=Fw5Iy zBAwTx0Z%tzOHt-9bPape!ucG`eTN843Er=(y|s&N02bn9rpJX%YOg~9ppT-hAPW2I z#ac;BNfjX`bI5+ludJ1_iVlKzK10Y&vRU6%*yn>lUp{V#GWS4$t;~UX3HK({zx1uxzwH!V{*mE-<3yBJNcWk2P1EM_ zWkx~(j4)!c{@}lX&4M>amtB3oqD*Tb5lUv5W2E`IQzv41XJW)Lp7DoejKE4w``3UF zt+yU$rQDS1oQ|7@G_{SS=fJl&3I|$@d2f2}2c@+)$BUSp%6zkzaWOEk;fs_!fuCR? z63QN&W^FIt=&WSIESW}FZ#egIC36}AVWO)Bh4FW6l98fwlTD)2q}WY7 znja@&Chw?%N#;W;hT#~wL8)HC0@dh#2#=O&>Zsb-DhvEo0e<++ToFnD+W@P&vJp8N zP)c0BzCwa1N#)nLKjj>{=63d0y1$_~iEL6(1Z9Io7TX?Opm${RLa#LrFaPp~z$oAW z>!!RE)z+w)Ys~yrZp%Mjbu)jNfJ$%5e=)B9CW?SARDIYU^dX8*9~I}67GXV9FMOg! zUVV?Dt2a}xa0@&wsSeaFk`9(v{WM5}I@6X-;B>+ob(lLY-C=0?A+l3@vYn>$2b6Io z^Sj@&yKc$!0W;DFu?(1ZDcpYFo;b8kRstuhR&mUodocJc;;V3BHLzg)R&B~hw*U}K?qkK5*alTIG`KN7g%{5vJn%3I_x7&uYs^E6^l`Ir<+ z-o8!cX!Er2A;^?FXmY&y76G2BbBm$wRv;P;p5FQ@(1z6&cMB+KF}ySZ*n5BlO{o0C zWoB_YQp##y$Vd9)Zgf=1rWor!o;{7L84ML}02-ria??=Tbu}uw*S`05d&R<7do8ac zT(Y=BEAi_?%$!GI6IfEe4+d{rShcZJZrZ8)1=jLmnR$`;RaEM)UQha@W_UJ&O8L99inB!5{p#$)I6Ni*gb`nx1m; zlx2w2e6x7Z(PH1_DAH=KK>yoZnFo0)T7(sQqOd|;?+wfPF;AvN6FGUx?`z$aRV{Xri~0jlst?U_DnR&YCH0?UD!^B_=kR ziKN7;Lb4~688HcHOY%o_2lcbsx+WRNFlLV{<<^i`wvj=KHQVSWPlUrb3}}*0$*{j6 z;y^XS7swset-KB8sYjt$Ym$Edcx#(72TEChqdez-q^M5$n^6zlm z5~ZL04pDJBGSt>6JLO#h$UzYZh*k_RtN&JFf|g(wMePw@KK2E?Gj6UIwJMJ`?>a+~ z1Npl`P?=#E=)pCCMj5Q4V|c;Im)-AaZ7jyM*}V&^RG!2BrZj{iu*^iz*?(e22r^FT zu)2}~#cUt4G9FCh$$ysJdGyhKew>Zt7QwS-!gtHx7$0h@Yl@~%f6#@USGGu$3=VC_ zWA-(}#L}xF-y!eWq7wJ_qZFX>nkk+VoZBHD17aen$TAUE zO0#AmVy0CR&Q5Ss3)rv51zgdwXuY>`&zI*Z1&cbqruSj1B&XhRi>B@;zlc8Icj-Sz0Jr%RlOVN3DiK@3^Fg}LH7e7;;puL!)K)0_ zeJ;)E)k)6m3n99>rR~~$@pYUHNN>mGgyk;OGmdDxL;_t;2yYg@N(MifHl)0`k$(Xq zASQjZ{>?*EnoCIVr04whPu;v~fsSVGuaPs@RMPsw7kl)wYb`9FM&G2!*-K(em+3h~ zT!EQ0^XeCDx1Q+&0BN@CHCL%W?MgEBzx`8ZHABz{nM>}?bopVgCzXpaZc?^lp{3Za zT_GErWGiZ7FGpuIfif#t{2C?v;oW73V0p@=ee{$H_yF4+`>ayrFUf~zkyIWULDR2& zE0@7LqRz9ep>0}J3h?gIs;*5^_ul)YMX_DU~t$CU0yImxYIW*F3v?Mo}zWI?NaR3+eeclL)>4y_5 z2NzucF&cMMrzSxC5pUiQ9V=~FteCVa#byvrlhj1n3RS&fcT5;&ef%m_EXU^v<7^zX zht&i_8sj8Q$G$Ny=mip|CbzBsh1$iy*&Bx6-?Vj)c_AWvt7q!}N~53Bs?(waZ!wl0M%@f@irHP{OH;Fp8Q~l|dGN_VG-+~=9O=^2O=;5q zbPaA{e+l10a!O4lQdbZ>Y`$DOJS#4zx~fNr8%4S+wAdqkcF%$y(i=ecVhTp2?#m57 z!Zf;P|H_f6yHE?`TV4>RQs)Rjp7*^iWQR{CPc4cQKKlGNj&{}*29W|cW_?-H2~c-4 zCrKKns`udfy=HmMn;?*%0%^CLgX>Yfkndid`5VUVMv3xdBL!9q`!Npt1~y9v8%E2_ z1XfHnI;KrJ#i|C!4V${e;IO)9BriSq!~S(R-!+TPw;h}r8^{YaeU_=KpyWoHj$562 z!C3emmM+?2xmM;RD-(WFj2ix{f|JO>X(=#Eca7hDK}v#gq(0FwEfrJfd~53#%dH8a zI!BDq1p(zfjK9G1Se4*@n1fsmcSrj8X3NtzfH8Edyra&dy8E;RFdTaEoI}~QJewDF zS*HbF37kk$@&D3IRuy!8g$RKQ)cg@h`J>KJ)ROw#F(j{Re#3 zN881CuJ9e}hX);gXziie0o%-MM>$4yt9qgNL8;+mnNPkOy`+Px1`H@hy3w8d3}El{ z9`nN10v126-Ef7-zrLeR(NV9}(^rKB?;&~vn)OUb61;-|gs!{HzSMF)2m`+v;y}&@ z!L|**Ax*C2f|EFtX}i4E&alYa$MzTozA(>t3{Y7gF_lLs3LUI$v_eQrVrs0HlSnQO8ew!5sV!l z{{r;Uw#?$!u56=aq3mert17==;y&)+%1}QDI`NjO&SYPoqj7FnL}DwPD#j?{#$Ss` zWr22N;u+vH{a<~nqjOt21mIS@&mfMWK?A~T6=}jQfA&e_KdDK8M(G2-^_djf@ZACy z#_PavKi4cfr{JOi-SPQGWrHA{z;Ul+XnF<+T9~M7CMju~YvvrZpT6oScLSVJ>tED! zaF>*sd>7b^*#F2?8w58s0R})^9uC}U)Y+wG(lbFD_^Jd^*jz<)#Kz-V_H>Vk?4nl7 z#BEr~j_BuStBaiz{zJvKKNJbOVd|tSsw5dWBy1V-y665uZ|X!`ETTgvl`z?$V_E8(c`F z-?qfuUJtv4me3CnhiGBl+h+a=dq6p6Fh zAb9k@)zEGrB}FXa!zkQb1>t;s6Dy?ft)A57@7kXtu3Lc_RQ$;@n4L|sW*u7y{lt;} zX1@k0DBGxOPX?jch2v$)55|4gDW-i=vV22cu?jdv9Z??gMNhT3tKBo*g_?O9yBG0( zQ5-_1!d@*>{TJWTk`g3?!hycFPcz8>M(cPE;BBiKs~}WEiB?>80?~q{F(%F>g%q)v zkq4TN0&Ls+1O#@56Ess>ts)^-uBRwP9^sQ-?;N4?RJ@HTTl_Go`Y&Ps4d?&ERsaC- z)AShtMeT~|w19>B4ETh+Y|-SqcWu=&;Jt&($AOT8z%3Hekac9krDkx0Uvb02PFS<< zRCk#P714Q>@z&x3x4N#R!Ywk68@&MRI8axXGCb1tLmS(cLdRB@QE*^*wY;Y#xj}}3 zeSzxd{J0w?W2J7Vep5PZ%*}0O!;ZA9No>o`aEKAoZTd9VT)I|@z80qiS4{uQT-Ua7 z?_ZQSUNiNEL6gXC1=^%A6@kuSNEY$V>jg59$iWMXiWa0I0vO+WjA^&-9xi}>AfQ%^*_BKG9~PDSN*Z5^zgT{WTm~t~{iS%Dx@_Zh?-fY4Ofkqm?%0s2?qJLK5&M;I3+Y5B_e%;N%_y!X zt??PE)Z&#KTG7Q3KS! z@UWx66@IlO6spz(FKH~6h`2xQy{w1&lOA#uxWsRaG`TQg!K;F<`R~zVpk%@~eiKK! z3Q{0F2ENB_zzLw|diG}^uzWz7b>a}QF_qZ$niSOI3Tv?IHmvK(Dp8$QEU0C-PTLZQ z71#sRF-Bax+-$x^M8VjAJ;3g(1uZUfdC5lW?nti&3D$az~C+K z>$4TZs8tb)zw55NFe}lk7YcOC8CW$@Y|754&rEhpc?n%Ij?9y~dPNVX^P&Ajzg zD+^n8`!y@yBFRhlC}hLvkgqz&R7Ea5Z+P z3ky2=1p&2;kL@~a!-x7n_5Hc~hR-+X<`XEVn{De-%*%0L0cBo*>68%a+CYQ9;nQu4 zm*wZJP(6_Zc4i}r^2 z@}iuh3eyY&^C)0YM*R>bD#!u^yURw|%|Ng>Vw7F~+XtjT$KTVQbNJ4Te)-J*d;-qI zNA0449^D1IJEDy@uW_qatGyeQfiotUG9^#hJmP^?3Nj2iXOS*EkRa!V!W+ zKrgyzPpatDp2=0!T4G5yJ6^nj)M)JH!IB%L_zP#+R@%lt)cXD0KosQ0z|87RecYwLrrB)PsmKV5B_suXIWNR^+w?*2dg#w#7lU z6~dj$G($LKs3I;0{x(VH{#cctINq7zG3DWnqmwGaUTxpbNyJp8FNNsi)t10eLa`Y= zVq}f`^LaTnacb1+g8e}9%v$rWu+l3Qa%cCa_CZ*VDTJz$sNu9o03kB>6(koJ&0R1z2&b<2b4@Y!e($LC`4*9}N1)>>BV`6%-C5Iq}A;G2@N=frC6$dU1K?Qy#SNFv?6mOFcCpnzkUnb#eJtC z@rEK*l<*n5OV-=&rsvRUkr(D&TdE9o{i~3dnm0=+!%u3F>M8CAH4Ru@x+y)p!Z`&d zL}TR%YIRyM`@WECGB?%E5{+O^+8ZVOKwmN&@~9ZygO@otasZ6k1VZLYl`F*u#yJGu zox)YSL<~>khi`olHUI_4JAt^y31+4F1Eq|xuR8JBG8H<&lOokRP4CQNShcgL+GE^B z+IPWAnVbRI=>VN5hXUwoFAV82(p|c&&}7o#{%bn=$Udjt-^PJDy46s|A{nT^g;)}E z@Fj6O6j}SZCrCtc5TsUcJ5Lh0aW1Ci!5H&M7LNrO@SA6#7w+yZ?P-Y;2@r{2ECRge z?lxppQjQ5tDlUucY>wG5fTD2m_3%9U@U~!jWdkI^{Ca+H00*!(9eI_8dkgldv4{!J zL%kpHVyE=;@pzU))3mP}=BSrE8+Cb+I+m15>!q*jA?3{dc%JV5SA-ylZfUeaAMN4@VyV#_S{w59YSHcldk-p6QHRXuB62eKV6!Vjrfscxd3&WDUb zb+!-VKh%_&=y_f5x8ry^*Xq`}!B#;VYe9WIX9OHg;}(>ugs0|5(T2 zc>+-_P3v<~A>i{R{Ez{_t`VB=ZZv0g7R{xpI%4$SJ+2U-#u}2hn28aZ@}rC&N;KBn z?i}v$PJ6!0#0%W#{?}dpU2EwFU!kbhL|jJBD~9W(|9bK`6MeRtlkq|Xe3!!d^Tuc` zJ{fSAX6KB-eE9^PgOsO|{KA#qD!;dmDeeI5c4*>;Bf?L~5o$xuJe#S)F8|R~IMi%k zv~)(@YDWV8m?=8E-D~~s9908og)X@t$>9<@W1*Gzt^FSWT0o`01Tc{DnXVjk4+gLq zOxgML*xGtwu~=;h+VAk@iPdt6W$LDElp1LY2g4JBqGo-XCu#t?KQ|6^a)BBsF`MUx z3xMkH`va{}>=K&V^IniuDzA=XY@=cvNx(s4?e*$Rq4q$*xtI(^w7k$Qzrf!OHKKN9 zpRf(&jFuOZUBrhm$v;c}p?Pq6773Bk;SCB$te7G$qJOY~{CN^yO~G6rl(i-YIK#Bl zhZ;Qp-Po(ud1EOYsB3*=#YWNV_^!BAFU(8AA>J^Y!v#&?5zum}AHK_E*bmJB`UMGo zSo4yH%m-IzHwjF(6=jgB1~#)bh!GqPid6iYS#-OG37ThLEhNi)5CUV1Di~^%`Oqs9 z5ATd{13QKs9b?;cPle*|M<}FOzg?GF!Ki~8(tK;^TvN+yz-(*mzQ7h%$PpsI7a3cn z>M_wM`;RMK8{JMh*eW_8f>kAU9}+q&Q00TmrW~zi({?B&Q?@y{DfpbjD4fcX^LtSI zr*=R>WN-yEMQui^m=8*yOI=BMmG6;}ExWEXP9udKV9b; zX2y{d@G`1Q8pQoe-d*H~CY3(5O(I|JtpOxZCd;__6-xMx&;l@nHauUiLgAQT4Qz8g zgYm71#vG!TME7&t@3YDER19$0WdgpTZ??>Kk>^pJPqh1a?S)0Pg0k-qO%+U6-{ zzZ_mC&gTI)!m>)=SV$7&%=HV9 z-+UPSQk-GZ8pVX!p67q{{#-7xjppXhs)*RXi5qc?qBS8k`NM*?iLBqGe})I*10SnG z?!_a2W`FL9QOSRzG-*iZ`^O6)y+?Urtfi8fhl?MK7qX;v;Fj4@m2rDbFs9vh_Vyp< z<4_w6Dq{P4w~H2%N#gyM$RpSSk))L0Nm8H9;2G%>Z*OhA%^b^UU#5zpq|J6~q+tFz zLDgSqZJ$qXa|-N%L*_sAWea}D->uxMrpcjd^QLs5)eL^20H(~^H#{%Bh~;X9I@txi zRS z6i9>WM8{m{^Blf3>W3GzR*SAtD;U?=wLMez~Gtz%x?E(#0@N zD`BBHAf@U_U!A)}9$g=-Kf&Y)^(rM{+p5ZNNiuAdZhEv}rc=WRBNCVmKYgr3mG%aG zeK4Vy>-vrsf>_l|f}mOQ3JLfEiGKlMfSzN_)Cf2G%E@^=IY-@XT|e58;k8_>8F2y! zRt9v-3ZUsIUG0)Dw((4GN_6sE@5KUUgJX6v?vE;NJ8iaxzSx9WO4w$~v{sbn{!-9w zBhUli#LkHP^<#hrWBDi8yK?yQ;_P>N7dnhhf;~r6Tws&Pa8V5GNI|N@8YEiHI`IFe zH4QRVgT>o$iO%Zzpz#XTpnLdhub4-0gy8=l2;xn2Jz$wVfq0@IbXm{SKUycsJ)t#dT=_8G8-)EcA7~j~8f9joz%SGZ)N6fW_uwxj5d$+0H-J zzl2hZ_C52P+=$r5Rd1a5Cz5~u73{qn=PK?Zs4gcl+)*C#7&HGtL1AC>*z9hzAxNU-8~ukef9J^cd7LYQ?s@wt zR8ZXGXMDCyH~;_u000000000000001WvWIi1^976CVWMZpEJVih=e%R>*7e?1;7Kn z`U69_#3}qgtX?DOrQC4w4Gi}?$hbmpIa(}wU0{kmg*Udhp(jQ`sSKNwVMm%c2g0>K zQk|e-fpt|{5_wm4w12qwUB1LSu{HfC(&H417Rlkd8o35^&lOZ%Xvx%R3E~1rE#bU= zbz1;68SCy^a{1bx=vmGvHI%&tj5JVXR)vy3dSfpD&0OgcIy-GuC|lAx43f9Bktv$W4#tP&+KXazq0gsM3ogatcTUC{b|0;lp*!8cZg z2ErXHc3}?k--T~uCNb|?ydPgaoAd@5QdrYIjOpVH*(m*2x=1JY$uJ$nb61UrBm za_mSm9qwqrU;-hbo>@?+`)|Mi8Rzn0Ub7XY3=r@`$1R%|0DlciQLp-!k8=;T0Jh(qu1ZR1andHzh6rCr4R8-HobK8V!&d z0{Cp94v>VZz2 zK#6OUQkm;2-gAChhmk`qqHx*s&zH%_PdBS*_R4u+7DcEA*CiNve22}S+fmn6{wOP* zxXbVIpg$Uafw8H|XW}^A)CSL9$_L%Ad2Y1u3?o)rYyXczSu%JlS+8?FEtkp+(yC*YbY3@8D#R*NY66-f zd^01v^qIb+UFKSI89Ija?{D$ia0{Aj(%On~N4Oie8ZX$9oSr|Q%TB7_$3!r=Gv1V*fsa0`>SMJV7@W&0W1T#-2g> z7J>_W^ZWx=*;G_8_M7Ezt~5wO3D>G6yO_8XcQ4K5jHljL#YvV6Isv7Bw-cx z@Bq9!yxK**#mY^6{IHW0O@@V6yluhoQP>c|uBsGamCMk2hlq4u&A?5X|4inUkUVss zaeD-Ta`=hZW)gYg*VH+EKOpROQs5* z!Jpg}WWuemeDtk`tt-Vw_I(E5v~L-5b!7bhO(;N^Sc4_KKw%nUtW9^a!tPm!jO}u= zfvXNF-~gl8$G%>f5e^eC&y28+`k+&TBb_SuP<$QX7TAQ<^( zzLfBbHx)fx^P#UOosFEft_!E3C1(C=ULwAOL<4%Eqgob1F z1g>>YPK;Gq@HFT2kh1s*|mC&tT%M&#ZRR7CHysX(QVTGj|v5F3zDF<>mF?gun3-9ZuYI${hZrof!L>zZ7?GW7(VoEzf!s&O z*n{ZIqfSUMVLL%G`PPEFl zoC9WKYe#n98cf<15|Za?k@z*CRt3@ly$zfK}K^F=HzxciO9@<(AvYa_mFr4D#{>-vMU#qu05~v z7124#7jEsMJQTHX3~W9_vbTr1A*gCJxsslLvjLz(fp%W7(}SiWAwvAK9&H96tCzQ7 z54Z+T$Abwiq+8q$t-i55)hh`@$tc@5;P?_QYc{d+P;`1}twHBt)guMI=q$paa=beK zN!UAi+y_cN!I6@%2VPgLmkmCW+ro0cUyZeuTj^UWwPN@dpw0K-vJFenoHU9CS8@{_ zhMk6@-+3ael7O^V{78Zn%8kL1`o240Aa5uG=YbCmV1)Pcf}a5jh7OO#QPxmq z{8e?Kemea9W^)3|^A@i@bX(~92)(?bHqVZ9UTKXaZ_&O-*fYn6VzRxWIla?XflHQT z2N1RGLZxCNj&;b*9h*6Dmh3&(SR0W#f<{D?s#SgP+X#4?8~CQg5`@hS>Q( zs8#R!sxmx^MCd(5<(ABm$vW?O3v=k{A4`$zQg8BpTn6Rpn4=}rC`CUW@(>u1x?~BQ zt|}tRk{yAcc`$f5&OZJ1w8Vug=N)IxsJSse>mu2a17#~rCMrR?BxpE%0000000000 z9#K#;kGeH^3uM)t!g1ME>f^u3_g2U{kII>p5m!{=HyuPBB7Ky#CptbV(Btkkk#vTc zM;AI10+HT-oh-bo%ujLWmgxUZf=v3^%*f5bOeuB#FyEqwU{N6e(+~Z=c>F^In)gR@ zMlyZg+!5}09QKQr0m09}>$U%= zE$4yU27`)>vb}+lyNpgrE~lvD=J=zIGqGpOBlhu=mdNmb07bBLR6af(e>EgrTe(Y= z@~r89mB-r`S)asR^Yz+kumQo2l5r(*(|=Wqqxm#h4S8)Iz@$RoP63Ty1UiMT0y~mZ z0eit)Y_r!nzA8hn`-yt6up<;+%T0?@;F!C?gOcQH*J?552S`QM3DiW%lX+xqLX6UB zG!Cut*R0zoiezg zBz8njM&t&J6$BuG%~8pqnmKnW$q$uixdm>{T8s`FepLgs7?TH>8}@JW6{x`Br{z#P zL5VPVfxl+|F1~Im>c$Q^A)IX@Scw+cXJS)uL=WhL4`>lZKMVeB>bk= z1ohqzyye#t(7YE8rp=%iM<6oPZg7VuG_gyo3y$I0I(LzAb@#)`GX^`v44lQMdu$;V zyYc+2084s=EtY+t{nF&OE9514kVG>RvN+vyKN$0a5RN1Z^$(2l5&Za7{1Ho7FU|3y zHkV{m12@g3nrCm5`#4l0&!5!($AhEGD;=6n-rI0tlXO#R%B%K8kg^JxK4 z&gF8Rr?IvO+&uKi2BasW;vHT)onV0iVY1{lE2<|e%%-lCcQ$Xy2CDZn%ZI$FcOOgd z(EKIoi-;`=lS)slq7qbX;hT4f?!_t&)ErbUmYtMEj};^CkwupCAHxN}y5Z|$`&{fF z3Vg#CL?o!&!#rHlZD(rV9d|x}XjLnb)u^6k#3ZQmytby{MGGJ}s9h;tR;RWL=d#=D z${YZ(I#CHKJnt$L;3W|NQ%WHniAWp$#RV>%&Pj8iyuTd-x z98YV3Wo)9-#uXf#;!DpN$cg-UcNQMRa;av&LVJ9ym3oO_aN>Jh3oB(7mN2N~=Mr9c z&O}e+%eb)iBb7@v`V-sbWUJIm1BVmZ;8|NJw6TRpCpePx#&RNm9$m$Uu^g&duh5>~ zD*8gX`10;88b9d%S3sujzgt1Km?*thcFIZwBTTG5+dyg#jM&ztx`a|72^GM8dxMGN?EHJmQR)U% zVaI}_z6QKpjtMN3OFQejd$k6oyKb<1@ts9HozkUH{xa(kVzekI;JEUfF$xqmnJ-v zM4QQ!!81V$$viP{gTQ?Hl6L6tm6Pu>C$h+J-G%2v2uIP7MSIZA~@DXL(3$bU6J zl@PNUY0Y6tBVQ1zh(Q>a+U6hm6!luXYW<$3~*W$<$eHjAY-a zw0!XqZq7MedM@)o5JtGC{ZVQ@(I{u&`kdI{Fx%XI6roNTWu!8CU3oWEiQ4KNgbBDqI;I7Cs- zE0Kh;7WzPc1$HdI3u!(hy>nv|?XqB+Le5Ec0DN?hf}Y4gc?A0^q*9!rAMP0IYZd4a5wCruU(gN%3fQ ztuzpZ0qS_=ccluYKib!5UAzgyBK37g>3d1b)ln8ECJdyKiQrvQJn;*@VIsMl-U{{) zw1wB4Mvm90vQ3&rt6!8jvaaSFYFY-rvKkD26jJ{S_o+45U5VNMtGrDb;nAw3A))fD zrUQkD@tA*$6f)As+QC+BSjLkMuo9wGQP_!bA(!P$NSB`E$aqCmQ-Il<6T64h<&S~? zF|sd!3t;*MT5-3KuK-XiUI<9-OYt!`vRp4!-i^oj5gbHU`vT-mWwcYAgH5XX3uE$S zIMcj$$lW%foUXL3yF1~B6!>1ugRhZxyJ8c^+ug0vFpY{3AN|$Y@FloUAkaugbijOH z!qzg)#+U7H_r_#?|Ej3&-kBAcx&wuP@;B}v@)g8bE&{zQ<|XNMW`khN&Oek;7EhMl z%C>BWr1lCpjdU_u35b>t!f7NFk__B4H^*`%CGq_ICw&c-(^_jehsh4Q38~zdYw@mC zWd(IlhnHP6y=(WcUpPR!#c2zC;dwrCt(#Gs#V&KH=UvK%aae2N*$=AQsuDn-7TL+a zDCM7dR+x_693n2qCS*U{GW-hu+1F!xeM6RrGettv)*Mn6A`kqd8Z>h=nc~>{l*Lba5y2rokgk0^ zyQK_Vxgx%N`55xlG&LC{o$lz{ps?(RaU$h$qFC)`MWl?ge-)t@h5!kV?0updx&&+7 zCr1Fae3Ytg&o#CqZIx|g98Fx`$zOdhhr!HGG&%1~XW{FdGG_-t zcbB-kD_dZi12jPfKF)}G8MzA+j~(!MFl;KD2r5?=Om)C3IZt;mB5IN&G`>3eZgOIH zgNw0jnuRJ#T;rAD{Ns~+n<2+cA~`!;fhFU1h#nTOJTRVd)YSXeD5d|esAE50KTsPb!K9tFgfo5bl$O1>TnvItCH~~NNcxu3BX9b1p#DSjoxT=Q>^z4sigT`2ninF&KLe=dKjIZPerxq(#Ge)~D zUrj;CvMPu{M_0u&8Cok~$^^q@;a-l8$9IC^9kMTIh}mWUb{6%5q8t92EBH!mxk!&> zMJ_*xMXK__RZ7}zEwuOo1Y4Ljmjp!+rGq?M?j)h0K6iZmT%-_EqRX|1#a)#91S|Z0 znM?u;Gb;p0IS4AiTgi7+d-@Kg9^6r1r+XCZC1f{V|9gf(?WSH?cnrep$N2D8-{WnL z81%9VIfO)2w-6lfTjMZ8ERpN>Mg^FQTG#KGJ_s#KQyHju;9$kQFT8QkK_opkBnw9m zH2Z~lw-!))BF`?4l=)FoTTjh?l0IOZ52#-5M!6fuKNIMdNI9ulI~sPS&Ai)2xGFQp z6+m%vM%i8*QV_lGalkk6_DCslZf5DgWN$JRspn92Aq*S-R^1F@siUJ0!@957s@VGQ zRqtY47nSvX3e?F8;C~;Ox|JTC8bTZW{iyHBC-{y|>yHppZup8A;f63(!UTxF>Dn(q zE}##d+Ao$dJ4p6Es&SDO9qGo)XNX!Dh_Y3~Qwip)qYEPbK?sKpB3*t0X0^_-z3psP zR)2y#*m|9$!FfPOAo6g2lwfAPeS9look2m$k_SIh%x}U^L9SX>ts=F%>flDYC7)bH z?&M8{eI1_QRVD~aV*Px(5K_Ic)!-BG(+L~mZEDHTkun2LZf)ZJ_^M;XlKtx)^fNyOQrfbJFCO_vzaH&n{&OMDJu^BDoK+i zC{F(rKP{dU5NW20q~!cz{iA*{-MqkF0qm2coB~n4WT#ZI9*afDA$pv4Q|&>5LaFyp z6BDii50JxSr}b?tblPv4^|WpOAsC9+hXaCk5*eS=H`kz;<+m%2nhneJJe_uR@rsAZ z2I9)jsXi~7sec`83*OWQzuGxR{!Q9OZ(W6NO}Kq%=Y&70h?o!O-D{{wid_9|yyCG$1#f@%t1 za|35r1$h|;Pi+t6+uzc_=;k)kIwXIcL_Ykgm*LtntQG&1YE(cq@LG|w&YT!r5z`rL z1NyJvTQ0i%ZLJKT_%3oi6CH*0yz?up}R}ax!C5^;KjghNn6r z+Ph*?gd^hAp$FBkTg4OH(mEsd&}~_ui7{Y|GNZL7b!Cp!;Y&w;rCSPxOCvRh%m?~E z4~XKR5V@S6%jPph6R9QxNzHE0$pbP z5V?H`TI?JzMXhn#(hVE4}a$FGi6=%@=-H0I_~+6=u;}e=v5`Srl*r80z~HSB*H? z>0a(c=V-rKeq5`2J=;f^&(%ZR5d`>*j=mRA|AD-6;6zB z(3GUbvIz1{!AM*4IJ{KxIhl`$!HERaB?2A?-Y&aD%jH`iH-=iEfeCKKwz$iBDsgKg zO@DJweNhN;rhZsp+ZD_84i@VR32L*{0RwFRJ1_RYbCG_3Lq>pyP0J#UW9D^xrLvrM z5+=f^sY`a6Qr;_bGINN;U1MlkUYD9<(Q2#Sy@{)nS`VNJlKSbd#~c03bAaYZ@qbJr z+7XLXyxg#*JZ8lh=S~GIoHnTJSqYkdHnKZCE_s8-LkH(R8#x!FV7!s-ypFva)~}5~ z<{?N*xLfgD1+2Y`ax&+5V0+2p0-;B%y$Igt*I2-Uh}{?Z#@oZDw~@h6^35otT@qx{ zRVkFY{$saO8sfK;U(9tQPvmxiYmi?uT+q$HJ=AB|bq&SY5-mbts@`cFlXdZ?y1gW! zTw4Kio|Vx*RG{k(IOrLpFDnAQa8u1Ked1#Cru3|^XX52Vn8or;`4IIB1BHf?(yF^ilb~GfHb;Y`=3nOZ zsITc1x$n&hLJ*)r0Q^3|17cLQ+%iri*e;@AOU3Kff;t*}oJ0*%y28)DB1USTSDv*Znt5Q5TmdB4Bl2JCwo9 z2^c@k3#gQ+;A&9>`@wWzhNByl zsByK(n7As?iKdgNc8-MbC$RFQGAu{>}x<@4PJ% zzLiAUMa=#dllb$K7zp{*}$9i##!H`{>6D)1Ut?Ijb z!nRNGARK<4SE-~71#bCL0tZes`tDyc?PgTiWQw0|38)UJgL|oqd}>h4qnE-V{W{4! zK*acG!c+vY_SlZeEZ=D#JF@o(vP7ENb+hjl#6cf1uQcScfGC(0i8S+xLKl<#m?igI zLeqXmn)7J&|A8yEwURh%hftj6{1J|bOOX?n2nizw^`M0Vw6bbW9Hy|zr@CxZMfcFC z!Ud1A8uv512JRc9n5^Q4jtpN%Xh@qIh@Na1gNU4#(DkdCC3S} zZ6-v(j6mv}Ayzv$;rN`%g^RgB#!|6 z$jEZ$QA>N>UYCKNxBMwz_XX^eZmUDW26KIGq2F&)RLzjkiJz`i$sk>gzw)b$6d7%1 z5wxic1bL1}+Rv~%BCOVo2Xz)-PLrTLv<{JTU&{zy=YaoTar_uvWL6|vZ3+<~CQ`qq zs6K%8C&HKsVsWfWUd);p^qI2)cr27W#MyX8bCmDH2Un;>2FQ+yn5!z>=((VqFV<8| z0%8s>V-jVC&8R^8-5B$t%d6CU@8cNog;_CaegmWuCaA^`-j2fLIGToMSGOqXo8L7= zwA-Qu+6STCKVMr}c(N%=&pi#~*}o+$@=dazuW+%4qMv3| z1vNS&&B|%NC1Bs+Iz3-)R)6*1RJ+heBI)nocg~yW-j&6!6lMKqpEOT&?jRz;@r!M# zCKCB;NC0kc& zKCGUQe}AVs;_p0LlS(ojm{`nT8prntn6WbR?A+Ed(joWU-^GsCFOdR`=@+~ocV+SI zi5k-_l50y^g)76FE)H%NoZ5lX6tLNK(ljnLUPXh<0>pqv?HvIwyrPKkOYw)PY(Zj^9{hV)eqvAe!Fx7k0 zo$Bcf0V>7`xw`??jr9xchf zQBd$0bA159<@s>=;q5J)-$DPXZ_1kp`N<`XY54UK1VFCF${UanW(pOV;Rbo|C7{-n z-5lRh>mjAKG@zpg$+S17B^h14?rTA*71~{^WhG!Ai}8-u{f;Fy zya8r=hV+rep$(EF&oN)ux0vNnNqEYKfUu!-Hqb4r3LSN8w(L2lN`pot4V}Y>#__wx z)69_Pok7zg62w{~-5&bS5z+oT(a7zNHU-qnohmddiO%z)Uw%vgu%#0?a|Y~aK53v{ zg}lEa36QH(lMO&{pLZph9Hz~dUu_x3{R&#{&$EKl_P;0#~e4mM8} zlqlJN+jpyyXK;P4%0gLzqCpYNMp%m{>AH`1hrt!tXJ>c1NjovK!U(q|&3+}np3`L) zjsd7=Dbxmx>Vxsj{W5S^No_+;MgU2RR;PvM=B9_!%ppQZ9xfJ0YHLl@vfqYeJpY(L z@det`{w%N+inl04NzzN3K-jF`p=)F(D7@YQ0Z;FWXqbZdyFkb{7beZd#}Hou@yI@f z+daqz2FLM6w{ij*r}@bu!HJ%iiR|U#zpG=<_E3^r-8>1_{wDyW6O2}_G{57Jv=9x- zbg)T;imIMwz8Oj^#uL343wukOy%o6Sn}vEWdMjE3*c$8+t|i{!dC<~oPAG?qil6ZI z$WzoGH!Go=>)sgbrUVqL{rF$>=V>biJ-}~LCJ0`Jg0+nakFzlMDB8LTJ6@?yu8=pk+y(wm7v|l&F!o_@zd7A0?EYLL_4a9yT|_qYk5 zb)N)YB3^V7CdIyo)pN5X|VRJU}4Z&?+p+Ne~>?tJnQrlZg0)C9-LO}FRq2zOzc zxP278-(uu`a~Vwavt@rtRf~+>CrlPoU$tM6Lq~XEHwiuxWDFXu+27qodq|n=F9p<{ zg58flFvT}_F{Voi-g9r)@^%rwW1gz57FGio$DQi4cgg4{-Tr2X{7SVe$(?hX@sxj#rGeQV40`G>v%~-5Z=v-}s|~bs%@OyS4VDi5@!U<6~rykoO?_T;&!eIzd1~)2jd0fWc|_^ak*J zd={1HyA=T1O|kkt4@j-upbvH!y?XP8s-iTzB8n;)%ZNEKafrh(9 z1F)3nRG$}yNx=J%WdoVl?ZP&e5XMH#EfRd?IRwXHp*#h{f(DD;|M$o(>NN0NL}Dhl zUHu5o86RMxJG3M+Y80Y#?2l{TL2elVc(#Q~WAGp>zKUAuA9wb)x3vY>ocX*6i6B38 z8t2TiEx@o{NQuU!Ka-y1fIDhK8lt4n+RBe}H6ioB5Xjt29vnoW(TsiUU z8qWNK#VNW(!3gf>e`Lh{f>BYCCfk-BANRT?Tm!mXKVdQ3{(&6zPv)zR8p-Q!Ln33YI{Bn$%j zH6Eo-6>Q5>(qdNTY{`mVu8z{)?(od^sa0P7l+3Hc>C#hxM)?=NNF>r$XUvHU9)34* zaIz6eXF^Xu3EG#=|* zyEL*-VLv5d?vlFJW3G?+0Bg^k=?T9ly$#btqX$R zy1=0+WH##Nii6Em0!Q+ZYaQcz%2hA#4q4Cyv^>k1m)qy|%TQCaiLeiWu-t>{jM>ru zJdwBmw6~Y-*+#JvZ-C)264lRoh)WeF*xh_U(8#rSHylmP@)PTJj$|q=m(bPlGe4 zaj`~ADvK2z<(W&x;4K*G)u@W|4qU`zr)%k=HHn`@W2aY~*Mo7ZMmlwBBD}+wF&OFE z`e=<}XVDnx)#o+f+-lK|omz;mFy+ifI(ELABUqXAMmlwQ&3HE&v}318*uK)zp>J=w z))sVdh70T%WEG83Q0y}kyAvuVx)L&9ki3D`_g4XR*59(XiGXaK@bmFlk$+Ea5IhbH zw{GMChcF$;0BYu7Y{vsUab6*xfVy^%7f#Q+b2WrSL+s6grd1jU=iQIGgD3>KfBr&p zW<PgS{yDujo%DTIMyab%CZ5em%NUXWxXt zX7%ltuX5x5%D3rMKKrYO-6IGV*Lii8m5Fa!`0K)N;hT(s;|0Mqk?c-6!R11fOD0Rk zgTqiB?=sR8+)-Vg_(p*9xI7SA#TL$+PQL{ag>OA+%8}6V{UmeWHk_h9sW4rQvf^7? zgkB%Y$`iqQCaW>Z`A^CaZPlrEZ* zRuRSmj+Wwu^Ws|#le|`i=4t>!%FT)*p0TO#CGSDN{+FbXs^JRlsG(FhI2MTXjhFaw z$+R#EtBaqsuSeBFX}{?Ea2|Znu}4F7k{XZ*f;3%PnmxPQfC+`IO8n^MHKj~puMz2$ zWl1-PAsJ-~x_lTe)@5aPoA@=_RPiHMN}^=Q!$@#ixIXSk#PSBr(!)Ne4KH`cNn%PG zD-<5;@vG6efPpu#gJRXxhrQPKuU5SK@?z%7o{Pxu zIGw^wskL5x_x_QcSF2uq`7v{4PetT+oKE2;RNAjU6x$mlQ=%RTSGbTi2;2lYxWESl zP+b6iXmeJLRVs=DDb~50BDxjXlVs7UKxGFD%ia|xfCZfv{eFy`C zok30@Kayj|cs?L<5-~eT;5nT6VZ(ldp~7UAm1R`DmGj?oVTe7xiL~I4d}j3p#<>F{ zGTjmF)nR$Rw{36c>l;$DCHB_`U7&{j8p+DG3_mBOJ?m(KEE1FRKb}<&c8-{s|Q}C8{0ekW@{E8FCIX2)V=e4 zgvwZZ0*j;ISd(pdRtpd~8E*PCdeA(N>GRPp!stG}cyLzpUt*_t;{bkTff(^?oxwgb zkEqFZ9KA`>*QaCE5ZSzUpxzWZayfoFKi@*2c1ha%k2-b7X!inv%X;4^Aq(dfouBSU zZT{;>il(|ZGEY~-KDo`f>D%9Msn2Q@G9D^+bQ2p*yPySG>>G#wg=642=0{0m(qrHj zlajjI%L>kezMvpnAhQ?)aE3FRcGAiVzhf{4+?w~B%w&qO63{ON# zIOZutul6?Nx0?KbaKMRMTkb^|92fu}kICbx`8|V2X6fE6z*8<~Sc12KLI?W&-)&WY z!sgHebBwQ4${C)hA%YFH-zSn~R+e~9PTbSklYR;9T~1vF1{P`uI)*y>++n%xbsa5bu~c3;5JY5cF0E*fw4hORsbYFU{%4s@~2KJb@4h% z>QuL20G;8f5GPq`1tvH7JE=?e>o{>7rg@XhF}+UlgZ6?k|StCBF~mAOtKawq;~&3LGy2felD~uA zWH>@G|G+RpY~kWrS-IwciNSmG`5a;kZ*P|%5>m{9<`+$iPgOM!CkJ7vxUu*J4<0;v z=rS`Qg6u!SA%iVK!mNfJ%a@kC9s?r9cL5rRV0B6Vjrbu_3IRH5`h%>%~%PbW- z)!AEwit5W{J9ka@t$coN0NI^ab`@kV3rUhO2;BQ`YjRW}!LK#IYu1ZIHt?4+=VXpE zyjS)@>=DxUs^H~imEh3$i{r~sqfY08O)P+r*?b1xbQUj-2Av2RS``o}LA^+Ij6PCZ zL~o&St*mAoP;M#9mnBj?YEQcL@W*c9zFDb24h{MNBy+LgTfHCWCB~u>;808fHU4F( z3Q_bHY!;fZ2Q%*+=xX>F@WeHibuybCDRU_{0V0aQW{Eyz*&ZDM0he`Vm&dINp%6n+ zy3}l{@+VxB0Khdow*s#?&a`pKzzwg?-hF8{>j_Kzw)>w%1VP5yqZko5p}h*IAxn|# zE}g!djGFT!sv{4Eu#D#pxaBgTx@B3{CB@=_p64cf@Q+I_UU!{Hgh~e-qhD9g3(46w z_od2YvZ%+Tjvf@)YW+mq;ab86R}CTRhhb0gCmVcWFB2v6+9=`6)@JAXLeQ1|aoX6B z`jQ_MZy0AsBD^%41Vddn#x`X=FVb2}zvf{=V!%DoOPOLy1k}tvp-EieIpbeR0@*Yo z^Gtx-do-q(omklG{_rHgFCD@ae9oTfi_2hk+bZ4ARPn|>Zya`7kM4mr77(X!!AHl? zd+U-iRHSyUvF*(^LIFZp@1%0`cocL~$J{mrRjR5Hddo2PfjivSbM9ctdU(*XI{2(%#Qcvvb# zDa^YCD8>h;D)U7(Pob*mU0sQXlZWCqY)Q7{I*O1R7Mvgh&P{7d%$Bdz+B-JhNcz7( zvLGvHw>{n{p*?H3Hhp%-NV2ndEN=E`6GBBTnavRbB3E%gO z>y1eWE7jY_x5o4}ov`T9@kz|pj+;2mk#|2}8WmJk;P#%#fl+-7X77=zLd*dbxwDWh zU?t^eq@spu!}N9kx?S8Htkd?Hu%CA242}^+m9Oo6^;ccJmp86Oi@Q5qyl`>Xi@UqK zySqCScXy|_7I$}PaVRduhG*7%-@v_sNzdpYy{>&PsMv){$Fc@CC2P?S`-> z%oe|YXggy3ZDf>~V&El;YE>B6$3?HWS@Y20;m`#dLx^1Mkc}J71X{RPqXx@oRh*ow zbJm!xy8C7+S8^m;`aat71%Y{uzBEyX2$X9c%AeUDJzZ1>`6V#GC8mr4|V7QEgvD+-7;3Ej>kZ} zcITBTUGbD7IZ^Ac4T6`pkY0L@3k~iOy%r!w zN{sJs_WzLzKQv4iV;mJaolk=|QCqfAj$Ijkj2r(%&yoV--l6 zuvu1r@qq$#C1cg4fFZBlT!sc9P}F)T9a8>s_@yaO@02l0i+qdiNx1~r|JJ%A)6|Xb zM^e_=Thqz$h$NKR@$ESkOM`RZYRsA%&-`j4hbKDk20aJQv%uG9os{ZrXZMa!C{x{C zz=HOw$qN5BhN~dhGBRM^JIiR)Y6NUC@xj_16<`m}nDNA@Z%H(Kd{JgbTQl+Fq{R+b z>R2C}B~^(ierfVstyBnLj@hroq zKia^0$ww5|d`*{st$b zyW}U1D3vaytAI&WsTXB$P*&s9eo(ZA0M_~$&csIB7Bo3^N>W`kqhpFwj-h>QH$}?| zOm+s3PX6!F$gByGv7FXmozlth(7I|8;$R4kV9N3-vLHeLMYwvY^_CUlz-U{kypz&f zi#wtxbLSfU>AdiJ9^T|RZyKO?!wFeElH21@vn}l6qS&$OQ<1y^edr!23;O>ej}o<-?&3hp*m zdLBh{mXYoiTDK~P8DzML1EC$mO{J;mQk@aj3+eSzFM);}6nO7fEL3S9_YmV>>F6O3 zN68G!k~V^lUEZ>oZS)lH;0Q+2u914n#Zj?5nQhE`i>j zA$a>kb3=XwiG@5x_+NIBH~P0|c{cyLZq=MBO4^f9w3-X$xA2kb61vvOcVw#g04lMV z5Mc!F=wdbn&DFt0F(~t_GCN@m>e@{0Dt-(MZ8J=E z_`iQiAQ)=;6~Y~3i%}?w8sAih_=|}Bj@>8E-nPA=2qn~jMG7V$%f2=qa`&HP|Fy#RE{8_in+XZ}D<;h< z24egpO?J+xd;1Q}bbQL47|L4E4nw(a8kEw4Q_e`PO1VdaA(q>NwGRa~@y-QS!1#jz zddPDdkg%zwZsS)18McQSy{+9e+J)P#Wn8UJULSzz8G{Ah*ecXu4I&I= z(JgNnDa4T`&VNnGn34~OoCRBoypiak)dssMXP(D&j%gHpc3?bIv$?t`e-MsHR(0tA zvL6gi>ZH6$EU$+H!9q8uN_Z;cMESSQ&Lq?~pAzPMDB~@5?pB@Vea~nz= z4;L($NT+RtuxV~roAV+UIHrkkABKDxqh@m$A4#O=N?C_$iBwC))$W7p4T=qCc^gqP zlA^_t#SVOezHzUW{og^>J1gVe1Vw`kC4t}LZU|UGBmsmqm%CrYT>0Y|x3U~N%m+a< zXy)*kD%@CNxzfUZ_zH>?5Av$$^E&wJggh?oE7-JAr<1+pv4=@;9b6?)*w2qO-D6g2 zkc>>hXGnQ%TIE#gvs9C>nPDUTx`-N{Qw>(*yRWa1p#~v*{Fi&bDvr3Ef<*|Dw{2Jm z?%?Z<2Ky0l8PrLF`Ew#@ID9n|2EIe+jfSKSX47B9Nd4-t1h*BDf*vEwA&!n`kIlC& z8=$3_%fKRjeu8d^o3O|HJ6w6L&?oEI&_XJ$9I}4zfi0o~ydBF{DmVU$k(ckhx@5`Y z@e?2j-ex6Z>9W1{$YZ{$T{tgd!%n6o%Z^R2Rh|Zw-c0CNW*L0Xlw%i}Fh&a+GzQ4{ z5&hLew}Wpa*U9natmX_#pz!5(xwk!dy~16B_(l-Hg@_wv`*j0bYe`mra?LjGEkc5s zn&tT2nG=D>mOnu5wvBaCm|)rz>~!vXYF1l``Ew&?iF14rat(VFbCFLLA}0}Ez$!_7!a`n4!btwPFb z93{9Z)NC5}07)!m#X6z>RfId`{S6#&LP*pmxpIHF@HmIs)7H=FF5;ZioR%gj$Z@Y% zJSD#)D|fMZ^U{^S7EpEET$(bvsg)Wn>C+Z%$JO`ZtgNvP>2!6_`hzqTVsucmDlxHP z(;|N#j3WN;#_H=}56i2=13eR!@#`K_yM$)ua!e7(X}lI)eQT;ts?WxPCOEbisaZ8X z;S{JS8g^N;HH-4oaYj<&^AzI2{Qh#Fke0sGMlMc9r=xKVOem`|T}<5VmbyODGq%9b zYi+bF!t(*0FMA2^v7}GL3US5EKJyXS7Z0$nThL20WYD+46u|eU$q;q zdEE4*W2;xx;#7BlvIWy4zbao6uN>gseH&)~i z#;3qrPbfLslD133wa0BOM#P5(?A?A$2alto53I#G0gvD1;af2W5lY&ujK^(q-DZ4_ zc>xRyW6w?3uF)0b1I3NC)8#P*;`=$kMwnUprj-Lw2>(VZx0-*^T&h9u04<#$$nvzM zaVb|K&-@r=R>|WVH|JJw(*Znlv2$KXk(%ATfN@&jZXrSuX$ySejXTk#==vh+lOJU8 z#!_z$V+Z0M=saXHh&X-0Zf*$&zCQWWFd;r7C=1|L(bmYqd#8h^Y!o+r8akKgt!(WB zpVez|wGJ|2m76>Ui;)>F)XBpZ1Xp;vzL_j4gK5_juXPk7=2S8uLXw7P6o^1yNLw%O zOH<%K?KV(b)iZ7Ka03ns{uNH_#=C;I9ldJ&^OuIdmSA#Pe#kGlSISxFNe3e`vL{;D%I~q{&p3&j%;S@N- z<)*IDJnQx6r-(3h~?-rXx(wz)abQi+95HM3E`{A?`l2O8Z?OPVyTEv(tLKG13;KV_nAB zpQ`Z8PCf?Qo4fmt!tfoE0r1=QMHMqt%mvYpLnV@)v#Zzhp`TAFS>_ujH(l|QwOgS? zo?68hJvSK(e^vQb+^}{&zz8I$Iy~aj$^fzy4#+N$4SKlfXp?#l#ikAPe8jHtYGNrY zTlK!SI#p<7PCb#-{p`h)L<- z&oDKg-UFvlsoOf>JVhMxlPS&-Ld5ENewZ#dv{U`st370H>uisDhy+S1ow>I-_%`X> zIZ&HQz~0Y!rF_u0r(`*kAw9ex>zb?J@M!{Ee1QSs90($`W&|YXUv3XbVUwKtwL$Js zE!RZ#?{P)zp!%lrur>R+l-laM&jm`2iO6khORJAl0{#4emi1ba9!UN&vq>NQ`~ z#72-}J%%1o>tAO4nFU@t%$1H%xwrxS?Om6Yd<3YgerwndN$a8|$~7b(YE-Kc-9viQ zq`pk%P=~ojBxzfGn+%Gvsm!L6k`SeKgNAQ+)iWz1#0CI4f1$MWz}o-i z;e8@(vKm;;yS4zien!^Stdtn&w&$Rt6hHV_8G&-hGp>BOrmb_%NaRwE^H*6kRr(6fc7}`=`_{VU!@D7#525+% zVhG%lP~~Pe_5%u{+JM$@C+b(UP)^D`>X=s)@nwjJBpQA) z74sW{dF`SKuB7<37t7>6s_CtGD{u%r+PY9(o%Na#-(VUph^^GRDQGr*rhsR8mf%G! z&c&g-XG>V+ih)5T<&hKF&v}7t5KYU;!*<59C=a8pw`-d+TUzJq)BvuN^ja>5YTn2@ zf+4(vgs})i0OafS0^lNHU))SmC{Nev!Bfk_@p(CKC930h!q)mdIO^cO2ZrMZw!4X1 zzk5sVHG%Wb!4-_aze2ywvXQ{sOg@k3!)|{T;HZ6{C3Q9!DkueBHL6Mbd;FIAtCq@U z)Zxj==GJ6S+33wvWf2a)^j|YhK~9B|y)l?4-$g)WTs8d~Rx&?5*9+(@)O7XvuS;jN zoceRnub!*#Aj*nV9P=JtVY@Q!-WS*BI-blwbxD6*{U4CFhOroYF8g@-7R%*M>IcqO z8BU~l1g2%!X<*Y4u&3X(lD?KlIA~*SYlpGvWc!@SaB898Ob(45KBM+JepP0E`Qsje zH$SxbfMknifW7bUkZ*{v*(y?ws6(`gwXR0M=DM@j_+t4ScHM~6q*jDDb|l5Y+3)!U z(I+_wdPANoy@=MwHIgb}4&iv{aB+P;P{dQ9jQ^cuRAUYt#S{rpI48Hjsze{6MS|#?p9_482~Bl6 zFe6l?*}`oFzXNY!a-~RMC*zV*U?b*9L_>?HY0#p&aLHw|6bXkG0*i>4g^3qlDn)%* zW}Z^dmEh!FiO$2l{ChrXZFq+aW(Fieh>i(?FQS~%Sq8%;c1sia(0SXpzKi&tr`@2; z>TeYTF%G!EKjhNH>qRSVfecvu^O892^qbtMwsn|u&*ew9_s!Q7Koiur#@<<@Ux~LQ zt`ZR?URyN5lOBb5G5PO8y)5-j{LR0yB~6FI=z^23XLy6~-;mcuS-CN<&Q)Nvaub_1 zgCQ#wk7$N6-Pl(W=#1-mIna3YN|B@K7v*nlJrkNbxLHZAJJ$38WHn+;X3DWZnwRQ9 zhCzB_nP|H6nX5ZK-B8b)w)nUn!>vvdRIS!Ie0@v9IXRHrw?sD6KfnJv$3}*8kt};M zhtgsC!&UCOE)mPNo(?Dh;}AkFp#=2@9lQR9=dtRpCZz5xulD$TQ>=;+Bw^>P>1q6^ z*+J8_A<4+Zm)$4blOJ?>6&R8o8 zL#j-F(#&~GG|5SNNP>L*-uIv-*n{iQSRjLv z9nM6F;+}>?yBn7o#fU|a6mGW>Yf@@s*$yZ3z~jnzAnZ)}+DJK>%BF#;;DX-Xe^^+; z#ohgt&;=d;n%--4yx|=9Xg%{i{|7N3*W#)H5fVmRv$$?Jk7P}3kxD7Jh`6e4c&*`w zG~N|%um*CM-GyTzB7Zx=lZSTy7AJIySF71a&(S;bH}y=;e)g|!h$2SZQ!UHd#fG{K z5cHRxoU+N<*~c>H<)ln_Hsnc-{foZ0Wv5~g5&TP>VqctrX8ni(3-~%5bP^<8F%=p- z!cg*)Pc#ezL5Vi0vy@dk?p~5c;J2IJMhQ`GL!5h@z83n;fDt2D7RU_$E5326R)y`T z7X;MM)p$ap8ES)TmW`+>S)Qn3IfAABdN^~x+RSX5nwmn!RtjKkzOySR_+uEQ^LNe3 zQ7s0=c=7>X7qW#ZTZn596&(@SZ^W0C-I7DOkthan1yS8z)3wL8@9|T0?uM9<@^D)y zK>y&~WpOULJPZYzSorSbZuBH18KZP38ob{v4OH?)|s{r(XIHMhBu_ zOyztv`_d>q4J(TGUVnUfA(IXI;SJvwLG~lLu{8DL1KW)V9kvfda~$HPe{gjuTY0kR zf8s4eI-O8%C;e(~_#+sp7h1Un$+vwM9`RJxHzmraDL3=LQ7$>NDL3k#L@3MgU3 z>*T^pH89GU3=p0~Q`0C?aPhixQ$ zawHc#VR`G~6zh{(fu(!YY;?yZq_9}Prd-_EA7-0aT)|^O?(Lk;-FcL}!@KX>DWCsr2 zect$6L1a+Xb?XFSYVcIW@>jjC{MWRpMx9tvRX>jkFPO&THBpQH66^K8fPq0Ie15|I zNsu7{VEGPUOC$F-rc4m0Z;=%6@kYbr(ECb5`E@5;8I#=;%hEH|XzNjpXA+|mi;O3ofD3v73*T1IuyLSR3(KnGerCQcbHx6%NF}~Rd7XBGw|xN{mz#116CQ|mxT(*!^y0A}W#|=B*EQ+_l=Y<%m&JLm#V(Ckh?Tjn zI)Fkp2TPQFZY|)s|FbYy#Yi;`ZcNKC&8To1~`=q=@$`xVi1fHcrBgP|Kb-da3GZ%0aNt<5a*Q zdF=RVo#e+z$2Y?z*Z{lLT!9T-r$L*i1bjaajRkv-gVGwaNnxA<7x%v__J+fuy17K6 zz1VUlY5p2_l-^Q$QAbpqA{?c z=0Fk(y_UYi$sI4c-%>epOD+DUM`d-hzn+j-vLzTT-F>^Wx{ZeqFS$YRLLS5s8HT)-285Yfr&+S7AiydHc0%qw_eUYEI-Hcw*oT zbBaAZQmT%b3nT|YDefu34*y&* z_lZr4XY}yvlg(tB<7bdxT=0UO6u5$nfL_a~waoAC(Ueq6@i*jJrI9BJ1ksXweqa)Q zPaz8-1V#NO>NU!wuog#%!9}t}LTukR!h0qIy+O7r;gNX@}ll(IMYiT>EqayFtcDp03pPm!&J)s}qy1M1|=xvHe)L z!dyRJ*yUk1>bFc2e1!&T8(%yj_YQ6r7*Gb*);HP>HL*PsEv7%xlxD*o7}K6Cxct;J z)^n#TW79Mf{oB@%|0Y1Xh?CRB`4Ii`JEoeuz#@hZXR0px3iFsuI2h!%kkCH3lwM}m zB=e&J8AVgf!`G7m3`ij3mn}nGgzNSwY;n2EZOIcUbmpVQj3bN;Xstl1$C1sJI`+= z*x-4NZTXhO&gq2@J?YqlCssQ3V%H^JU$;n%5Y$jEj{Ot}WVSqTvQun((*hr%#%qw&UPx zuOb^sisdNDhLJ!}rnO9K#2&m;g?vUN{E8aGo1aVhK-!mPyP|ZRYcq*Ef&BL??UdX| zzM*2&jg-few;t5tIuZ z{b&7c_ehV`-1MsY6-URU(M{;(+}X|mAVbESexybEBhqu!qBV@%w%q01SBZ{4JMAlR zArH)|hP0}!QqJ4K%j? z-?F%n1Qg7&GMn2;Sj)emGUNd0JpL)ALL;du+De0!5^T$ETT;teZo(h+9Y4>)Bb41= zygy^gxqC&+hsP-_Ht;d{_X>X7Vg5rtX#^(DNiLJxVY_yj(Gu+&0rxods!FY4q~xcAI1>{eU&)Q=0>hFzfu;^tpSh`OiR&Fd%+;R9a+EKx z$ZKEYc7cXnbIXBRL*0V#28%7h9%I9CvLQ>M~`ORM%V(F41u$Khh8cBaFtg# zGLsfvRWs<+E;0Z(T$F{vS=}~l1xBIrpoVGdr;okVf9}&T&)AOirUmm4-MRn9h1+}R z*Ni&FBP2uct5C);OhQdy3)NSNGxaS>wZ&Jbj^$)UCh9CR2zh#JXI=M0ihu z>%%3(+OeKR;SMfvnV-^aNshrsDHL%A0L?TzZJwt&18-ZgMgcA282Fa`$ej%Uv8~mM zzpeoA5sdrsdB05T@zv0j?O%#Pjs3~(_ZU8R5QYU@kZP5p<0_XpjSIqu;?~7BxQHjkx{fD-(o!TIul*9#nw}Grprc46@#kj3mei(OFI* zL#{jWtT1%e={LFP(?ep>KN0qGu+d|;`Qvk0qnZPeWgK1)OahfdP3XZcPN=P8#a(ZY z@b|Kg*hW>u6D#A`cQMz;o|?keQlp;W(Kc71(!33>rCEPjSUNUd=Ui7x5Pgp;5rE?y zaPf;%=kv2PN?KU^IfCiR!l9XzF?TG^`?gl-`kV`qzqJ>XYpO<_fSp?}?wHK~ zNCS;go60-&>!&d_(Iv^ggPj8cy-E|?I_6wV&v`gD172m*-?u zvNk@<^!?(3*>0}I!3Qi0BbyyaiiOiE3eI8eX|K2oU4%ZaHG)BXbsV>Pu6OKAns`3 z#nZsTml=ouFh%L?*3^gPJJ3^TuW(Jrd?8rQn0Egma}D)U!UsM9l0wbf!^T!u^G#$< z8dYAfnRv}Abgb9?@pBG*lRCY^tLUdK=21?94U)_^7oVGOfuqG9? z8))n!V!}6^lWjB>5idpV5OfPx?xz)rtyAw(LvXsm%FE@yI0hP$-}=a8m{t+|zM^u4 z_Te*A#t0w%wPP;0%p{`8g+>peBNF!9I+iv}Ky7O-GFiD*&xS5BZ$GWY9Dk)P{{`!C zwvyKSg9@FOjWi=8;K4g^35)$m7%Bpr#mJy4*fOB&a`jBgdIlwT79Ms2zN`ObVO$~2 zk+*HP7=XhYB0sOnsOoHOG8s)F?Ny&mx+j^}ys>tVDW8w8DhVYJeO6P+Rjj#-$2r86 z_XXFD9~vqq>>2#y3Jgxku2%h~AhP+|0|lm8-}l+7SLp6^LNGOTe(`vBsQEC5grjPu z^dc73ino)oA3|tBCz%u7e3|wx{RDQvK;{kfQ$WxAh{X~OBy99%`DokK!)~WefUS56 zqGw`FzgG#;{DJZs8kmNK8=%jDoS>L(uc>z2t2L#cNTWQ5rK?H-PWHlv1GBa13RTlD zi14!k@8XqeFQs<~t_9bcOIDbQoH90^OEc(mVFo6qAtf&QNkJemx=#WJ1|Dt+2KC8- ze`dVT{JFf#2M2@vq~QP52)WTBI%gI#POHReu%i5URgiL^+gx3S;Ve4RP>S9RZ zVQXXO4D{e5Gc>U`G6jC}|5O9WNd8;J#hQ=opQc3YolHqMnV6ZF$v&-}Ow52vqT>H! z`}xF2X5r%E00aQs-QAho*_iB|%mFOi+}r?WRsbt2i!h?+VZ zJ6SroSlZi>{NvHk$lle3kLc0Rt4U824 literal 0 HcmV?d00001 From fd4ea3aca5b9b1e7782dcd9818d745eb8ceac69b Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 8 Mar 2024 20:00:24 +1100 Subject: [PATCH 087/299] fix: update docker docs --- docker/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docker/README.md b/docker/README.md index addb278c4..ba942ac1c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -60,15 +60,13 @@ docker pull ghcr.io/documenso/documenso ``` docker run -d \ -p 3000:3000 \ - -e POSTGRES_USER="" - -e POSTGRES_PASSWORD="" - -e POSTGRES_DB="" -e NEXTAUTH_URL="" -e NEXTAUTH_SECRET="" -e NEXT_PRIVATE_ENCRYPTION_KEY="" -e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="" -e NEXT_PUBLIC_WEBAPP_URL="" -e NEXT_PRIVATE_DATABASE_URL="" + -e NEXT_PRIVATE_DIRECT_DATABASE_URL="" -e NEXT_PRIVATE_SMTP_TRANSPORT="" -e NEXT_PRIVATE_SMTP_FROM_NAME="" -e NEXT_PRIVATE_SMTP_FROM_ADDRESS="" From 8afe6699785b51f8abb5fa937418430b79a6df75 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 8 Mar 2024 16:26:47 +0530 Subject: [PATCH 088/299] feat: improve lint staged performance --- lint-staged.config.cjs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lint-staged.config.cjs b/lint-staged.config.cjs index d7e42680e..0fff01832 100644 --- a/lint-staged.config.cjs +++ b/lint-staged.config.cjs @@ -1,7 +1,15 @@ +const path = require('path'); + +const buildEslintCommand = (filenames) => + `eslint --fix ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')} `; + +const buildPrettierCommand = (filenames) => + `prettier --write ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')} `; + /** @type {import('lint-staged').Config} */ module.exports = { - '**/*.{ts,tsx,cts,mts}': (files) => files.map((file) => `eslint --fix ${file}`), - '**/*.{js,jsx,cjs,mjs}': (files) => files.map((file) => `prettier --write ${file}`), - '**/*.{yml,mdx}': (files) => files.map((file) => `prettier --write ${file}`), + '**/*.{ts,tsx,cts,mts}': [buildEslintCommand], + '**/*.{js,jsx,cjs,mjs}': [buildPrettierCommand], + '**/*.{yml,mdx}': [buildPrettierCommand], '**/*/package.json': 'npm run precommit', }; From c463d5a0ed82bc82317e10bc7baa5b2e73f79d63 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 8 Mar 2024 16:47:38 +0530 Subject: [PATCH 089/299] fix: add double quote --- lint-staged.config.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lint-staged.config.cjs b/lint-staged.config.cjs index 0fff01832..a23c3fd78 100644 --- a/lint-staged.config.cjs +++ b/lint-staged.config.cjs @@ -1,10 +1,10 @@ const path = require('path'); const buildEslintCommand = (filenames) => - `eslint --fix ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')} `; + `eslint --fix ${filenames.map((f) => `"${path.relative(process.cwd(), f)}"`).join(' ')}`; const buildPrettierCommand = (filenames) => - `prettier --write ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')} `; + `prettier --write ${filenames.map((f) => `"${path.relative(process.cwd(), f)}"`).join(' ')}`; /** @type {import('lint-staged').Config} */ module.exports = { From 41843691e807b59efe1dd3ac1fec2b34e80fd1df Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 8 Mar 2024 13:44:25 +0200 Subject: [PATCH 090/299] feat: add website cta --- apps/marketing/contentlayer.config.ts | 1 + .../src/app/(marketing)/blog/[post]/page.tsx | 4 +++ .../src/components/(marketing)/CTA.tsx | 26 +++++++++++++++++++ 3 files changed, 31 insertions(+) create mode 100644 apps/marketing/src/components/(marketing)/CTA.tsx diff --git a/apps/marketing/contentlayer.config.ts b/apps/marketing/contentlayer.config.ts index f1ba82b89..10999a408 100644 --- a/apps/marketing/contentlayer.config.ts +++ b/apps/marketing/contentlayer.config.ts @@ -12,6 +12,7 @@ export const BlogPost = defineDocumentType(() => ({ authorName: { type: 'string', required: true }, authorImage: { type: 'string', required: false }, authorRole: { type: 'string', required: true }, + cta: { type: 'boolean', required: false, default: true }, }, computedFields: { href: { type: 'string', resolve: (post) => `/${post._raw.flattenedPath}` }, diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx index 14b8b2d8f..240e8576f 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx @@ -7,6 +7,8 @@ import { ChevronLeft } from 'lucide-react'; import type { MDXComponents } from 'mdx/types'; import { useMDXComponent } from 'next-contentlayer/hooks'; +import CTA from '~/components/(marketing)/CTA'; + export const dynamic = 'force-dynamic'; export const generateMetadata = ({ params }: { params: { post: string } }) => { @@ -89,6 +91,8 @@ export default function BlogPostPage({ params }: { params: { post: string } }) { Back to all posts + + {post.cta && } ); } diff --git a/apps/marketing/src/components/(marketing)/CTA.tsx b/apps/marketing/src/components/(marketing)/CTA.tsx new file mode 100644 index 000000000..b1b1e8603 --- /dev/null +++ b/apps/marketing/src/components/(marketing)/CTA.tsx @@ -0,0 +1,26 @@ +import Link from 'next/link'; + +import { Button } from '@documenso/ui/primitives/button'; + +// import { cn } from '@documenso/ui/lib/utils'; + +export default function CTA() { + return ( +

+

+ Join the Open Document Signing Movement +

+ +

+ Create your account and start using state-of-the-art document signing. Open and beautiful + signing is within your grasp. +

+ + +
+ ); +} From 61ca34eee17074d7025e00eea865d41f46c4686a Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 8 Mar 2024 13:46:22 +0200 Subject: [PATCH 091/299] removed unused cn --- apps/marketing/src/components/(marketing)/CTA.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/marketing/src/components/(marketing)/CTA.tsx b/apps/marketing/src/components/(marketing)/CTA.tsx index b1b1e8603..92198a0f1 100644 --- a/apps/marketing/src/components/(marketing)/CTA.tsx +++ b/apps/marketing/src/components/(marketing)/CTA.tsx @@ -2,8 +2,6 @@ import Link from 'next/link'; import { Button } from '@documenso/ui/primitives/button'; -// import { cn } from '@documenso/ui/lib/utils'; - export default function CTA() { return (
From 32b0b1bcdaee01cdee9b6ce1b494211263ae487f Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 8 Mar 2024 12:21:32 +0000 Subject: [PATCH 092/299] fix: revert api change and use mouseenter/mouseleave --- .../app/(dashboard)/documents/[id]/page.tsx | 6 +- .../app/(dashboard)/documents/data-table.tsx | 4 +- .../avatar/stack-avatars-component.tsx | 71 --------- .../(dashboard)/avatar/stack-avatars-ui.tsx | 52 ------- .../avatar/stack-avatars-with-tooltip.tsx | 140 ++++++++++++++++++ 5 files changed, 145 insertions(+), 128 deletions(-) delete mode 100644 apps/web/src/components/(dashboard)/avatar/stack-avatars-component.tsx delete mode 100644 apps/web/src/components/(dashboard)/avatar/stack-avatars-ui.tsx create mode 100644 apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index bf58ae36a..44f3991d8 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -13,7 +13,7 @@ import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/clie import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; -import { StackAvatarsUI } from '~/components/(dashboard)/avatar/stack-avatars-ui'; +import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { DocumentStatus } from '~/components/formatter/document-status'; export type DocumentPageProps = { @@ -90,9 +90,9 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
- + {recipients.length} Recipient(s) - +
)}
diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index ca2da02d3..72787714f 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -12,7 +12,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; -import { StackAvatarsUI } from '~/components/(dashboard)/avatar/stack-avatars-ui'; +import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { DocumentStatus } from '~/components/formatter/document-status'; import { LocaleDate } from '~/components/formatter/locale-date'; @@ -64,7 +64,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { { header: 'Recipient', accessorKey: 'recipient', - cell: ({ row }) => , + cell: ({ row }) => , }, { header: 'Status', diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-component.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-component.tsx deleted file mode 100644 index d7f3106e6..000000000 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-component.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; -import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import type { Recipient } from '@documenso/prisma/client'; - -import { AvatarWithRecipient } from './avatar-with-recipient'; -import { StackAvatar } from './stack-avatar'; - -export const StackAvatarsComponent = ({ recipients }: { recipients: Recipient[] }) => { - const waitingRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === 'waiting', - ); - - const openedRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === 'opened', - ); - - const completedRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === 'completed', - ); - - const uncompletedRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === 'unsigned', - ); - return ( -
- {completedRecipients.length > 0 && ( -
-

Completed

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

Waiting

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

Opened

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

Uncompleted

- {uncompletedRecipients.map((recipient: Recipient) => ( - - ))} -
- )} -
- ); -}; diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-ui.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-ui.tsx deleted file mode 100644 index c1c44836a..000000000 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-ui.tsx +++ /dev/null @@ -1,52 +0,0 @@ -'use client'; - -import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size'; -import type { Recipient } from '@documenso/prisma/client'; -import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@documenso/ui/primitives/tooltip'; - -import { StackAvatars } from './stack-avatars'; -import { StackAvatarsComponent } from './stack-avatars-component'; - -export type StackAvatarsUIProps = { - recipients: Recipient[]; - position?: 'top' | 'bottom'; - children?: React.ReactNode; -}; - -export const StackAvatarsUI = ({ recipients, position, children }: StackAvatarsUIProps) => { - const size = useWindowSize(); - - return ( - <> - {size.width > 1050 ? ( - - - - {children || } - - - - - - - - ) : ( - - - {children || } - - - - - - - )} - - ); -}; 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 new file mode 100644 index 000000000..51fd9c8cd --- /dev/null +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -0,0 +1,140 @@ +import { useRef, useState } from 'react'; + +import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +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 { AvatarWithRecipient } from './avatar-with-recipient'; +import { StackAvatar } from './stack-avatar'; +import { StackAvatars } from './stack-avatars'; + +export type StackAvatarsWithTooltipProps = { + recipients: Recipient[]; + position?: 'top' | 'bottom'; + children?: React.ReactNode; +}; + +export const StackAvatarsWithTooltip = ({ + recipients, + position, + children, +}: StackAvatarsWithTooltipProps) => { + const [open, setOpen] = useState(false); + + const isControlled = useRef(false); + const isMouseOverTimeout = useRef(null); + + const waitingRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === 'waiting', + ); + + const openedRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === 'opened', + ); + + const completedRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === 'completed', + ); + + const uncompletedRecipients = recipients.filter( + (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} +
+ ))} +
+ )} + + {waitingRecipients.length > 0 && ( +
+

Waiting

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

Opened

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

Uncompleted

+ {uncompletedRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} +
+
+ ); +}; From 40343d1c7241861ccc59788fef3f47c3b0680f27 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 8 Mar 2024 12:34:49 +0000 Subject: [PATCH 093/299] fix: add use client directive --- .../(dashboard)/avatar/stack-avatars-with-tooltip.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 0e6aa0ac8..10f7d1e6a 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,3 +1,5 @@ +'use client'; + import { useRef, useState } from 'react'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; From 0fdb7f7a8d6f80f74cada410c825b4bc000dc5fe Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 8 Mar 2024 15:30:08 +0200 Subject: [PATCH 094/299] fix: changed to card component --- .../src/components/(marketing)/CTA.tsx | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/marketing/src/components/(marketing)/CTA.tsx b/apps/marketing/src/components/(marketing)/CTA.tsx index 92198a0f1..d7ce572e1 100644 --- a/apps/marketing/src/components/(marketing)/CTA.tsx +++ b/apps/marketing/src/components/(marketing)/CTA.tsx @@ -1,24 +1,26 @@ import Link from 'next/link'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; export default function CTA() { return ( -
-

- Join the Open Document Signing Movement -

+ + +

Join the Open Signing Movement

-

- Create your account and start using state-of-the-art document signing. Open and beautiful - signing is within your grasp. -

+

+ Create your account and start using state-of-the-art document signing. Open and beautiful + signing is within your grasp. +

- -
+ + + ); } From e8b209eb822e9c8280be689599f2ec1ec624adf1 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 8 Mar 2024 15:46:44 +0200 Subject: [PATCH 095/299] fix: fixed cta component --- .../src/app/(marketing)/blog/[post]/page.tsx | 81 +++---- .../src/app/(marketing)/open/page.tsx | 212 +++++++++--------- .../src/components/(marketing)/CTA.tsx | 8 +- 3 files changed, 153 insertions(+), 148 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx index 240e8576f..917045e5a 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx @@ -44,55 +44,56 @@ export default function BlogPostPage({ params }: { params: { post: string } }) { const MDXContent = useMDXComponent(post.body.code); return ( -
-
- +
+
+
+ -

{post.title}

+

{post.title}

-
-
- {post.authorImage && ( - {`Image - )} -
+
+
+ {post.authorImage && ( + {`Image + )} +
-
-

{post.authorName}

-

{post.authorRole}

+
+

{post.authorName}

+

{post.authorRole}

+
-
- + - {post.tags.length > 0 && ( -
    - {post.tags.map((tag, i) => ( -
  • - {tag} -
  • - ))} -
- )} + {post.tags.length > 0 && ( +
    + {post.tags.map((tag, i) => ( +
  • + {tag} +
  • + ))} +
+ )} -
- - - - Back to all posts - +
+ + + Back to all posts + +
{post.cta && } -
+
); } diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index 8fef81134..842d91ff8 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -7,6 +7,7 @@ import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-m 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 CTA from '~/components/(marketing)/CTA'; import { BarMetric } from './bar-metrics'; import { CapTable } from './cap-table'; @@ -141,114 +142,117 @@ export default async function OpenPage() { const MONTHLY_USERS = await getUserMonthlyGrowth(); return ( -
-
-

Open Startup

+
+
+
+

Open Startup

-

- All our metrics, finances, and learnings are public. We believe in transparency and want - to share our journey with you. You can read more about why here:{' '} - - Announcing Open Metrics - -

-
- -
-
- - - - -
- - - - - - - - - - - data={EARLY_ADOPTERS_DATA} - metricKey="earlyAdopters" - title="Early Adopters" - label="Early Adopters" - className="col-span-12 lg:col-span-6" - extraInfo={} - /> - - - data={STARGAZERS_DATA} - metricKey="stars" - title="Github: Total Stars" - label="Stars" - className="col-span-12 lg:col-span-6" - /> - - - data={STARGAZERS_DATA} - metricKey="mergedPRs" - title="Github: Total Merged PRs" - label="Merged PRs" - chartHeight={300} - className="col-span-12 lg:col-span-6" - /> - - - data={STARGAZERS_DATA} - metricKey="forks" - title="Github: Total Forks" - label="Forks" - chartHeight={300} - className="col-span-12 lg:col-span-6" - /> - - - data={STARGAZERS_DATA} - metricKey="openIssues" - title="Github: Total Open Issues" - label="Open Issues" - chartHeight={300} - className="col-span-12 lg:col-span-6" - /> - - - - - - -
-

Where's the rest?

- -

- We're still working on getting all our metrics together. We'll update this page as soon - as we have more to share. +

+ All our metrics, finances, and learnings are public. We believe in transparency and want + to share our journey with you. You can read more about why here:{' '} + + Announcing Open Metrics +

+ +
+
+ + + + +
+ + + + + + + + + + + data={EARLY_ADOPTERS_DATA} + metricKey="earlyAdopters" + title="Early Adopters" + label="Early Adopters" + className="col-span-12 lg:col-span-6" + extraInfo={} + /> + + + data={STARGAZERS_DATA} + metricKey="stars" + title="Github: Total Stars" + label="Stars" + className="col-span-12 lg:col-span-6" + /> + + + data={STARGAZERS_DATA} + metricKey="mergedPRs" + title="Github: Total Merged PRs" + label="Merged PRs" + chartHeight={300} + className="col-span-12 lg:col-span-6" + /> + + + data={STARGAZERS_DATA} + metricKey="forks" + title="Github: Total Forks" + label="Forks" + chartHeight={300} + className="col-span-12 lg:col-span-6" + /> + + + data={STARGAZERS_DATA} + metricKey="openIssues" + title="Github: Total Open Issues" + label="Open Issues" + chartHeight={300} + className="col-span-12 lg:col-span-6" + /> + + + + + + +
+

Where's the rest?

+ +

+ We're still working on getting all our metrics together. We'll update this page as + soon as we have more to share. +

+
+
+
); } diff --git a/apps/marketing/src/components/(marketing)/CTA.tsx b/apps/marketing/src/components/(marketing)/CTA.tsx index d7ce572e1..dee5e5fae 100644 --- a/apps/marketing/src/components/(marketing)/CTA.tsx +++ b/apps/marketing/src/components/(marketing)/CTA.tsx @@ -6,16 +6,16 @@ import { Card, CardContent } from '@documenso/ui/primitives/card'; export default function CTA() { return ( - - + +

Join the Open Signing Movement

-

+

Create your account and start using state-of-the-art document signing. Open and beautiful signing is within your grasp.

-
- + setEnteredUsername('')}> @@ -103,17 +109,44 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp , along with all of your completed documents, signatures, and all other resources belonging to your Account. + {!hasTwoFactorAuthentication && ( + + Please type {username} to confirm. + + )} + {!hasTwoFactorAuthentication && ( +
+ setEnteredUsername(e.target.value)} + onPaste={(e) => e.preventDefault()} + /> +
+ )} - + {!hasTwoFactorAuthentication && ( + + )}
From b4332257627b442929d43bc41d1975ee1784d370 Mon Sep 17 00:00:00 2001 From: Gautam Hegde <85569489+Gautam-Hegde@users.noreply.github.com> Date: Fri, 8 Mar 2024 22:12:05 +0530 Subject: [PATCH 099/299] Update command.tsx --- packages/ui/primitives/command.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/ui/primitives/command.tsx b/packages/ui/primitives/command.tsx index a36ae8fdc..89777d417 100644 --- a/packages/ui/primitives/command.tsx +++ b/packages/ui/primitives/command.tsx @@ -96,10 +96,7 @@ const CommandGroup = React.forwardRef< className, )} {...props} - > -
- {props.children} - + /> )); CommandGroup.displayName = CommandPrimitive.Group.displayName; From d462ca0b462c6317c3eba0e19d7a96ec3ea0752f Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 9 Mar 2024 00:16:16 +0530 Subject: [PATCH 100/299] feat: remove prettier plugin --- packages/eslint-config/index.cjs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/eslint-config/index.cjs b/packages/eslint-config/index.cjs index 57cecf40d..bc20fcbdc 100644 --- a/packages/eslint-config/index.cjs +++ b/packages/eslint-config/index.cjs @@ -4,29 +4,20 @@ module.exports = { 'turbo', 'eslint:recommended', 'plugin:@typescript-eslint/recommended', - 'plugin:prettier/recommended', 'plugin:package-json/recommended', ], - plugins: ['prettier', 'package-json', 'unused-imports'], + plugins: ['package-json', 'unused-imports'], env: { + es2022: true, node: true, browser: true, - es6: true, }, parser: '@typescript-eslint/parser', - parserOptions: { - tsconfigRootDir: __dirname, - project: ['../../apps/*/tsconfig.json', '../../packages/*/tsconfig.json'], - ecmaVersion: 2022, - ecmaFeatures: { - jsx: true, - }, - sourceType: 'module', - }, + parserOptions: { project: true }, rules: { '@next/next/no-html-link-for-pages': 'off', From 7631c6e90ee3b4ea7b4cc8d1cc585f6ea98480b2 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 9 Mar 2024 00:17:04 +0530 Subject: [PATCH 101/299] feat: add prettier to lint --- lint-staged.config.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lint-staged.config.cjs b/lint-staged.config.cjs index a23c3fd78..9f10dcc76 100644 --- a/lint-staged.config.cjs +++ b/lint-staged.config.cjs @@ -8,7 +8,7 @@ const buildPrettierCommand = (filenames) => /** @type {import('lint-staged').Config} */ module.exports = { - '**/*.{ts,tsx,cts,mts}': [buildEslintCommand], + '**/*.{ts,tsx,cts,mts}': [buildEslintCommand, buildPrettierCommand], '**/*.{js,jsx,cjs,mjs}': [buildPrettierCommand], '**/*.{yml,mdx}': [buildPrettierCommand], '**/*/package.json': 'npm run precommit', From 19714fb807a2a1496e26ca82f4c3c3c2cfad5d36 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 9 Mar 2024 00:29:42 +0530 Subject: [PATCH 102/299] feat: update packages --- package-lock.json | 1571 ++++++++++++++++++++------- packages/eslint-config/package.json | 18 +- 2 files changed, 1202 insertions(+), 387 deletions(-) diff --git a/package-lock.json b/package-lock.json index 72d06b98a..aa4c258f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3110,14 +3110,6 @@ "resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.3.tgz", "integrity": "sha512-7xRqh9nMvP5xrW4/+L0jgRRX+HoNRGnfJpD+5Wq6/13j3dsdzxO3BCXn7D3hMqsDb+vjZnJq+vI7+EtgrYZTeA==" }, - "node_modules/@next/eslint-plugin-next": { - "version": "13.4.19", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-13.4.19.tgz", - "integrity": "sha512-N/O+zGb6wZQdwu6atMZHbR7T9Np5SUFUjZqCbj0sXm+MwQO35M8TazVB4otm87GkXYs2l6OPwARd3/PUWhZBVQ==", - "dependencies": { - "glob": "7.1.7" - } - }, "node_modules/@next/swc-darwin-arm64": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.3.tgz", @@ -7496,187 +7488,6 @@ "@types/node": "*" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.8.0.tgz", - "integrity": "sha512-GosF4238Tkes2SHPQ1i8f6rMtG6zlKwMEB0abqSJ3Npvos+doIlc/ATG+vX1G9coDF3Ex78zM3heXHLyWEwLUw==", - "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.8.0", - "@typescript-eslint/type-utils": "6.8.0", - "@typescript-eslint/utils": "6.8.0", - "@typescript-eslint/visitor-keys": "6.8.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.4", - "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.8.0.tgz", - "integrity": "sha512-5tNs6Bw0j6BdWuP8Fx+VH4G9fEPDxnVI7yH1IAPkQH5RUtvKwRoqdecAPdQXv4rSOADAaz1LFBZvZG7VbXivSg==", - "dependencies": { - "@typescript-eslint/scope-manager": "6.8.0", - "@typescript-eslint/types": "6.8.0", - "@typescript-eslint/typescript-estree": "6.8.0", - "@typescript-eslint/visitor-keys": "6.8.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.8.0.tgz", - "integrity": "sha512-xe0HNBVwCph7rak+ZHcFD6A+q50SMsFwcmfdjs9Kz4qDh5hWhaPhFjRs/SODEhroBI5Ruyvyz9LfwUJ624O40g==", - "dependencies": { - "@typescript-eslint/types": "6.8.0", - "@typescript-eslint/visitor-keys": "6.8.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.8.0.tgz", - "integrity": "sha512-RYOJdlkTJIXW7GSldUIHqc/Hkto8E+fZN96dMIFhuTJcQwdRoGN2rEWA8U6oXbLo0qufH7NPElUb+MceHtz54g==", - "dependencies": { - "@typescript-eslint/typescript-estree": "6.8.0", - "@typescript-eslint/utils": "6.8.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/types": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.8.0.tgz", - "integrity": "sha512-p5qOxSum7W3k+llc7owEStXlGmSl8FcGvhYt8Vjy7FqEnmkCVlM3P57XQEGj58oqaBWDQXbJDZxwUWMS/EAPNQ==", - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.8.0.tgz", - "integrity": "sha512-ISgV0lQ8XgW+mvv5My/+iTUdRmGspducmQcDw5JxznasXNnZn3SKNrTRuMsEXv+V/O+Lw9AGcQCfVaOPCAk/Zg==", - "dependencies": { - "@typescript-eslint/types": "6.8.0", - "@typescript-eslint/visitor-keys": "6.8.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.8.0.tgz", - "integrity": "sha512-dKs1itdE2qFG4jr0dlYLQVppqTE+Itt7GmIf/vX6CSvsW+3ov8PbWauVKyyfNngokhIO9sKZeRGCUo1+N7U98Q==", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.8.0", - "@typescript-eslint/types": "6.8.0", - "@typescript-eslint/typescript-estree": "6.8.0", - "semver": "^7.5.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.8.0.tgz", - "integrity": "sha512-oqAnbA7c+pgOhW2OhGvxm0t1BULX5peQI/rLsNDpGM78EebV3C9IGbX5HNZabuZ6UQrYveCLjKo8Iy/lLlBkkg==", - "dependencies": { - "@typescript-eslint/types": "6.8.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -7954,6 +7765,24 @@ "node": ">=8" } }, + "node_modules/array.prototype.findlast": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.4.tgz", + "integrity": "sha512-BMtLxpV+8BD+6ZPFIWmnUBpQoy+A+ujcg4rhp2iwCRJYA7PEh2MS4NL3lz8EiDlLrJPp2hg9qWihr5pd//jcGw==", + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.findlastindex": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", @@ -8006,10 +7835,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array.prototype.toreversed": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/array.prototype.toreversed/-/array.prototype.toreversed-1.1.2.tgz", + "integrity": "sha512-wwDCoT4Ck4Cz7sLtgUmzR5UV3YF5mFHUlbChCzZBQZ+0m2cl/DH3tKgvphv1nKgFsJ48oCSg6p91q2Vm0I/ZMA==", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + } + }, "node_modules/array.prototype.tosorted": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.2.tgz", "integrity": "sha512-HuQCHOlk1Weat5jzStICBCd83NxiIMwqDg/dHEsoefabn/hJRj5pVdWcPUSpRrwhwxZOsQassMpgN/xRYFBMIg==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -10247,6 +10088,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-iterator-helpers": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", @@ -10723,35 +10583,11 @@ "semver": "bin/semver.js" } }, - "node_modules/eslint-config-next": { - "version": "13.4.19", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-13.4.19.tgz", - "integrity": "sha512-WE8367sqMnjhWHvR5OivmfwENRQ1ixfNE9hZwQqNCsd+iM3KnuMc1V8Pt6ytgjxjf23D+xbesADv9x3xaKfT3g==", - "dependencies": { - "@next/eslint-plugin-next": "13.4.19", - "@rushstack/eslint-patch": "^1.1.3", - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-react": "^7.31.7", - "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" - }, - "peerDependencies": { - "eslint": "^7.23.0 || ^8.0.0", - "typescript": ">=3.3.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/eslint-config-prettier": { "version": "8.10.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz", "integrity": "sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==", + "dev": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10759,17 +10595,6 @@ "eslint": ">=7.0.0" } }, - "node_modules/eslint-config-turbo": { - "version": "1.10.16", - "resolved": "https://registry.npmjs.org/eslint-config-turbo/-/eslint-config-turbo-1.10.16.tgz", - "integrity": "sha512-O3NQI72bQHV7FvSC6lWj66EGx8drJJjuT1kuInn6nbMLOHdMBhSUX/8uhTAlHRQdlxZk2j9HtgFCIzSc93w42g==", - "dependencies": { - "eslint-plugin-turbo": "1.10.16" - }, - "peerDependencies": { - "eslint": ">6.6.0" - } - }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -10922,55 +10747,6 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, - "node_modules/eslint-plugin-prettier": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", - "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", - "dependencies": { - "prettier-linter-helpers": "^1.0.0" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "eslint": ">=7.28.0", - "prettier": ">=2.0.0" - }, - "peerDependenciesMeta": { - "eslint-config-prettier": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.33.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", - "integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flatmap": "^1.3.1", - "array.prototype.tosorted": "^1.1.1", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.0.12", - "estraverse": "^5.3.0", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.6", - "object.fromentries": "^2.0.6", - "object.hasown": "^1.1.2", - "object.values": "^1.1.6", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.4", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.8" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, "node_modules/eslint-plugin-react-hooks": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", @@ -10982,80 +10758,6 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" } }, - "node_modules/eslint-plugin-react/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-turbo": { - "version": "1.10.16", - "resolved": "https://registry.npmjs.org/eslint-plugin-turbo/-/eslint-plugin-turbo-1.10.16.tgz", - "integrity": "sha512-ZjrR88MTN64PNGufSEcM0tf+V1xFYVbeiMeuIqr0aiABGomxFLo4DBkQ7WI4WzkZtWQSIA2sP+yxqSboEfL9MQ==", - "dependencies": { - "dotenv": "16.0.3" - }, - "peerDependencies": { - "eslint": ">6.6.0" - } - }, - "node_modules/eslint-plugin-turbo/node_modules/dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", - "engines": { - "node": ">=12" - } - }, - "node_modules/eslint-plugin-unused-imports": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-3.0.0.tgz", - "integrity": "sha512-sduiswLJfZHeeBJ+MQaG+xYzSWdRXoSw61DpU13mzWumCkR0ufD0HmO4kdNokjrkluMHpj/7PJeN35pgbhW3kw==", - "dependencies": { - "eslint-rule-composer": "^0.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^6.0.0", - "eslint": "^8.0.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - } - } - }, "node_modules/eslint-rule-composer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz", @@ -11342,11 +11044,6 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==" - }, "node_modules/fast-equals": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", @@ -11884,15 +11581,19 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dependencies": { + "es-errors": "^1.3.0", "function-bind": "^1.1.2", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", "hasown": "^2.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -16362,6 +16063,14 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "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", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -16575,17 +16284,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/prettier-plugin-sql": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/prettier-plugin-sql/-/prettier-plugin-sql-0.14.0.tgz", @@ -21588,19 +21286,690 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "6.8.0", - "@typescript-eslint/parser": "6.8.0", - "eslint": "^8.40.0", - "eslint-config-next": "13.4.19", - "eslint-config-prettier": "^8.8.0", - "eslint-config-turbo": "^1.9.3", - "eslint-plugin-package-json": "^0.2.0", - "eslint-plugin-prettier": "^4.2.1", - "eslint-plugin-react": "^7.32.2", - "eslint-plugin-unused-imports": "^3.0.0", + "@typescript-eslint/eslint-plugin": "^7.1.1", + "@typescript-eslint/parser": "^7.1.1", + "eslint": "^8.57.0", + "eslint-config-next": "^14.1.3", + "eslint-config-turbo": "^1.12.5", + "eslint-plugin-package-json": "^0.10.4", + "eslint-plugin-react": "^7.34.0", + "eslint-plugin-unused-imports": "^3.1.0", "typescript": "5.2.2" } }, + "packages/eslint-config/node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/eslint-config/node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "packages/eslint-config/node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "packages/eslint-config/node_modules/@humanwhocodes/object-schema": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", + "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==" + }, + "packages/eslint-config/node_modules/@next/eslint-plugin-next": { + "version": "14.1.3", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.1.3.tgz", + "integrity": "sha512-VCnZI2cy77Yaj3L7Uhs3+44ikMM1VD/fBMwvTBb3hIaTIuqa+DmG4dhUDq+MASu3yx97KhgsVJbsas0XuiKyww==", + "dependencies": { + "glob": "10.3.10" + } + }, + "packages/eslint-config/node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.1.1.tgz", + "integrity": "sha512-zioDz623d0RHNhvx0eesUmGfIjzrk18nSBC8xewepKXbBvN/7c1qImV7Hg8TI1URTxKax7/zxfxj3Uph8Chcuw==", + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "7.1.1", + "@typescript-eslint/type-utils": "7.1.1", + "@typescript-eslint/utils": "7.1.1", + "@typescript-eslint/visitor-keys": "7.1.1", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/eslint-config/node_modules/@typescript-eslint/parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.1.1.tgz", + "integrity": "sha512-ZWUFyL0z04R1nAEgr9e79YtV5LbafdOtN7yapNbn1ansMyaegl2D4bL7vHoJ4HPSc4CaLwuCVas8CVuneKzplQ==", + "dependencies": { + "@typescript-eslint/scope-manager": "7.1.1", + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/typescript-estree": "7.1.1", + "@typescript-eslint/visitor-keys": "7.1.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/eslint-config/node_modules/@typescript-eslint/scope-manager": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.1.tgz", + "integrity": "sha512-cirZpA8bJMRb4WZ+rO6+mnOJrGFDd38WoXCEI57+CYBqta8Yc8aJym2i7vyqLL1vVYljgw0X27axkUXz32T8TA==", + "dependencies": { + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/visitor-keys": "7.1.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/eslint-config/node_modules/@typescript-eslint/type-utils": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.1.1.tgz", + "integrity": "sha512-5r4RKze6XHEEhlZnJtR3GYeCh1IueUHdbrukV2KSlLXaTjuSfeVF8mZUVPLovidCuZfbVjfhi4c0DNSa/Rdg5g==", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.1.1", + "@typescript-eslint/utils": "7.1.1", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/eslint-config/node_modules/@typescript-eslint/types": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.1.1.tgz", + "integrity": "sha512-KhewzrlRMrgeKm1U9bh2z5aoL4s7K3tK5DwHDn8MHv0yQfWFz/0ZR6trrIHHa5CsF83j/GgHqzdbzCXJ3crx0Q==", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/eslint-config/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.1.1.tgz", + "integrity": "sha512-9ZOncVSfr+sMXVxxca2OJOPagRwT0u/UHikM2Rd6L/aB+kL/QAuTnsv6MeXtjzCJYb8PzrXarypSGIPx3Jemxw==", + "dependencies": { + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/visitor-keys": "7.1.1", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/eslint-config/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/eslint-config/node_modules/@typescript-eslint/utils": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz", + "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "7.1.1", + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/typescript-estree": "7.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "packages/eslint-config/node_modules/@typescript-eslint/visitor-keys": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.1.1.tgz", + "integrity": "sha512-yTdHDQxY7cSoCcAtiBzVzxleJhkGB9NncSIyMYe2+OGON1ZsP9zOPws/Pqgopa65jvknOjlk/w7ulPlZ78PiLQ==", + "dependencies": { + "@typescript-eslint/types": "7.1.1", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/eslint-config/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "packages/eslint-config/node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/array.prototype.tosorted": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.3.tgz", + "integrity": "sha512-/DdH4TiTmOKzyQbp/eadcCVexiCb36xJg7HshYOYJnNZFDj33GEv0P7GxsynpShhq4OLYJzbGcBDkLsDt7MnNg==", + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.1.0", + "es-shim-unscopables": "^1.0.2" + } + }, + "packages/eslint-config/node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "packages/eslint-config/node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/dotenv": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", + "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", + "engines": { + "node": ">=12" + } + }, + "packages/eslint-config/node_modules/es-abstract": { + "version": "1.22.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.5.tgz", + "integrity": "sha512-oW69R+4q2wG+Hc3KZePPZxOiisRIqfKBVo/HLx94QcJeWGU/8sZhCvc829rd1kS366vlJbzBfXf9yWwf0+Ko7w==", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "hasown": "^2.0.1", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.0", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.5", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/es-iterator-helpers": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.17.tgz", + "integrity": "sha512-lh7BsUqelv4KUbR5a/ZTaGGIMLCjPGPqJ6q+Oq24YP0RdyptX1uzm4vvaqzk7Zx3bpl/76YLTTDj9L7uYQ92oQ==", + "dependencies": { + "asynciterator.prototype": "^1.0.0", + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.4", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.2", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "globalthis": "^1.0.3", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "iterator.prototype": "^1.1.2", + "safe-array-concat": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "packages/eslint-config/node_modules/es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "dependencies": { + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "packages/eslint-config/node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/eslint-config/node_modules/eslint-config-next": { + "version": "14.1.3", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.1.3.tgz", + "integrity": "sha512-sUCpWlGuHpEhI0pIT0UtdSLJk5Z8E2DYinPTwsBiWaSYQomchdl0i60pjynY48+oXvtyWMQ7oE+G3m49yrfacg==", + "dependencies": { + "@next/eslint-plugin-next": "14.1.3", + "@rushstack/eslint-patch": "^1.3.3", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/eslint-config/node_modules/eslint-config-next/node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/eslint-config/node_modules/eslint-config-next/node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/eslint-config/node_modules/eslint-config-next/node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/eslint-config/node_modules/eslint-config-next/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/eslint-config/node_modules/eslint-config-next/node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/eslint-config/node_modules/eslint-config-next/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/eslint-config/node_modules/eslint-config-turbo": { + "version": "1.12.5", + "resolved": "https://registry.npmjs.org/eslint-config-turbo/-/eslint-config-turbo-1.12.5.tgz", + "integrity": "sha512-wXytbX+vTzQ6rwgM6sIr447tjYJBlRj5V/eBFNGNXw5Xs1R715ppPYhbmxaFbkrWNQSGJsWRrYGAlyq0sT/OsQ==", + "dependencies": { + "eslint-plugin-turbo": "1.12.5" + }, + "peerDependencies": { + "eslint": ">6.6.0" + } + }, "packages/eslint-config/node_modules/eslint-plugin-package-json": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-package-json/-/eslint-plugin-package-json-0.2.0.tgz", @@ -21618,6 +21987,436 @@ "eslint": ">=4.7.0" } }, + "packages/eslint-config/node_modules/eslint-plugin-react": { + "version": "7.34.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.34.0.tgz", + "integrity": "sha512-MeVXdReleBTdkz/bvcQMSnCXGi+c9kvy51IpinjnJgutl3YTHWsDdke7Z1ufZpGfDG8xduBDKyjtB9JH1eBKIQ==", + "dependencies": { + "array-includes": "^3.1.7", + "array.prototype.findlast": "^1.2.4", + "array.prototype.flatmap": "^1.3.2", + "array.prototype.toreversed": "^1.1.2", + "array.prototype.tosorted": "^1.1.3", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.0.17", + "estraverse": "^5.3.0", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.7", + "object.fromentries": "^2.0.7", + "object.hasown": "^1.1.3", + "object.values": "^1.1.7", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.10" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "packages/eslint-config/node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "packages/eslint-config/node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "packages/eslint-config/node_modules/eslint-plugin-turbo": { + "version": "1.12.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-turbo/-/eslint-plugin-turbo-1.12.5.tgz", + "integrity": "sha512-cXy7mCzAdngBTJIWH4DASXHy0vQpujWDBqRTu0YYqCN/QEGsi3HWM+STZEbPYELdjtm5EsN2HshOSSqWnjdRHg==", + "dependencies": { + "dotenv": "16.0.3" + }, + "peerDependencies": { + "eslint": ">6.6.0" + } + }, + "packages/eslint-config/node_modules/eslint-plugin-unused-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-3.1.0.tgz", + "integrity": "sha512-9l1YFCzXKkw1qtAru1RWUtG2EVDZY0a0eChKXcL+EZ5jitG7qxdctu4RnvhOJHv4xfmUf7h+JJPINlVpGhZMrw==", + "dependencies": { + "eslint-rule-composer": "^0.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "6 - 7", + "eslint": "8" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "packages/eslint-config/node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "dependencies": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/eslint-config/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "packages/eslint-config/node_modules/glob/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/eslint-config/node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/hasown": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", + "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "packages/eslint-config/node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "packages/eslint-config/node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "packages/eslint-config/node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/regexp.prototype.flags": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "dependencies": { + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/safe-array-concat": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.0.tgz", + "integrity": "sha512-ZdQ0Jeb9Ofti4hbt5lX3T2JcAamT9hfzYU1MNB+z/jaEbB6wfFfPIR/zEORmZqobkCCJhSjodobH6WHNmJ97dg==", + "dependencies": { + "call-bind": "^1.0.5", + "get-intrinsic": "^1.2.2", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/set-function-length": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", + "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", + "dependencies": { + "define-data-property": "^1.1.2", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "packages/eslint-config/node_modules/typed-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + } + }, + "packages/eslint-config/node_modules/typed-array-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/typed-array-byte-offset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/eslint-config/node_modules/typed-array-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.5.tgz", + "integrity": "sha512-yMi0PlwuznKHxKmcpoOdeLwxBoVPkqZxd7q2FgMkmD3bNwvF5VW0+UlUQ1k1vmktTu4Yu13Q0RIxEP8+B+wloA==", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "packages/eslint-config/node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -21630,6 +22429,24 @@ "node": ">=14.17" } }, + "packages/eslint-config/node_modules/which-typed-array": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.14.tgz", + "integrity": "sha512-VnXFiIW8yNn9kIHN88xvZ4yOWchftKDsRJ8fEPacX/wl1lOvBrhsJ/OeJCXq7B0AaijRuqgzSKalJoPk+D8MPg==", + "dependencies": { + "available-typed-arrays": "^1.0.6", + "call-bind": "^1.0.5", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "packages/lib": { "name": "@documenso/lib", "version": "1.0.0", diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index d519a3362..4d25e1bd8 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -7,16 +7,14 @@ "clean": "rimraf node_modules" }, "dependencies": { - "@typescript-eslint/eslint-plugin": "6.8.0", - "@typescript-eslint/parser": "6.8.0", - "eslint": "^8.40.0", - "eslint-config-next": "13.4.19", - "eslint-config-prettier": "^8.8.0", - "eslint-config-turbo": "^1.9.3", - "eslint-plugin-package-json": "^0.2.0", - "eslint-plugin-prettier": "^4.2.1", - "eslint-plugin-react": "^7.32.2", - "eslint-plugin-unused-imports": "^3.0.0", + "@typescript-eslint/eslint-plugin": "^7.1.1", + "@typescript-eslint/parser": "^7.1.1", + "eslint": "^8.57.0", + "eslint-config-next": "^14.1.3", + "eslint-config-turbo": "^1.12.5", + "eslint-plugin-package-json": "^0.10.4", + "eslint-plugin-react": "^7.34.0", + "eslint-plugin-unused-imports": "^3.1.0", "typescript": "5.2.2" } } From 62b4a13d4d908e97438496c7a50edd9fa4eb3c49 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 9 Mar 2024 00:32:08 +0530 Subject: [PATCH 103/299] feat: upgrade packages --- package-lock.json | 400 ++++++++++++++++++++++++++++++++++++---------- package.json | 4 +- 2 files changed, 316 insertions(+), 88 deletions(-) diff --git a/package-lock.json b/package-lock.json index aa4c258f4..b1124e033 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,8 @@ "dotenv-cli": "^7.3.0", "eslint": "^8.40.0", "eslint-config-custom": "*", - "husky": "^8.0.0", - "lint-staged": "^14.0.0", + "husky": "^9.0.11", + "lint-staged": "^15.2.2", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" @@ -7603,27 +7603,15 @@ } }, "node_modules/ansi-escapes": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-5.0.0.tgz", - "integrity": "sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.0.tgz", + "integrity": "sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==", "dev": true, "dependencies": { - "type-fest": "^1.0.2" + "type-fest": "^3.0.0" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "dev": true, - "engines": { - "node": ">=10" + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -8547,21 +8535,71 @@ } }, "node_modules/cli-truncate": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz", - "integrity": "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", "dev": true, "dependencies": { "slice-ansi": "^5.0.0", - "string-width": "^5.0.0" + "string-width": "^7.0.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "dev": true + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.1.0.tgz", + "integrity": "sha512-SEIJCWiX7Kg4c129n48aDRwLbFb2LJmXXFrWBG4NGaRtMQ3myKPKbwrD1BKqQn74oCoNMBVrfDEr5M9YxCsrkw==", + "dev": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -8973,9 +9011,9 @@ "integrity": "sha512-VtDvQpIJBvBatnONUsPzXYFVKQQAhuf3XTNOAsdBxCNO/QCtUUd8LSgjn0GVarBkCad6aJCZfXgrjYbl/KRr7w==" }, "node_modules/commander": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.0.0.tgz", - "integrity": "sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", "dev": true, "engines": { "node": ">=16" @@ -11580,6 +11618,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", + "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -12242,15 +12292,15 @@ } }, "node_modules/husky": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", - "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "version": "9.0.11", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.0.11.tgz", + "integrity": "sha512-AB6lFlbwwyIqMdHYhwPe+kjOC3Oc5P3nThEoW/AaO2BX3vJDjWPFxYLxokUZOo6RNX20He3AaT8sESs9NJcmEw==", "dev": true, "bin": { - "husky": "lib/bin.js" + "husky": "bin.mjs" }, "engines": { - "node": ">=14" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/typicode" @@ -13355,27 +13405,27 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, "node_modules/lint-staged": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-14.0.1.tgz", - "integrity": "sha512-Mw0cL6HXnHN1ag0mN/Dg4g6sr8uf8sn98w2Oc1ECtFto9tvRF7nkXGJRbx8gPlHyoR0pLyBr2lQHbWwmUHe1Sw==", + "version": "15.2.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.2.tgz", + "integrity": "sha512-TiTt93OPh1OZOsb5B7k96A/ATl2AjIZo+vnzFZ6oHK5FuTk63ByDtxGQpHm+kFETjEWqgkF95M8FRXKR/LEBcw==", "dev": true, "dependencies": { "chalk": "5.3.0", - "commander": "11.0.0", + "commander": "11.1.0", "debug": "4.3.4", - "execa": "7.2.0", - "lilconfig": "2.1.0", - "listr2": "6.6.1", + "execa": "8.0.1", + "lilconfig": "3.0.0", + "listr2": "8.0.1", "micromatch": "4.0.5", "pidtree": "0.6.0", "string-argv": "0.3.2", - "yaml": "2.3.1" + "yaml": "2.3.4" }, "bin": { "lint-staged": "bin/lint-staged.js" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": ">=18.12.0" }, "funding": { "url": "https://opencollective.com/lint-staged" @@ -13394,35 +13444,47 @@ } }, "node_modules/lint-staged/node_modules/execa": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", - "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", "dev": true, "dependencies": { "cross-spawn": "^7.0.3", - "get-stream": "^6.0.1", - "human-signals": "^4.3.0", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", - "signal-exit": "^3.0.7", + "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" }, "engines": { - "node": "^14.18.0 || ^16.14.0 || >=18.0.0" + "node": ">=16.17" }, "funding": { "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/lint-staged/node_modules/human-signals": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", - "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "node_modules/lint-staged/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true, "engines": { - "node": ">=14.18.0" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" } }, "node_modules/lint-staged/node_modules/is-stream": { @@ -13437,6 +13499,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lint-staged/node_modules/lilconfig": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz", + "integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/lint-staged/node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -13450,9 +13521,9 @@ } }, "node_modules/lint-staged/node_modules/npm-run-path": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", - "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", "dev": true, "dependencies": { "path-key": "^4.0.0" @@ -13491,6 +13562,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lint-staged/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/lint-staged/node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", @@ -13503,43 +13586,105 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lint-staged/node_modules/yaml": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", - "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", - "dev": true, - "engines": { - "node": ">= 14" - } - }, "node_modules/listenercount": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==" }, "node_modules/listr2": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-6.6.1.tgz", - "integrity": "sha512-+rAXGHh0fkEWdXBmX+L6mmfmXmXvDGEKzkjxO+8mP3+nI/r/CWznVBvsibXdxda9Zz0OW2e2ikphN3OwCT/jSg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.0.1.tgz", + "integrity": "sha512-ovJXBXkKGfq+CwmKTjluEqFi3p4h8xvkxGQQAQan22YCgef4KZ1mKGjzfGh6PL6AW5Csw0QiQPNuQyH+6Xk3hA==", "dev": true, "dependencies": { - "cli-truncate": "^3.1.0", + "cli-truncate": "^4.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", - "log-update": "^5.0.1", + "log-update": "^6.0.0", "rfdc": "^1.3.0", - "wrap-ansi": "^8.1.0" + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" }, - "peerDependencies": { - "enquirer": ">= 2.3.0 < 3" + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" }, - "peerDependenciesMeta": { - "enquirer": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "dev": true + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.1.0.tgz", + "integrity": "sha512-SEIJCWiX7Kg4c129n48aDRwLbFb2LJmXXFrWBG4NGaRtMQ3myKPKbwrD1BKqQn74oCoNMBVrfDEr5M9YxCsrkw==", + "dev": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/load-tsconfig": { @@ -13661,19 +13806,19 @@ } }, "node_modules/log-update": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-5.0.1.tgz", - "integrity": "sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.0.0.tgz", + "integrity": "sha512-niTvB4gqvtof056rRIrTZvjNYE4rCUzO6X/X+kYjd7WFxXeJ0NwEFnRxX6ehkvv3jTwrXnNdtAak5XYZuIyPFw==", "dev": true, "dependencies": { - "ansi-escapes": "^5.0.0", + "ansi-escapes": "^6.2.0", "cli-cursor": "^4.0.0", - "slice-ansi": "^5.0.0", - "strip-ansi": "^7.0.1", - "wrap-ansi": "^8.0.1" + "slice-ansi": "^7.0.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -13691,6 +13836,72 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "dev": true + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, + "dependencies": { + "get-east-asian-width": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.1.0.tgz", + "integrity": "sha512-SEIJCWiX7Kg4c129n48aDRwLbFb2LJmXXFrWBG4NGaRtMQ3myKPKbwrD1BKqQn74oCoNMBVrfDEr5M9YxCsrkw==", + "dev": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/log-update/node_modules/strip-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", @@ -13706,6 +13917,23 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/long": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", diff --git a/package.json b/package.json index cbaa2a1eb..9e1797cb8 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,8 @@ "dotenv-cli": "^7.3.0", "eslint": "^8.40.0", "eslint-config-custom": "*", - "husky": "^8.0.0", - "lint-staged": "^14.0.0", + "husky": "^9.0.11", + "lint-staged": "^15.2.2", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" From 415f79f82184ab21e6cbcc64cdd5541e3ccc3145 Mon Sep 17 00:00:00 2001 From: Mythie Date: Sun, 10 Mar 2024 11:13:05 +1100 Subject: [PATCH 104/299] fix: update docker docs and compose files --- .env.example | 10 ++++++++++ docker/README.md | 16 +++++++++++++++- docker/production/compose.yml | 3 +++ docker/testing/compose.yml | 2 ++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index c482c128e..20e1ae2ae 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,16 @@ E2E_TEST_AUTHENTICATE_USERNAME="Test User" E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com" E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123" +# [[SIGNING]] +# OPTIONAL: Defines the signing transport to use. Available options: local (default) +NEXT_PRIVATE_SIGNING_TRANSPORT="local" +# OPTIONAL: Defines the passphrase for the signing certificate. +NEXT_PRIVATE_SIGNING_PASSPHRASE= +# OPTIONAL: Defines the file contents for the signing certificate as a base64 encoded string. +NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS= +# OPTIONAL: Defines the file path for the signing certificate. defaults to ./example/cert.p12 +NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH= + # [[STORAGE]] # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 NEXT_PUBLIC_UPLOAD_TRANSPORT="database" diff --git a/docker/README.md b/docker/README.md index ba942ac1c..bda1638a2 100644 --- a/docker/README.md +++ b/docker/README.md @@ -29,7 +29,16 @@ NEXT_PRIVATE_SMTP_USERNAME="" NEXT_PRIVATE_SMTP_PASSWORD="" ``` -4. Run the following command to start the containers: +4. Update the volume binding for the cert file in the `compose.yml` file to point to your own key file: + +Since the `cert.p12` file is required for signing and encrypting documents, you will need to provide your own key file. Update the volume binding in the `compose.yml` file to point to your key file: + +```yaml +volumes: + - /path/to/your/keyfile.p12:/opt/documenso/cert.p12 +``` + +1. Run the following command to start the containers: ``` docker-compose --env-file ./.env -d up @@ -70,6 +79,7 @@ docker run -d \ -e NEXT_PRIVATE_SMTP_TRANSPORT="" -e NEXT_PRIVATE_SMTP_FROM_NAME="" -e NEXT_PRIVATE_SMTP_FROM_ADDRESS="" + -v /path/to/your/keyfile.p12:/opt/documenso/cert.p12 documenso/documenso ``` @@ -99,6 +109,10 @@ Here's a markdown table documenting all the provided environment variables: | `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. | | `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). | | `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). | +| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) | +| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. | +| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file, will be used instead of file path. | +| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. | | `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). | | `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). | | `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). | diff --git a/docker/production/compose.yml b/docker/production/compose.yml index 08abcf050..02acc655d 100644 --- a/docker/production/compose.yml +++ b/docker/production/compose.yml @@ -57,8 +57,11 @@ services: - NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT} - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY} - NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP} + - NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH?:-/opt/documenso/cert.p12} ports: - ${PORT:-3000}:${PORT:-3000} + volumes: + - /opt/documenso/cert.p12:/opt/documenso/cert.p12 volumes: database: diff --git a/docker/testing/compose.yml b/docker/testing/compose.yml index cecb5bf14..de4a71fea 100644 --- a/docker/testing/compose.yml +++ b/docker/testing/compose.yml @@ -49,3 +49,5 @@ services: - NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@documenso.com ports: - 3000:3000 + volumes: + - ../../apps/web/example/cert.p12:/opt/documenso/cert.p12 From 608e622f69a7520a1d70941b2f47547e3a384634 Mon Sep 17 00:00:00 2001 From: Mythie Date: Sun, 10 Mar 2024 13:48:09 +1100 Subject: [PATCH 105/299] fix: update testing compose config --- docker/testing/compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/testing/compose.yml b/docker/testing/compose.yml index de4a71fea..28ec055c1 100644 --- a/docker/testing/compose.yml +++ b/docker/testing/compose.yml @@ -47,6 +47,7 @@ services: - NEXT_PRIVATE_SMTP_PASSWORD=password - NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso" - NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@documenso.com + - NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12 ports: - 3000:3000 volumes: From 9e1b2e5cc3ca51e96fee68578675ee9e483a23c5 Mon Sep 17 00:00:00 2001 From: Mythie Date: Sun, 10 Mar 2024 13:48:25 +1100 Subject: [PATCH 106/299] fix: update sharp dependency --- apps/marketing/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 121 ++++++++++++------------------------ 3 files changed, 43 insertions(+), 82 deletions(-) diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 1cfb7337f..f6af3a9ff 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -36,7 +36,7 @@ "react-hook-form": "^7.43.9", "react-icons": "^4.11.0", "recharts": "^2.7.2", - "sharp": "0.33.1", + "sharp": "^0.33.1", "typescript": "5.2.2", "zod": "^3.22.4" }, diff --git a/apps/web/package.json b/apps/web/package.json index 41caec804..e72f4898a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -43,7 +43,7 @@ "react-icons": "^4.11.0", "react-rnd": "^10.4.1", "remeda": "^1.27.1", - "sharp": "0.33.1", + "sharp": "^0.33.1", "ts-pattern": "^5.0.5", "ua-parser-js": "^1.0.37", "uqr": "^0.1.2", diff --git a/package-lock.json b/package-lock.json index 72d06b98a..d7615e179 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "react-hook-form": "^7.43.9", "react-icons": "^4.11.0", "recharts": "^2.7.2", - "sharp": "0.33.1", + "sharp": "^0.33.1", "typescript": "5.2.2", "zod": "^3.22.4" }, @@ -74,45 +74,6 @@ "integrity": "sha512-O+z53uwx64xY7D6roOi4+jApDGFg0qn6WHcxe5QeqjMaTezBO/mxdfFXIVAVVyNWKx84OmPB3L8kbVYOTeN34A==", "dev": true }, - "apps/marketing/node_modules/sharp": { - "version": "0.33.1", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.1.tgz", - "integrity": "sha512-iAYUnOdTqqZDb3QjMneBKINTllCJDZ3em6WaWy7NPECM4aHncvqHRm0v0bN9nqJxMiwamv5KIdauJ6lUzKDpTQ==", - "hasInstallScript": true, - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.2", - "semver": "^7.5.4" - }, - "engines": { - "libvips": ">=8.15.0", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.1", - "@img/sharp-darwin-x64": "0.33.1", - "@img/sharp-libvips-darwin-arm64": "1.0.0", - "@img/sharp-libvips-darwin-x64": "1.0.0", - "@img/sharp-libvips-linux-arm": "1.0.0", - "@img/sharp-libvips-linux-arm64": "1.0.0", - "@img/sharp-libvips-linux-s390x": "1.0.0", - "@img/sharp-libvips-linux-x64": "1.0.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.0", - "@img/sharp-libvips-linuxmusl-x64": "1.0.0", - "@img/sharp-linux-arm": "0.33.1", - "@img/sharp-linux-arm64": "0.33.1", - "@img/sharp-linux-s390x": "0.33.1", - "@img/sharp-linux-x64": "0.33.1", - "@img/sharp-linuxmusl-arm64": "0.33.1", - "@img/sharp-linuxmusl-x64": "0.33.1", - "@img/sharp-wasm32": "0.33.1", - "@img/sharp-win32-ia32": "0.33.1", - "@img/sharp-win32-x64": "0.33.1" - } - }, "apps/marketing/node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -159,7 +120,7 @@ "react-icons": "^4.11.0", "react-rnd": "^10.4.1", "remeda": "^1.27.1", - "sharp": "0.33.1", + "sharp": "^0.33.1", "ts-pattern": "^5.0.5", "ua-parser-js": "^1.0.37", "uqr": "^0.1.2", @@ -182,45 +143,6 @@ "integrity": "sha512-O+z53uwx64xY7D6roOi4+jApDGFg0qn6WHcxe5QeqjMaTezBO/mxdfFXIVAVVyNWKx84OmPB3L8kbVYOTeN34A==", "dev": true }, - "apps/web/node_modules/sharp": { - "version": "0.33.1", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.1.tgz", - "integrity": "sha512-iAYUnOdTqqZDb3QjMneBKINTllCJDZ3em6WaWy7NPECM4aHncvqHRm0v0bN9nqJxMiwamv5KIdauJ6lUzKDpTQ==", - "hasInstallScript": true, - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.2", - "semver": "^7.5.4" - }, - "engines": { - "libvips": ">=8.15.0", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.1", - "@img/sharp-darwin-x64": "0.33.1", - "@img/sharp-libvips-darwin-arm64": "1.0.0", - "@img/sharp-libvips-darwin-x64": "1.0.0", - "@img/sharp-libvips-linux-arm": "1.0.0", - "@img/sharp-libvips-linux-arm64": "1.0.0", - "@img/sharp-libvips-linux-s390x": "1.0.0", - "@img/sharp-libvips-linux-x64": "1.0.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.0", - "@img/sharp-libvips-linuxmusl-x64": "1.0.0", - "@img/sharp-linux-arm": "0.33.1", - "@img/sharp-linux-arm64": "0.33.1", - "@img/sharp-linux-s390x": "0.33.1", - "@img/sharp-linux-x64": "0.33.1", - "@img/sharp-linuxmusl-arm64": "0.33.1", - "@img/sharp-linuxmusl-x64": "0.33.1", - "@img/sharp-wasm32": "0.33.1", - "@img/sharp-win32-ia32": "0.33.1", - "@img/sharp-win32-x64": "0.33.1" - } - }, "apps/web/node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -18560,6 +18482,45 @@ "sha.js": "bin.js" } }, + "node_modules/sharp": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.1.tgz", + "integrity": "sha512-iAYUnOdTqqZDb3QjMneBKINTllCJDZ3em6WaWy7NPECM4aHncvqHRm0v0bN9nqJxMiwamv5KIdauJ6lUzKDpTQ==", + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "semver": "^7.5.4" + }, + "engines": { + "libvips": ">=8.15.0", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.1", + "@img/sharp-darwin-x64": "0.33.1", + "@img/sharp-libvips-darwin-arm64": "1.0.0", + "@img/sharp-libvips-darwin-x64": "1.0.0", + "@img/sharp-libvips-linux-arm": "1.0.0", + "@img/sharp-libvips-linux-arm64": "1.0.0", + "@img/sharp-libvips-linux-s390x": "1.0.0", + "@img/sharp-libvips-linux-x64": "1.0.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.0", + "@img/sharp-libvips-linuxmusl-x64": "1.0.0", + "@img/sharp-linux-arm": "0.33.1", + "@img/sharp-linux-arm64": "0.33.1", + "@img/sharp-linux-s390x": "0.33.1", + "@img/sharp-linux-x64": "0.33.1", + "@img/sharp-linuxmusl-arm64": "0.33.1", + "@img/sharp-linuxmusl-x64": "0.33.1", + "@img/sharp-wasm32": "0.33.1", + "@img/sharp-win32-ia32": "0.33.1", + "@img/sharp-win32-x64": "0.33.1" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", From f646fa29d7c1c9240810e50d9d8e72e255958be3 Mon Sep 17 00:00:00 2001 From: Brayden Brayden Date: Sun, 10 Mar 2024 07:01:18 +0000 Subject: [PATCH 107/299] fix: ensure password input is cleared when 2fa enable dialog is closed --- .../forms/2fa/enable-authenticator-app-dialog.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 27560c073..e3120141a 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 @@ -197,7 +197,14 @@ export const EnableAuthenticatorAppDialog = ({ /> - From 6f958b9320a389f92fc9c16dc7244d666752db4f Mon Sep 17 00:00:00 2001 From: Brayden Brayden Date: Sun, 10 Mar 2024 09:28:08 +0000 Subject: [PATCH 108/299] fix: update the dialog cancel to reset --- .../forms/2fa/enable-authenticator-app-dialog.tsx | 2 +- .../components/forms/2fa/view-recovery-codes-dialog.tsx | 9 ++++++++- 2 files changed, 9 insertions(+), 2 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 e3120141a..487ebfc32 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 @@ -202,7 +202,7 @@ export const EnableAuthenticatorAppDialog = ({ variant="secondary" onClick={() => { onOpenChange(false); - setupTwoFactorAuthenticationForm.setValue('password', ''); + setupTwoFactorAuthenticationForm.reset(); }} > Cancel diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx index 376a8939c..649cbd11c 100644 --- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -138,7 +138,14 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode /> - From afe99e5ec90dd1cc781280c2ad2b702be24e13fc Mon Sep 17 00:00:00 2001 From: Brayden Brayden Date: Sun, 10 Mar 2024 09:36:54 +0000 Subject: [PATCH 109/299] fix: revert reset changes, reset on open state change instead --- .../2fa/enable-authenticator-app-dialog.tsx | 16 +++++++--------- .../forms/2fa/view-recovery-codes-dialog.tsx | 16 +++++++--------- 2 files changed, 14 insertions(+), 18 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 487ebfc32..cb4cd60e1 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 { useEffect, useMemo } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; @@ -149,6 +149,11 @@ export const EnableAuthenticatorAppDialog = ({ } }; + useEffect(() => { + // Reset the form when the Dialog open state changes + setupTwoFactorAuthenticationForm.reset(); + }, [open, setupTwoFactorAuthenticationForm]); + return ( @@ -197,14 +202,7 @@ export const EnableAuthenticatorAppDialog = ({ /> - diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx index 649cbd11c..2a83e6499 100644 --- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; @@ -92,6 +92,11 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode } }; + useEffect(() => { + // Reset the form when the Dialog open state changes + viewRecoveryCodesForm.reset(); + }, [open, viewRecoveryCodesForm]); + return ( @@ -138,14 +143,7 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode /> - From c744482b844e8d8068489267b9256b3e321cf495 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 11 Mar 2024 10:55:46 +1100 Subject: [PATCH 110/299] fix: add conditional to useEffects --- .../forms/2fa/enable-authenticator-app-dialog.tsx | 6 ++++-- .../src/components/forms/2fa/view-recovery-codes-dialog.tsx | 6 ++++-- 2 files changed, 8 insertions(+), 4 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 cb4cd60e1..d7a8f6553 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 @@ -150,8 +150,10 @@ export const EnableAuthenticatorAppDialog = ({ }; useEffect(() => { - // Reset the form when the Dialog open state changes - setupTwoFactorAuthenticationForm.reset(); + // Reset the form when the Dialog closes + if (!open) { + setupTwoFactorAuthenticationForm.reset(); + } }, [open, setupTwoFactorAuthenticationForm]); return ( diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx index 2a83e6499..48e343e8d 100644 --- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -93,8 +93,10 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode }; useEffect(() => { - // Reset the form when the Dialog open state changes - viewRecoveryCodesForm.reset(); + // Reset the form when the Dialog closes + if (!open) { + viewRecoveryCodesForm.reset(); + } }, [open, viewRecoveryCodesForm]); return ( From 7f31ab1ce39f6fa8a150b0484d8f9c85dbee8ca0 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 11 Mar 2024 00:52:56 +0000 Subject: [PATCH 111/299] fix: add scrollbar gutter property --- packages/ui/primitives/data-table.tsx | 2 +- packages/ui/styles/theme.css | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/ui/primitives/data-table.tsx b/packages/ui/primitives/data-table.tsx index 55895e08f..0d8556d89 100644 --- a/packages/ui/primitives/data-table.tsx +++ b/packages/ui/primitives/data-table.tsx @@ -118,7 +118,7 @@ export function DataTable({ {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css index 8e488ad95..70a06ad15 100644 --- a/packages/ui/styles/theme.css +++ b/packages/ui/styles/theme.css @@ -81,7 +81,7 @@ --ring: 95.08 71.08% 67.45%; --radius: 0.5rem; - + --warning: 54 96% 45%; } } @@ -91,6 +91,11 @@ @apply border-border; } + html, + body { + scrollbar-gutter: stable; + } + body { @apply bg-background text-foreground; font-feature-settings: 'rlig' 1, 'calt' 1; From c2cf25b13835fe8260444a34c0337d8d775343eb Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 11 Mar 2024 01:46:19 +0000 Subject: [PATCH 112/299] fix: templates incorrectly linking when in a team --- .../templates/data-table-templates.tsx | 6 +++++- .../(dashboard)/templates/data-table-title.tsx | 18 +++++++++++++++--- .../lib/server-only/template/find-templates.ts | 6 ++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx index e878d8df2..cb00ef6f6 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -25,7 +25,11 @@ type TemplateWithRecipient = Template & { }; type TemplatesDataTableProps = { - templates: TemplateWithRecipient[]; + templates: Array< + TemplateWithRecipient & { + team: { id: number; url: string } | null; + } + >; perPage: number; page: number; totalPages: number; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-title.tsx b/apps/web/src/app/(dashboard)/templates/data-table-title.tsx index 31e1011be..69855ca1e 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-title.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-title.tsx @@ -1,23 +1,35 @@ +'use client'; + import Link from 'next/link'; import { useSession } from 'next-auth/react'; -import { Template } from '@documenso/prisma/client'; +import { formatTemplatesPath } from '@documenso/lib/utils/teams'; +import type { Template } from '@documenso/prisma/client'; + +import { useOptionalCurrentTeam } from '~/providers/team'; export type DataTableTitleProps = { - row: Template; + row: Template & { + team: { id: number; url: string } | null; + }; }; export const DataTableTitle = ({ row }: DataTableTitleProps) => { const { data: session } = useSession(); + const team = useOptionalCurrentTeam(); if (!session) { return null; } + const isCurrentTeamTemplate = team?.url && row.team?.url === team?.url; + + const templatesPath = formatTemplatesPath(isCurrentTeamTemplate ? team?.url : undefined); + return ( {row.title} diff --git a/packages/lib/server-only/template/find-templates.ts b/packages/lib/server-only/template/find-templates.ts index 69b43f9b9..9252d32ea 100644 --- a/packages/lib/server-only/template/find-templates.ts +++ b/packages/lib/server-only/template/find-templates.ts @@ -37,6 +37,12 @@ export const findTemplates = async ({ where: whereFilter, include: { templateDocumentData: true, + team: { + select: { + id: true, + url: true, + }, + }, Field: true, Recipient: true, }, From bbcb90d8a5c08c5337d2ae774b277cb4d9f3fff4 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 11 Mar 2024 15:00:58 +0530 Subject: [PATCH 113/299] chore: updated url regex Signed-off-by: Adithya Krishna --- packages/lib/constants/url-regex.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lib/constants/url-regex.ts b/packages/lib/constants/url-regex.ts index 259ce070d..1dfb70ad3 100644 --- a/packages/lib/constants/url-regex.ts +++ b/packages/lib/constants/url-regex.ts @@ -1,2 +1,2 @@ export const URL_REGEX = - /^(https?):\/\/(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z0-9()]{2,}(?:\/[a-zA-Z0-9-._?&=/]*)?$/i; + /^(https?):\/\/(?:www\.)?(?:[a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.[a-zA-Z0-9()]{2,}(?:\/[a-zA-Z0-9-._?&=/]*)?$/i; From efb90ca5fb6748c6aa409516c2e6a669b495f220 Mon Sep 17 00:00:00 2001 From: Gautam-Hegde Date: Mon, 11 Mar 2024 23:17:11 +0530 Subject: [PATCH 114/299] chore: use email confirmation --- .../settings/profile/delete-account-dialog.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx b/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx index 2cb60f4a7..72a61d5ed 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx @@ -30,9 +30,9 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp const { toast } = useToast(); const hasTwoFactorAuthentication = user.twoFactorEnabled; - const username = user.name!; + const userEmail = user.email; - const [enteredUsername, setEnteredUsername] = useState(''); + const [enteredEmail, setEnteredEmail] = useState(''); const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } = trpc.profile.deleteAccount.useMutation(); @@ -82,7 +82,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
- setEnteredUsername('')}> + setEnteredEmail('')}> @@ -111,7 +111,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp {!hasTwoFactorAuthentication && ( - Please type {username} to confirm. + Please type {userEmail} to confirm. )} @@ -120,9 +120,8 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
setEnteredUsername(e.target.value)} - onPaste={(e) => e.preventDefault()} + value={enteredEmail} + onChange={(e) => setEnteredEmail(e.target.value)} />
)} @@ -133,7 +132,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp onClick={onDeleteAccount} loading={isDeletingAccount} variant="outline" - disabled={hasTwoFactorAuthentication || enteredUsername !== username} + disabled={hasTwoFactorAuthentication || enteredEmail !== userEmail} > {isDeletingAccount ? ( 'Deleting account...' From f6c2b6c1c5a439199585198b4fce2245483f9e58 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 12 Mar 2024 01:52:16 +0000 Subject: [PATCH 115/299] fix: minor updates --- .../src/app/(marketing)/blog/[post]/page.tsx | 5 +++-- apps/marketing/src/app/(marketing)/open/page.tsx | 5 +++-- .../(marketing)/{CTA.tsx => call-to-action.tsx} | 13 +++++++++---- 3 files changed, 15 insertions(+), 8 deletions(-) rename apps/marketing/src/components/(marketing)/{CTA.tsx => call-to-action.tsx} (77%) diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx index 917045e5a..bd5fdb2da 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx @@ -7,7 +7,7 @@ import { ChevronLeft } from 'lucide-react'; import type { MDXComponents } from 'mdx/types'; import { useMDXComponent } from 'next-contentlayer/hooks'; -import CTA from '~/components/(marketing)/CTA'; +import { CallToAction } from '~/components/(marketing)/call-to-action'; export const dynamic = 'force-dynamic'; @@ -93,7 +93,8 @@ export default function BlogPostPage({ params }: { params: { post: string } }) { Back to all posts - {post.cta && } + + {post.cta && }
); } diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index 842d91ff8..10ab71aa7 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -7,7 +7,7 @@ import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-m 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 CTA from '~/components/(marketing)/CTA'; +import { CallToAction } from '~/components/(marketing)/call-to-action'; import { BarMetric } from './bar-metrics'; import { CapTable } from './cap-table'; @@ -252,7 +252,8 @@ export default async function OpenPage() {
- + +
); } diff --git a/apps/marketing/src/components/(marketing)/CTA.tsx b/apps/marketing/src/components/(marketing)/call-to-action.tsx similarity index 77% rename from apps/marketing/src/components/(marketing)/CTA.tsx rename to apps/marketing/src/components/(marketing)/call-to-action.tsx index dee5e5fae..3d1f51b23 100644 --- a/apps/marketing/src/components/(marketing)/CTA.tsx +++ b/apps/marketing/src/components/(marketing)/call-to-action.tsx @@ -4,9 +4,14 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; -export default function CTA() { +type CallToActionProps = { + className?: string; + utmSource?: string; +}; + +export const CallToAction = ({ className, utmSource = 'generic-cta' }: CallToActionProps) => { return ( - +

Join the Open Signing Movement

@@ -16,11 +21,11 @@ export default function CTA() {

); -} +}; From d3f4e20f1cb76da91a68590e6de3880cd923fb2a Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 12 Mar 2024 02:57:22 +0000 Subject: [PATCH 116/299] fix: update styling and e2e test --- .../profile/delete-account-dialog.tsx | 44 ++++++++----------- .../app-tests/e2e/test-delete-user.spec.ts | 2 + 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx b/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx index 72a61d5ed..2e1087380 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx @@ -19,6 +19,7 @@ import { DialogTrigger, } from '@documenso/ui/primitives/dialog'; import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type DeleteAccountDialogProps = { @@ -30,7 +31,6 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp const { toast } = useToast(); const hasTwoFactorAuthentication = user.twoFactorEnabled; - const userEmail = user.email; const [enteredEmail, setEnteredEmail] = useState(''); @@ -86,6 +86,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp + Delete Account @@ -109,43 +110,34 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp , along with all of your completed documents, signatures, and all other resources belonging to your Account. - {!hasTwoFactorAuthentication && ( - - Please type {userEmail} to confirm. - - )} {!hasTwoFactorAuthentication && (
+ + setEnteredEmail(e.target.value)} />
)} - {!hasTwoFactorAuthentication && ( - - )} +
diff --git a/packages/app-tests/e2e/test-delete-user.spec.ts b/packages/app-tests/e2e/test-delete-user.spec.ts index beae6eb09..6eb72bad9 100644 --- a/packages/app-tests/e2e/test-delete-user.spec.ts +++ b/packages/app-tests/e2e/test-delete-user.spec.ts @@ -16,6 +16,8 @@ test('delete user', async ({ page }) => { }); await page.getByRole('button', { name: 'Delete Account' }).click(); + await page.getByLabel('Confirm Email').fill(user.email); + await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled(); await page.getByRole('button', { name: 'Confirm Deletion' }).click(); await page.waitForURL(`${WEBAPP_BASE_URL}/signin`); From d0b9cee500b8262d1991a522440f482b498b09d5 Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Tue, 12 Mar 2024 18:55:59 +0530 Subject: [PATCH 117/299] 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 118/299] 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 119/299] 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 120/299] 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 121/299] 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 122/299] 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 123/299] 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 27a69819f9b925119cac61518a0c5bb761593512 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Wed, 13 Mar 2024 09:49:31 +0530 Subject: [PATCH 124/299] feat: added custom styling for swagger ui Signed-off-by: Adithya Krishna --- packages/api/v1/api-documentation.css | 101 ++++++++++++++++++++++++++ packages/api/v1/api-documentation.tsx | 4 + 2 files changed, 105 insertions(+) create mode 100644 packages/api/v1/api-documentation.css diff --git a/packages/api/v1/api-documentation.css b/packages/api/v1/api-documentation.css new file mode 100644 index 000000000..6e2884cbb --- /dev/null +++ b/packages/api/v1/api-documentation.css @@ -0,0 +1,101 @@ +#swagger-ui.api-platform .wrapper { + padding: 0 60px; +} + +#swagger-ui.api-platform .information-container.wrapper { + margin: 0 0 30px; + padding: 10px 0 0; + width: 100%; + max-width: 100%; + background-color: white; + border-bottom: 1px solid #ccc; +} + +#swagger-ui.api-platform .info, #formats { + width: 100%; + max-width: 1460px; + padding: 0 50px; + margin: 0 auto; +} + +#swagger-ui.api-platform .opblock .opblock-summary-method, +#swagger-ui.api-platform .btn.execute { + transition: all ease 0.3s; + background-color: #3CAAB5; + border-color: #3CAAB5; +} + +#swagger-ui.api-platform .opblock .opblock-summary-method:hover, +#swagger-ui.api-platform .btn.execute:hover { + background-color: #288690; + border-color: #288690; +} + +#swagger-ui.api-platform .opblock-summary { + padding: 0; +} + +#swagger-ui.api-platform .opblock-tag:hover { + background-color: rgba(0, 0, 0, .1); + transform: scale(1.01); +} + +#swagger-ui.api-platform .opblock.opblock-get, +#swagger-ui.api-platform .opblock-section-header, +#swagger-ui.api-platform .opblock-summary { + background-color: rgba(60, 170, 181, 0.1); + border-color: #3CAAB5; +} + +#swagger-ui.api-platform .opblock-summary-method { + border-radius: 0; + padding: 10px; +} + +#swagger-ui.api-platform .opblock-tag, .swagger-ui .table-container tr th, +.swagger-ui .table-container tr td { + padding: 5px 0; + margin: 0 0 10px; +} + +#swagger-ui.api-platform .btn-group .btn, #swagger-ui.api-platform .execute-wrapper .btn { + padding: 10px 40px; +} + +#swagger-ui.api-platform .btn { + transition: all ease 0.2s; + box-shadow: none; + background-color: #f7f7f7; +} + +#swagger-ui.api-platform .btn:hover, #swagger-ui.api-platform .btn.cancel:hover, +#swagger-ui.api-platform .btn.authorize:hover { + background-color: rgba(65, 68, 78, 0.1); + border-color: transparent; +} + +#swagger-ui.api-platform select, #swagger-ui.api-platform input:focus, +#swagger-ui.api-platform select:focus, #swagger-ui.api-platform textarea:focus, +#swagger-ui.api-platform button:focus { + box-shadow: none; + cursor: pointer; + outline: none; +} + +.swagger-ui .markdown p, .swagger-ui .markdown pre, +.swagger-ui .renderedMarkdown p, .swagger-ui .renderedMarkdown pre { + margin: 0; +} + +::placeholder, :-moz-placeholder, ::-moz-placeholder, :-ms-input-placeholder { + color: #fff; +} + +:disabled::placeholder, :disabled:-moz-placeholder, +:disabled::-moz-placeholder, :disabled:-ms-input-placeholder { + color: #fafafa; +} + +#swagger-ui .topbar { + display: none; +} diff --git a/packages/api/v1/api-documentation.tsx b/packages/api/v1/api-documentation.tsx index fe394e603..0ecdc4b39 100644 --- a/packages/api/v1/api-documentation.tsx +++ b/packages/api/v1/api-documentation.tsx @@ -5,6 +5,10 @@ import 'swagger-ui-react/swagger-ui.css'; import { OpenAPIV1 } from '@documenso/api/v1/openapi'; +// Custom CSS for the Swagger UI +// eslint-disable-next-line prettier/prettier +import './api-documentation.css'; + export const OpenApiDocsPage = () => { return ; }; From 52afae331e6dfaf907894aff446d8f732bb04a2c Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Wed, 13 Mar 2024 09:50:37 +0530 Subject: [PATCH 125/299] chore: updated to send email to doc owners Signed-off-by: Adithya Krishna --- .../server-only/document/send-completed-email.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/lib/server-only/document/send-completed-email.ts b/packages/lib/server-only/document/send-completed-email.ts index 812e54ba3..2ef1da851 100644 --- a/packages/lib/server-only/document/send-completed-email.ts +++ b/packages/lib/server-only/document/send-completed-email.ts @@ -10,6 +10,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { getFile } from '../../universal/upload/get-file'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; +import { getUserById } from '../user/get-user-by-id'; export interface SendDocumentOptions { documentId: number; @@ -40,6 +41,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo await Promise.all( document.Recipient.map(async (recipient) => { const { email, name, token } = recipient; + const user = await getUserById({ id: document.userId }); const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; @@ -52,10 +54,16 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo await prisma.$transaction( async (tx) => { await mailer.sendMail({ - to: { - address: email, - name, - }, + to: [ + { + address: email, + name, + }, + { + address: user.email, + name: user.name!, + }, + ], from: { name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso', address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com', From 3fb57c877ef0f71910f9b86e22641c1334f778f0 Mon Sep 17 00:00:00 2001 From: Rohit Saluja Date: Wed, 13 Mar 2024 10:54:53 +0530 Subject: [PATCH 126/299] 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 127/299] 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 128/299] 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 129/299] 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 130/299] 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 131/299] 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 e5497efe7c9f74f93e10557fda22aa3cd67c611f Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Wed, 13 Mar 2024 12:38:56 +0530 Subject: [PATCH 132/299] chore: updated dark mode styling Signed-off-by: Adithya Krishna --- packages/api/v1/api-documentation.css | 110 +++----------------------- packages/api/v1/api-documentation.tsx | 2 +- 2 files changed, 10 insertions(+), 102 deletions(-) diff --git a/packages/api/v1/api-documentation.css b/packages/api/v1/api-documentation.css index 6e2884cbb..9edb85748 100644 --- a/packages/api/v1/api-documentation.css +++ b/packages/api/v1/api-documentation.css @@ -1,101 +1,9 @@ -#swagger-ui.api-platform .wrapper { - padding: 0 60px; -} - -#swagger-ui.api-platform .information-container.wrapper { - margin: 0 0 30px; - padding: 10px 0 0; - width: 100%; - max-width: 100%; - background-color: white; - border-bottom: 1px solid #ccc; -} - -#swagger-ui.api-platform .info, #formats { - width: 100%; - max-width: 1460px; - padding: 0 50px; - margin: 0 auto; -} - -#swagger-ui.api-platform .opblock .opblock-summary-method, -#swagger-ui.api-platform .btn.execute { - transition: all ease 0.3s; - background-color: #3CAAB5; - border-color: #3CAAB5; -} - -#swagger-ui.api-platform .opblock .opblock-summary-method:hover, -#swagger-ui.api-platform .btn.execute:hover { - background-color: #288690; - border-color: #288690; -} - -#swagger-ui.api-platform .opblock-summary { - padding: 0; -} - -#swagger-ui.api-platform .opblock-tag:hover { - background-color: rgba(0, 0, 0, .1); - transform: scale(1.01); -} - -#swagger-ui.api-platform .opblock.opblock-get, -#swagger-ui.api-platform .opblock-section-header, -#swagger-ui.api-platform .opblock-summary { - background-color: rgba(60, 170, 181, 0.1); - border-color: #3CAAB5; -} - -#swagger-ui.api-platform .opblock-summary-method { - border-radius: 0; - padding: 10px; -} - -#swagger-ui.api-platform .opblock-tag, .swagger-ui .table-container tr th, -.swagger-ui .table-container tr td { - padding: 5px 0; - margin: 0 0 10px; -} - -#swagger-ui.api-platform .btn-group .btn, #swagger-ui.api-platform .execute-wrapper .btn { - padding: 10px 40px; -} - -#swagger-ui.api-platform .btn { - transition: all ease 0.2s; - box-shadow: none; - background-color: #f7f7f7; -} - -#swagger-ui.api-platform .btn:hover, #swagger-ui.api-platform .btn.cancel:hover, -#swagger-ui.api-platform .btn.authorize:hover { - background-color: rgba(65, 68, 78, 0.1); - border-color: transparent; -} - -#swagger-ui.api-platform select, #swagger-ui.api-platform input:focus, -#swagger-ui.api-platform select:focus, #swagger-ui.api-platform textarea:focus, -#swagger-ui.api-platform button:focus { - box-shadow: none; - cursor: pointer; - outline: none; -} - -.swagger-ui .markdown p, .swagger-ui .markdown pre, -.swagger-ui .renderedMarkdown p, .swagger-ui .renderedMarkdown pre { - margin: 0; -} - -::placeholder, :-moz-placeholder, ::-moz-placeholder, :-ms-input-placeholder { - color: #fff; -} - -:disabled::placeholder, :disabled:-moz-placeholder, -:disabled::-moz-placeholder, :disabled:-ms-input-placeholder { - color: #fafafa; -} - -#swagger-ui .topbar { - display: none; -} +/* Custom CSS for dark mode */ +@media (prefers-color-scheme: dark) { + .swagger-ui { + filter: invert(85%) hue-rotate(180deg); + } + .swagger-ui .microlight { + filter: invert(100%) hue-rotate(180deg); + } +} \ No newline at end of file diff --git a/packages/api/v1/api-documentation.tsx b/packages/api/v1/api-documentation.tsx index 0ecdc4b39..b7c36fa8b 100644 --- a/packages/api/v1/api-documentation.tsx +++ b/packages/api/v1/api-documentation.tsx @@ -5,7 +5,7 @@ import 'swagger-ui-react/swagger-ui.css'; import { OpenAPIV1 } from '@documenso/api/v1/openapi'; -// Custom CSS for the Swagger UI +// Custom Dark Mode CSS for the Swagger UI // eslint-disable-next-line prettier/prettier import './api-documentation.css'; From 025af6e9f46f5e1683f607928fa73c341b68ba43 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Wed, 13 Mar 2024 12:41:08 +0530 Subject: [PATCH 133/299] chore: added eol Signed-off-by: Adithya Krishna --- packages/api/v1/api-documentation.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/v1/api-documentation.css b/packages/api/v1/api-documentation.css index 9edb85748..9deba27ea 100644 --- a/packages/api/v1/api-documentation.css +++ b/packages/api/v1/api-documentation.css @@ -6,4 +6,4 @@ .swagger-ui .microlight { filter: invert(100%) hue-rotate(180deg); } -} \ No newline at end of file +} From cc483016d8d6732b8a47365181dc31cee4323888 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Wed, 13 Mar 2024 13:24:09 +0530 Subject: [PATCH 134/299] chore: updated styling Signed-off-by: Adithya Krishna --- packages/api/v1/api-documentation.css | 9 --------- packages/api/v1/api-documentation.tsx | 23 +++++++++++++++++++---- packages/ui/styles/theme.css | 9 +++++++++ 3 files changed, 28 insertions(+), 13 deletions(-) delete mode 100644 packages/api/v1/api-documentation.css diff --git a/packages/api/v1/api-documentation.css b/packages/api/v1/api-documentation.css deleted file mode 100644 index 9deba27ea..000000000 --- a/packages/api/v1/api-documentation.css +++ /dev/null @@ -1,9 +0,0 @@ -/* Custom CSS for dark mode */ -@media (prefers-color-scheme: dark) { - .swagger-ui { - filter: invert(85%) hue-rotate(180deg); - } - .swagger-ui .microlight { - filter: invert(100%) hue-rotate(180deg); - } -} diff --git a/packages/api/v1/api-documentation.tsx b/packages/api/v1/api-documentation.tsx index b7c36fa8b..98bdd7095 100644 --- a/packages/api/v1/api-documentation.tsx +++ b/packages/api/v1/api-documentation.tsx @@ -1,15 +1,30 @@ 'use client'; +import { useEffect } from 'react'; + +import { useTheme } from 'next-themes'; import SwaggerUI from 'swagger-ui-react'; import 'swagger-ui-react/swagger-ui.css'; import { OpenAPIV1 } from '@documenso/api/v1/openapi'; -// Custom Dark Mode CSS for the Swagger UI -// eslint-disable-next-line prettier/prettier -import './api-documentation.css'; - export const OpenApiDocsPage = () => { + const { theme } = useTheme(); + + useEffect(() => { + const body = document.body; + + if (theme === 'dark') { + body.classList.add('swagger-dark-theme'); + } else { + body.classList.remove('swagger-dark-theme'); + } + + return () => { + body.classList.remove('swagger-dark-theme'); + }; + }, [theme]); + return ; }; diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css index 70a06ad15..de1927f73 100644 --- a/packages/ui/styles/theme.css +++ b/packages/ui/styles/theme.css @@ -129,3 +129,12 @@ .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgb(100 116 139 / 0.5); } + + /* Custom Swagger Dark Theme */ +.swagger-dark-theme .swagger-ui { + filter: invert(88%) hue-rotate(180deg); +} + +.swagger-dark-theme .swagger-ui .microlight { + filter: invert(100%) hue-rotate(180deg); +} \ No newline at end of file From 0488442652fdac10f68587f36fd9244351501226 Mon Sep 17 00:00:00 2001 From: Gautam-Hegde Date: Wed, 13 Mar 2024 13:45:10 +0530 Subject: [PATCH 135/299] fix: pagination discrepancy --- packages/ui/primitives/data-table-pagination.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/ui/primitives/data-table-pagination.tsx b/packages/ui/primitives/data-table-pagination.tsx index feebf6c54..a143960b4 100644 --- a/packages/ui/primitives/data-table-pagination.tsx +++ b/packages/ui/primitives/data-table-pagination.tsx @@ -20,6 +20,9 @@ export function DataTablePagination({ table, additionalInformation = 'VisibleCount', }: DataTablePaginationProps) { + const pageCount = table.getPageCount(); + const isEmptyTable = pageCount === 0; + return (
@@ -65,7 +68,9 @@ export function DataTablePagination({
- Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} + {isEmptyTable + ? 'Page 1 of 1' + : `Page ${table.getState().pagination.pageIndex + 1} of ${pageCount}`}
@@ -99,7 +104,7 @@ export function DataTablePagination({
); } From d6c8a3d32c5939536f8c87e4b6938f03d35744f8 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 14 Mar 2024 09:20:01 +0000 Subject: [PATCH 141/299] fix: what happens if we use a dynamic import? --- .../src/app/(marketing)/blog/[post]/opengraph/route.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/opengraph/route.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/opengraph/route.tsx index 906ee18cd..6f16b5092 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/opengraph/route.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/opengraph/route.tsx @@ -1,12 +1,8 @@ import { ImageResponse } from 'next/og'; import { NextResponse } from 'next/server'; -import { allBlogPosts } from 'contentlayer/generated'; - export const runtime = 'edge'; -const contentType = 'image/png'; - const IMAGE_SIZE = { width: 1200, height: 630, @@ -17,6 +13,8 @@ type BlogPostOpenGraphImageProps = { }; export async function GET(_request: Request, { params }: BlogPostOpenGraphImageProps) { + const { allBlogPosts } = await import('contentlayer/generated'); + const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`); if (!blogPost) { From 4926b6de509b7b5942a16238030507faf7098f74 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 14 Mar 2024 09:40:26 +0000 Subject: [PATCH 142/299] fix: boring sign/verify approach --- .../blog/[post]/opengraph/route.tsx | 22 +++++++++++-------- .../src/app/(marketing)/blog/[post]/page.tsx | 18 +++++++++++++-- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/opengraph/route.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/opengraph/route.tsx index 6f16b5092..f17a7931a 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/opengraph/route.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/opengraph/route.tsx @@ -1,6 +1,8 @@ import { ImageResponse } from 'next/og'; import { NextResponse } from 'next/server'; +import { verify } from '@documenso/lib/server-only/crypto/verify'; + export const runtime = 'edge'; const IMAGE_SIZE = { @@ -8,16 +10,18 @@ const IMAGE_SIZE = { height: 630, }; -type BlogPostOpenGraphImageProps = { - params: { post: string }; -}; +export async function GET(_request: Request) { + const url = new URL(_request.url); -export async function GET(_request: Request, { params }: BlogPostOpenGraphImageProps) { - const { allBlogPosts } = await import('contentlayer/generated'); + const signature = url.searchParams.get('sig'); + const title = url.searchParams.get('title'); + const author = url.searchParams.get('author'); - const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`); + if (!title || !author || !signature) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } - if (!blogPost) { + if (!verify({ title, author }, signature)) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } @@ -48,10 +52,10 @@ export async function GET(_request: Request, { params }: BlogPostOpenGraphImageP logo

- {blogPost.title} + {title}

-

Written by {blogPost.authorName}

+

Written by {author}

), { diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx index fc65d9772..d8ef587c4 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx @@ -7,6 +7,8 @@ import { ChevronLeft } from 'lucide-react'; import type { MDXComponents } from 'mdx/types'; import { useMDXComponent } from 'next-contentlayer/hooks'; +import { sign } from '@documenso/lib/server-only/crypto/sign'; + import { CallToAction } from '~/components/(marketing)/call-to-action'; export const dynamic = 'force-dynamic'; @@ -20,16 +22,28 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => { }; } + const signature = sign({ + title: blogPost.title, + author: blogPost.authorName, + }); + + // Use the url constructor to ensure that things are escaped as they should be + const openGraphImageUrl = new URL(`${blogPost.href}/opengraph`); + + openGraphImageUrl.searchParams.set('title', blogPost.title); + openGraphImageUrl.searchParams.set('author', blogPost.authorName); + openGraphImageUrl.searchParams.set('sig', signature); + return { title: { absolute: `${blogPost.title} - Documenso Blog`, }, description: blogPost.description, openGraph: { - images: [`${blogPost.href}/opengraph`], + images: [openGraphImageUrl.toString()], }, twitter: { - images: [`${blogPost.href}/opengraph`], + images: [openGraphImageUrl.toString()], }, }; }; From f5967e28c3fa751b54e1c9ad79bbb04ee52641b9 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 14 Mar 2024 10:02:09 +0000 Subject: [PATCH 143/299] fix: without protection? --- .../blog/[post]/opengraph-image.tsx | 76 ------------------- .../blog/[post]/opengraph/route.tsx | 9 +-- 2 files changed, 1 insertion(+), 84 deletions(-) delete mode 100644 apps/marketing/src/app/(marketing)/blog/[post]/opengraph-image.tsx diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/opengraph-image.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/opengraph-image.tsx deleted file mode 100644 index 4c01967d2..000000000 --- a/apps/marketing/src/app/(marketing)/blog/[post]/opengraph-image.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { ImageResponse } from 'next/og'; - -import { allBlogPosts } from 'contentlayer/generated'; - -export const runtime = 'edge'; - -export const contentType = 'image/png'; - -export const IMAGE_SIZE = { - width: 1200, - height: 630, -}; - -type BlogPostOpenGraphImageProps = { - params: { post: string }; -}; - -export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGraphImageProps) { - const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`); - - if (!blogPost) { - return null; - } - - // The long urls are needed for a compiler optimisation on the Next.js side, lifting this up - // to a constant will break og image generation. - const [interBold, interRegular, backgroundImage, logoImage] = await Promise.all([ - fetch(new URL('@documenso/assets/fonts/inter-bold.ttf', import.meta.url)).then(async (res) => - res.arrayBuffer(), - ), - fetch(new URL('@documenso/assets/fonts/inter-regular.ttf', import.meta.url)).then(async (res) => - res.arrayBuffer(), - ), - fetch(new URL('@documenso/assets/images/background-blog-og.png', import.meta.url)).then( - async (res) => res.arrayBuffer(), - ), - fetch(new URL('@documenso/assets/logo.png', import.meta.url)).then(async (res) => - res.arrayBuffer(), - ), - ]); - - return new ImageResponse( - ( -
- {/* @ts-expect-error Lack of typing from ImageResponse */} - og-background - - {/* @ts-expect-error Lack of typing from ImageResponse */} - logo - -

- {blogPost.title} -

- -

Written by {blogPost.authorName}

-
- ), - { - ...IMAGE_SIZE, - fonts: [ - { - name: 'Inter', - data: interRegular, - style: 'normal', - weight: 400, - }, - { - name: 'Inter', - data: interBold, - style: 'normal', - weight: 700, - }, - ], - }, - ); -} diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/opengraph/route.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/opengraph/route.tsx index f17a7931a..70233bbdd 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/opengraph/route.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/opengraph/route.tsx @@ -1,8 +1,6 @@ import { ImageResponse } from 'next/og'; import { NextResponse } from 'next/server'; -import { verify } from '@documenso/lib/server-only/crypto/verify'; - export const runtime = 'edge'; const IMAGE_SIZE = { @@ -13,15 +11,10 @@ const IMAGE_SIZE = { export async function GET(_request: Request) { const url = new URL(_request.url); - const signature = url.searchParams.get('sig'); const title = url.searchParams.get('title'); const author = url.searchParams.get('author'); - if (!title || !author || !signature) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } - - if (!verify({ title, author }, signature)) { + if (!title || !author) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } From 0db2e6643dd8678283eb3b1ab3ab499bfe62a0fc Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 14 Mar 2024 10:39:48 +0000 Subject: [PATCH 144/299] fix: final final v2 --- .../src/app/(marketing)/blog/[post]/page.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx index d8ef587c4..b0d59edc1 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx @@ -28,11 +28,11 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => { }); // Use the url constructor to ensure that things are escaped as they should be - const openGraphImageUrl = new URL(`${blogPost.href}/opengraph`); - - openGraphImageUrl.searchParams.set('title', blogPost.title); - openGraphImageUrl.searchParams.set('author', blogPost.authorName); - openGraphImageUrl.searchParams.set('sig', signature); + const searchParams = new URLSearchParams({ + title: blogPost.title, + author: blogPost.authorName, + sig: signature, + }); return { title: { @@ -40,10 +40,10 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => { }, description: blogPost.description, openGraph: { - images: [openGraphImageUrl.toString()], + images: [`${blogPost.href}/opengraph?${searchParams.toString()}`], }, twitter: { - images: [openGraphImageUrl.toString()], + images: [`${blogPost.href}/opengraph?${searchParams.toString()}`], }, }; }; From 524a7918d55754a7c26e107f268db298cd7a3eb3 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 14 Mar 2024 10:41:59 +0000 Subject: [PATCH 145/299] fix: toss the signature --- apps/marketing/src/app/(marketing)/blog/[post]/page.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx index b0d59edc1..3e50f8305 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx @@ -7,8 +7,6 @@ import { ChevronLeft } from 'lucide-react'; import type { MDXComponents } from 'mdx/types'; import { useMDXComponent } from 'next-contentlayer/hooks'; -import { sign } from '@documenso/lib/server-only/crypto/sign'; - import { CallToAction } from '~/components/(marketing)/call-to-action'; export const dynamic = 'force-dynamic'; @@ -22,16 +20,10 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => { }; } - const signature = sign({ - title: blogPost.title, - author: blogPost.authorName, - }); - // Use the url constructor to ensure that things are escaped as they should be const searchParams = new URLSearchParams({ title: blogPost.title, author: blogPost.authorName, - sig: signature, }); return { From d5c4885c67a0a71aabf0c370b5a19e77da4de119 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 14 Mar 2024 12:39:09 +0000 Subject: [PATCH 146/299] fix: update signup form to handle password managers better --- apps/web/src/app/(unauthenticated)/layout.tsx | 2 +- .../src/app/(unauthenticated)/signup/page.tsx | 2 +- apps/web/src/components/forms/v2/signup.tsx | 32 ++++++++----------- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/apps/web/src/app/(unauthenticated)/layout.tsx b/apps/web/src/app/(unauthenticated)/layout.tsx index 03a73278f..05055d508 100644 --- a/apps/web/src/app/(unauthenticated)/layout.tsx +++ b/apps/web/src/app/(unauthenticated)/layout.tsx @@ -10,7 +10,7 @@ type UnauthenticatedLayoutProps = { export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) { return ( -
+
diff --git a/apps/web/src/components/forms/v2/signup.tsx b/apps/web/src/components/forms/v2/signup.tsx index a7e33a759..b3b502993 100644 --- a/apps/web/src/components/forms/v2/signup.tsx +++ b/apps/web/src/components/forms/v2/signup.tsx @@ -108,17 +108,6 @@ export const SignUpFormV2 = ({ const name = form.watch('name'); const url = form.watch('url'); - // To continue we need to make sure name, email, password and signature are valid - const canContinue = - form.formState.dirtyFields.name && - form.formState.errors.name === undefined && - form.formState.dirtyFields.email && - form.formState.errors.email === undefined && - form.formState.dirtyFields.password && - form.formState.errors.password === undefined && - form.formState.dirtyFields.signature && - form.formState.errors.signature === undefined; - const { mutateAsync: signup } = trpc.auth.signup.useMutation(); const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormV2Schema) => { @@ -169,6 +158,14 @@ export const SignUpFormV2 = ({ } }; + const onNextClick = async () => { + const valid = await form.trigger(['name', 'email', 'password', 'signature']); + + if (valid) { + setStep('CLAIM_USERNAME'); + } + }; + const onSignUpWithGoogleClick = async () => { try { await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH }); @@ -224,7 +221,7 @@ export const SignUpFormV2 = ({
-
+
{step === 'BASIC_DETAILS' && (

Create a new account

@@ -257,8 +254,8 @@ export const SignUpFormV2 = ({ {step === 'BASIC_DETAILS' && (
@@ -360,8 +357,8 @@ export const SignUpFormV2 = ({ {step === 'CLAIM_USERNAME' && (
@@ -431,9 +428,8 @@ export const SignUpFormV2 = ({ type="button" size="lg" className="flex-1 disabled:cursor-not-allowed" - disabled={!canContinue} loading={form.formState.isSubmitting} - onClick={() => setStep('CLAIM_USERNAME')} + onClick={onNextClick} > Next From fd4d5468cfc331f221d4e2d1a74ae60ac6af7fb4 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 14 Mar 2024 23:52:49 +1100 Subject: [PATCH 147/299] fix: use gif for readme --- README.md | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 93c6d9f95..b83df450a 100644 --- a/README.md +++ b/README.md @@ -30,17 +30,8 @@ Contributor Covenant

-
- - - - - +
+
## About this project From 17c6a4bd55e16f6f7544f85f7166ccc46b7ba1ff Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Fri, 15 Mar 2024 09:18:51 +0530 Subject: [PATCH 148/299] chore: updated focus state of menu switcher Signed-off-by: Adithya Krishna --- .../(dashboard)/layout/menu-switcher.tsx | 2 +- packages/ui/styles/theme.css | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx index 6e3a4c015..e7959322a 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
From b95f7176e26abbdb6bdc4b95c6d3deec46106b14 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu Date: Mon, 18 Mar 2024 18:25:04 +0000 Subject: [PATCH 158/299] fix: username overflow issue --- apps/web/src/components/forms/public-profile-claim-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/forms/public-profile-claim-dialog.tsx b/apps/web/src/components/forms/public-profile-claim-dialog.tsx index dbd52fd27..e3b03a44c 100644 --- a/apps/web/src/components/forms/public-profile-claim-dialog.tsx +++ b/apps/web/src/components/forms/public-profile-claim-dialog.tsx @@ -154,7 +154,7 @@ export const ClaimPublicProfileDialogForm = ({ -
+
{baseUrl.host}/u/{field.value || ''}
From 2facc0e3318b0aafbf5579fd283779adbdb2b3a6 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Wed, 20 Mar 2024 10:17:31 +0000 Subject: [PATCH 159/299] feat: add completed documents per month graph --- ...monthly-completed-documents-chart copy.tsx | 57 +++++++++++++++++++ .../src/app/(marketing)/open/page.tsx | 8 +++ .../user/get-monthly-completed-document.ts | 35 ++++++++++++ .../seed/pr-711-deletion-of-documents.ts | 1 + 4 files changed, 101 insertions(+) create mode 100644 apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart copy.tsx create mode 100644 packages/lib/server-only/user/get-monthly-completed-document.ts 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 copy.tsx new file mode 100644 index 000000000..ce438145b --- /dev/null +++ b/apps/marketing/src/app/(marketing)/open/monthly-completed-documents-chart copy.tsx @@ -0,0 +1,57 @@ +'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 MonthlyCompletedDocumentsChartProps = { + className?: string; + data: GetUserMonthlyGrowthResult; +}; + +export const MonthlyCompletedDocumentsChart = ({ + className, + data, +}: MonthlyCompletedDocumentsChartProps) => { + const formattedData = [...data].reverse().map(({ month, cume_count: count }) => { + return { + month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'), + count: Number(count), + }; + }); + + return ( +
+
+

Completed Documents per Month

+
+ +
+ + + + + + [Number(value).toLocaleString('en-US'), 'Total Users']} + cursor={{ fill: 'hsl(var(--primary) / 10%)' }} + /> + + + + +
+
+ ); +}; diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index 10ab71aa7..77e36ca78 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { z } from 'zod'; +import { getCompletedDocumentsMonthly } from '@documenso/lib/server-only/user/get-monthly-completed-document'; import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth'; import { FUNDING_RAISED } from '~/app/(marketing)/open/data'; @@ -12,6 +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 { MonthlyNewUsersChart } from './monthly-new-users-chart'; import { MonthlyTotalUsersChart } from './monthly-total-users-chart'; import { TeamMembers } from './team-members'; @@ -140,6 +142,7 @@ export default async function OpenPage() { ]); const MONTHLY_USERS = await getUserMonthlyGrowth(); + const MONTHLY_COMPLETED_DOCUMENTS = await getCompletedDocumentsMonthly(); return (
@@ -240,6 +243,11 @@ export default async function OpenPage() { + +
diff --git a/packages/lib/server-only/user/get-monthly-completed-document.ts b/packages/lib/server-only/user/get-monthly-completed-document.ts new file mode 100644 index 000000000..ef1bcd4b9 --- /dev/null +++ b/packages/lib/server-only/user/get-monthly-completed-document.ts @@ -0,0 +1,35 @@ +import { DateTime } from 'luxon'; + +import { prisma } from '@documenso/prisma'; + +export type GetCompletedDocumentsMonthlyResult = Array<{ + month: string; + count: number; + cume_count: number; +}>; + +type GetCompletedDocumentsMonthlyQueryResult = Array<{ + month: Date; + count: bigint; + cume_count: bigint; +}>; + +export const getCompletedDocumentsMonthly = async () => { + const result = await prisma.$queryRaw` + SELECT + DATE_TRUNC('month', "completedAt") AS "month", + COUNT("id") as "count", + SUM(COUNT("id")) OVER (ORDER BY DATE_TRUNC('month', "completedAt")) as "cume_count" + FROM "Document" + WHERE "status" = 'COMPLETED' + GROUP BY "month" + ORDER BY "month" DESC + LIMIT 12 + `; + + return result.map((row) => ({ + month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'), + count: Number(row.count), + cume_count: Number(row.cume_count), + })); +}; diff --git a/packages/prisma/seed/pr-711-deletion-of-documents.ts b/packages/prisma/seed/pr-711-deletion-of-documents.ts index 5365ecf47..d2706b734 100644 --- a/packages/prisma/seed/pr-711-deletion-of-documents.ts +++ b/packages/prisma/seed/pr-711-deletion-of-documents.ts @@ -182,6 +182,7 @@ const createCompletedDocument = async (sender: User, recipients: User[]) => { title: `[${PULL_REQUEST_NUMBER}] Document 1 - Completed`, status: DocumentStatus.COMPLETED, documentDataId: documentData.id, + completedAt: new Date(), userId: sender.id, }, }); From 48858cfdd00748767852c72c315c2d0c4c5309e4 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Wed, 20 Mar 2024 10:31:19 +0000 Subject: [PATCH 160/299] chore: restructure open page --- .../src/app/(marketing)/open/page.tsx | 51 +++++++++++-------- .../src/app/(marketing)/open/team-members.tsx | 2 +- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index 77e36ca78..41f0aceba 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -164,7 +164,7 @@ export default async function OpenPage() {

-
+
+
+

Finances

+
+
- - data={EARLY_ADOPTERS_DATA} - metricKey="earlyAdopters" - title="Early Adopters" - label="Early Adopters" - className="col-span-12 lg:col-span-6" - extraInfo={} - /> - +

Community

+
data={STARGAZERS_DATA} metricKey="stars" @@ -240,6 +237,20 @@ export default async function OpenPage() { className="col-span-12 lg:col-span-6" /> + +
+ +

Growth

+
+ + data={EARLY_ADOPTERS_DATA} + metricKey="earlyAdopters" + title="Early Adopters" + label="Early Adopters" + className="col-span-12 lg:col-span-6" + extraInfo={} + /> + @@ -247,20 +258,18 @@ export default async function OpenPage() { data={MONTHLY_COMPLETED_DOCUMENTS} className="col-span-12 lg:col-span-6" /> - - - -
-

Where's the rest?

- -

- We're still working on getting all our metrics together. We'll update this page as - soon as we have more to share. -

-
+
+

Where's the rest?

+ +

+ We're still working on getting all our metrics together. We'll update this page as soon as + we have more to share. +

+
+
); diff --git a/apps/marketing/src/app/(marketing)/open/team-members.tsx b/apps/marketing/src/app/(marketing)/open/team-members.tsx index a79fcd182..288d48a0b 100644 --- a/apps/marketing/src/app/(marketing)/open/team-members.tsx +++ b/apps/marketing/src/app/(marketing)/open/team-members.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import { cn } from '@documenso/ui/lib/utils'; import { From 574cd176c20ac43d5366581f6972bfc44c1442ce Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 20 Mar 2024 12:34:03 +0100 Subject: [PATCH 161/299] chore: update copy to have more swag --- apps/marketing/src/app/(marketing)/open/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index 41f0aceba..4f2b0d857 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -262,10 +262,10 @@ export default async function OpenPage() {
-

Where's the rest?

+

Is there more?

- We're still working on getting all our metrics together. We'll update this page as soon as + This page is evolving as we learn what makes a great signing company. We'll update it when we have more to share.

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 162/299] 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 163/299] 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 164/299] 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 165/299] 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 166/299] 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 167/299] 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 168/299] 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/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 9b051bbad..90f605602 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -49,6 +49,8 @@ export const EditDocumentForm = ({ const searchParams = useSearchParams(); const team = useOptionalCurrentTeam(); + const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); + const utils = trpc.useUtils(); const { data: document, refetch: refetchDocument } = @@ -294,6 +296,7 @@ export const EditDocumentForm = ({ document={document} password={document.documentMeta?.password} onPasswordSubmit={onPasswordSubmit} + onDocumentLoad={() => setIsDocumentPdfLoaded(true)} /> @@ -314,8 +317,8 @@ export const EditDocumentForm = ({ recipients={recipients} fields={fields} onSubmit={onAddTitleFormSubmit} + isDocumentPdfLoaded={isDocumentPdfLoaded} /> - diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index c4f58b83c..3031d6479 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; + isDocumentPdfLoaded: boolean; }; export const AddFieldsFormPartial = ({ @@ -61,6 +62,7 @@ export const AddFieldsFormPartial = ({ recipients, fields, onSubmit, + isDocumentPdfLoaded, }: AddFieldsFormProps) => { const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement(); const { currentStep, totalSteps, previousStep } = useStep(); @@ -342,19 +344,20 @@ export const AddFieldsFormPartial = ({ )} - {localFields.map((field, index) => ( - onFieldResize(options, index)} - onMove={(options) => onFieldMove(options, index)} - onRemove={() => remove(index)} - /> - ))} + {isDocumentPdfLoaded && + localFields.map((field, index) => ( + onFieldResize(options, index)} + onMove={(options) => onFieldMove(options, index)} + onRemove={() => remove(index)} + /> + ))} {!hideRecipients && ( diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 8792af2a8..95f2c7983 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -39,6 +39,7 @@ export type AddSignersFormProps = { fields: Field[]; document: DocumentWithData; onSubmit: (_data: TAddSignersFormSchema) => void; + isDocumentPdfLoaded: boolean; }; export const AddSignersFormPartial = ({ @@ -47,6 +48,7 @@ export const AddSignersFormPartial = ({ document, fields, onSubmit, + isDocumentPdfLoaded, }: AddSignersFormProps) => { const { toast } = useToast(); const { remaining } = useLimits(); @@ -145,9 +147,10 @@ export const AddSignersFormPartial = ({ />
- {fields.map((field, index) => ( - - ))} + {isDocumentPdfLoaded && + fields.map((field, index) => ( + + ))} {signers.map((signer, index) => ( diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index aa0bc148f..3f9cbe798 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -50,6 +50,7 @@ export type AddSubjectFormProps = { fields: Field[]; document: DocumentWithData; onSubmit: (_data: TAddSubjectFormSchema) => void; + isDocumentPdfLoaded: boolean; }; export const AddSubjectFormPartial = ({ @@ -58,6 +59,7 @@ export const AddSubjectFormPartial = ({ fields: fields, document, onSubmit, + isDocumentPdfLoaded, }: AddSubjectFormProps) => { const { control, @@ -103,9 +105,10 @@ export const AddSubjectFormPartial = ({ />
- {fields.map((field, index) => ( - - ))} + {isDocumentPdfLoaded && + fields.map((field, index) => ( + + ))}
diff --git a/packages/ui/primitives/document-flow/add-title.tsx b/packages/ui/primitives/document-flow/add-title.tsx index a6390fd3a..5abe44003 100644 --- a/packages/ui/primitives/document-flow/add-title.tsx +++ b/packages/ui/primitives/document-flow/add-title.tsx @@ -28,6 +28,7 @@ export type AddTitleFormProps = { fields: Field[]; document: DocumentWithData; onSubmit: (_data: TAddTitleFormSchema) => void; + isDocumentPdfLoaded: boolean; }; export const AddTitleFormPartial = ({ @@ -36,6 +37,7 @@ export const AddTitleFormPartial = ({ fields, document, onSubmit, + isDocumentPdfLoaded, }: AddTitleFormProps) => { const { register, @@ -59,9 +61,10 @@ export const AddTitleFormPartial = ({ description={documentFlow.description} /> - {fields.map((field, index) => ( - - ))} + {isDocumentPdfLoaded && + fields.map((field, index) => ( + + ))}
From b491bd4db9aeca01820ae6a5df413b4f938999b6 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 27 Mar 2024 17:20:52 +0700 Subject: [PATCH 190/299] fix: normalize and flatten annotations --- .../lib/server-only/document/seal-document.ts | 33 +---- .../server-only/pdf/flatten-annotations.ts | 63 +++++++++ .../pdf/normalize-signature-appearances.ts | 26 ++++ .../helpers/add-signing-placeholder.ts | 120 ++++++++++-------- 4 files changed, 160 insertions(+), 82 deletions(-) create mode 100644 packages/lib/server-only/pdf/flatten-annotations.ts create mode 100644 packages/lib/server-only/pdf/normalize-signature-appearances.ts diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 95b7d9dc4..c5607d98b 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -2,7 +2,7 @@ import { nanoid } from 'nanoid'; import path from 'node:path'; -import { PDFDocument, PDFSignature, rectangle } from 'pdf-lib'; +import { PDFDocument } from 'pdf-lib'; import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; @@ -15,7 +15,9 @@ 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 { flattenAnnotations } from '../pdf/flatten-annotations'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; +import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { sendCompletedEmail } from './send-completed-email'; @@ -91,31 +93,10 @@ export const sealDocument = async ({ const doc = await PDFDocument.load(pdfData); - const form = doc.getForm(); - - // Remove old signatures - for (const field of form.getFields()) { - if (field instanceof PDFSignature) { - field.acroField.getWidgets().forEach((widget) => { - widget.ensureAP(); - - try { - widget.getNormalAppearance(); - } catch (e) { - const { context } = widget.dict; - - const xobj = context.formXObject([rectangle(0, 0, 0, 0)]); - - const streamRef = context.register(xobj); - - widget.setNormalAppearance(streamRef); - } - }); - } - } - - // Flatten the form to stop annotation layers from appearing above documenso fields - form.flatten(); + // Normalize and flatten layers that could cause issues with the signature + normalizeSignatureAppearances(doc); + flattenAnnotations(doc); + doc.getForm().flatten(); for (const field of fields) { await insertFieldInPDF(doc, field); diff --git a/packages/lib/server-only/pdf/flatten-annotations.ts b/packages/lib/server-only/pdf/flatten-annotations.ts new file mode 100644 index 000000000..83ac860e0 --- /dev/null +++ b/packages/lib/server-only/pdf/flatten-annotations.ts @@ -0,0 +1,63 @@ +import { PDFAnnotation, PDFRef } from 'pdf-lib'; +import { + PDFDict, + type PDFDocument, + PDFName, + drawObject, + popGraphicsState, + pushGraphicsState, + rotateInPlace, + translate, +} from 'pdf-lib'; + +export const flattenAnnotations = (document: PDFDocument) => { + const pages = document.getPages(); + + for (const page of pages) { + const annotations = page.node.Annots()?.asArray() ?? []; + + annotations.forEach((annotation) => { + if (!(annotation instanceof PDFRef)) { + return; + } + + const actualAnnotation = page.node.context.lookup(annotation); + + if (!(actualAnnotation instanceof PDFDict)) { + return; + } + + const pdfAnnot = PDFAnnotation.fromDict(actualAnnotation); + + const appearance = pdfAnnot.ensureAP(); + + // Skip annotations without a normal appearance + if (!appearance.has(PDFName.of('N'))) { + return; + } + + const normalAppearance = pdfAnnot.getNormalAppearance(); + const rectangle = pdfAnnot.getRectangle(); + + if (!(normalAppearance instanceof PDFRef)) { + // Not sure how to get the reference to the normal appearance yet + // so we should skip this annotation for now + return; + } + + const xobj = page.node.newXObject('FlatAnnot', normalAppearance); + + const operators = [ + pushGraphicsState(), + translate(rectangle.x, rectangle.y), + ...rotateInPlace({ ...rectangle, rotation: 0 }), + drawObject(xobj), + popGraphicsState(), + ].filter((op) => !!op); + + page.pushOperators(...operators); + + page.node.removeAnnot(annotation); + }); + } +}; diff --git a/packages/lib/server-only/pdf/normalize-signature-appearances.ts b/packages/lib/server-only/pdf/normalize-signature-appearances.ts new file mode 100644 index 000000000..d8bb83563 --- /dev/null +++ b/packages/lib/server-only/pdf/normalize-signature-appearances.ts @@ -0,0 +1,26 @@ +import type { PDFDocument } from 'pdf-lib'; +import { PDFSignature, rectangle } from 'pdf-lib'; + +export const normalizeSignatureAppearances = (document: PDFDocument) => { + const form = document.getForm(); + + for (const field of form.getFields()) { + if (field instanceof PDFSignature) { + field.acroField.getWidgets().forEach((widget) => { + widget.ensureAP(); + + try { + widget.getNormalAppearance(); + } catch { + const { context } = widget.dict; + + const xobj = context.formXObject([rectangle(0, 0, 0, 0)]); + + const streamRef = context.register(xobj); + + widget.setNormalAppearance(streamRef); + } + }); + } + } +}; diff --git a/packages/signing/helpers/add-signing-placeholder.ts b/packages/signing/helpers/add-signing-placeholder.ts index cdee18863..2dcf16dc4 100644 --- a/packages/signing/helpers/add-signing-placeholder.ts +++ b/packages/signing/helpers/add-signing-placeholder.ts @@ -1,5 +1,6 @@ import { PDFArray, + PDFDict, PDFDocument, PDFHexString, PDFName, @@ -16,7 +17,7 @@ export type AddSigningPlaceholderOptions = { export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOptions) => { const doc = await PDFDocument.load(pdf); - const pages = doc.getPages(); + const [firstPage] = doc.getPages(); const byteRange = PDFArray.withContext(doc.context); @@ -25,64 +26,71 @@ export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOption byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER)); byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER)); - const signature = doc.context.obj({ - Type: 'Sig', - Filter: 'Adobe.PPKLite', - SubFilter: 'adbe.pkcs7.detached', - ByteRange: byteRange, - Contents: PDFHexString.fromText(' '.repeat(8192)), - Reason: PDFString.of('Signed by Documenso'), - M: PDFString.fromDate(new Date()), - }); - - const signatureRef = doc.context.register(signature); - - const widget = doc.context.obj({ - Type: 'Annot', - Subtype: 'Widget', - FT: 'Sig', - Rect: [0, 0, 0, 0], - V: signatureRef, - T: PDFString.of('Signature1'), - F: 4, - P: pages[0].ref, - }); - - const xobj = widget.context.formXObject([rectangle(0, 0, 0, 0)]); - - const streamRef = widget.context.register(xobj); - - widget.set(PDFName.of('AP'), widget.context.obj({ N: streamRef })); - - const widgetRef = doc.context.register(widget); - - let widgets = pages[0].node.get(PDFName.of('Annots')); - - if (widgets instanceof PDFArray) { - widgets.push(widgetRef); - } else { - const newWidgets = PDFArray.withContext(doc.context); - - newWidgets.push(widgetRef); - - pages[0].node.set(PDFName.of('Annots'), newWidgets); - - widgets = pages[0].node.get(PDFName.of('Annots')); - } - - if (!widgets) { - throw new Error('No widgets'); - } - - pages[0].node.set(PDFName.of('Annots'), widgets); - - doc.catalog.set( - PDFName.of('AcroForm'), + const signature = doc.context.register( doc.context.obj({ - SigFlags: 3, - Fields: [widgetRef], + Type: 'Sig', + Filter: 'Adobe.PPKLite', + SubFilter: 'adbe.pkcs7.detached', + ByteRange: byteRange, + Contents: PDFHexString.fromText(' '.repeat(8192)), + Reason: PDFString.of('Signed by Documenso'), + M: PDFString.fromDate(new Date()), }), ); + const widget = doc.context.register( + doc.context.obj({ + Type: 'Annot', + Subtype: 'Widget', + FT: 'Sig', + Rect: [0, 0, 0, 0], + V: signature, + T: PDFString.of('Signature1'), + F: 4, + P: firstPage.ref, + AP: doc.context.obj({ + N: doc.context.register(doc.context.formXObject([rectangle(0, 0, 0, 0)])), + }), + }), + ); + + let widgets: PDFArray; + + try { + widgets = firstPage.node.lookup(PDFName.of('Annots'), PDFArray); + } catch { + widgets = PDFArray.withContext(doc.context); + + firstPage.node.set(PDFName.of('Annots'), widgets); + } + + widgets.push(widget); + + let arcoForm: PDFDict; + + try { + arcoForm = doc.catalog.lookup(PDFName.of('AcroForm'), PDFDict); + } catch { + arcoForm = doc.context.obj({ + Fields: PDFArray.withContext(doc.context), + }); + + doc.catalog.set(PDFName.of('AcroForm'), arcoForm); + } + + let fields: PDFArray; + + try { + fields = arcoForm.lookup(PDFName.of('Fields'), PDFArray); + } catch { + fields = PDFArray.withContext(doc.context); + + arcoForm.set(PDFName.of('Fields'), fields); + } + + fields.push(widget); + + arcoForm.set(PDFName.of('SigFlags'), PDFNumber.of(3)); + return Buffer.from(await doc.save({ useObjectStreams: false })); }; From c644d527dffac7aa0b9988237bc0515a61f89ad6 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 27 Mar 2024 19:10:12 +0800 Subject: [PATCH 191/299] fix: remove scrollbar gutter (#1063) ## Description Currently opening modals, clicking select boxes or using anything from radix that overlays the screen in some way will shift the screen. This can be easily noticeable when changing the document "Period" selector on the /documents page. ## Changes Made Undo the gutter change for now. Can find a proper solution another time. https://github.com/documenso/documenso/assets/20962767/5bcae576-2944-4ae5-a2c3-0589e7f61bdb --- packages/ui/styles/theme.css | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css index de1927f73..cb2d9d5c5 100644 --- a/packages/ui/styles/theme.css +++ b/packages/ui/styles/theme.css @@ -91,11 +91,6 @@ @apply border-border; } - html, - body { - scrollbar-gutter: stable; - } - body { @apply bg-background text-foreground; font-feature-settings: 'rlig' 1, 'calt' 1; @@ -130,11 +125,11 @@ background: rgb(100 116 139 / 0.5); } - /* Custom Swagger Dark Theme */ +/* Custom Swagger Dark Theme */ .swagger-dark-theme .swagger-ui { filter: invert(88%) hue-rotate(180deg); } .swagger-dark-theme .swagger-ui .microlight { filter: invert(100%) hue-rotate(180deg); -} \ No newline at end of file +} From f386dd31a75ee73462f81626df6ad1ff541654fa Mon Sep 17 00:00:00 2001 From: Sai Suhas Sawant <92092643+SaiSawant1@users.noreply.github.com> Date: Wed, 27 Mar 2024 18:07:11 +0530 Subject: [PATCH 192/299] fix: user preview to lowercase (#1064) changed the user preview in user-profile-skeleton to lowercase to match ui of other components --- apps/web/src/components/ui/user-profile-skeleton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/user-profile-skeleton.tsx b/apps/web/src/components/ui/user-profile-skeleton.tsx index c8b8b808a..74026cdbb 100644 --- a/apps/web/src/components/ui/user-profile-skeleton.tsx +++ b/apps/web/src/components/ui/user-profile-skeleton.tsx @@ -24,7 +24,7 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk className, )} > -
+
{baseUrl.host}/u/{user.url}
From 956562d3b4b1246bddc3a588537fb63da23d4c96 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 27 Mar 2024 23:05:40 +0700 Subject: [PATCH 193/299] fix: change flattening order --- packages/lib/server-only/document/seal-document.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index c5607d98b..58480a7bd 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -95,8 +95,8 @@ export const sealDocument = async ({ // Normalize and flatten layers that could cause issues with the signature normalizeSignatureAppearances(doc); - flattenAnnotations(doc); doc.getForm().flatten(); + flattenAnnotations(doc); for (const field of fields) { await insertFieldInPDF(doc, field); From a54eb54ef73376fdef1880711987e0ae575d0f78 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 28 Mar 2024 13:13:29 +0800 Subject: [PATCH 194/299] feat: add document auth (#1029) --- .env.example | 1 + .../app/(marketing)/singleplayer/client.tsx | 1 + .../documents/[id]/edit-document.tsx | 63 ++- .../[id]/edit/document-edit-page-view.tsx | 7 + .../(signing)/sign/[token]/complete/page.tsx | 17 + .../app/(signing)/sign/[token]/date-field.tsx | 11 +- .../[token]/document-action-auth-dialog.tsx | 241 ++++++++++ .../sign/[token]/document-auth-provider.tsx | 168 +++++++ .../(signing)/sign/[token]/email-field.tsx | 11 +- .../src/app/(signing)/sign/[token]/form.tsx | 12 + .../app/(signing)/sign/[token]/name-field.tsx | 63 ++- .../src/app/(signing)/sign/[token]/page.tsx | 122 ++--- .../(signing)/sign/[token]/sign-dialog.tsx | 22 +- .../sign/[token]/signature-field.tsx | 77 ++-- .../sign/[token]/signing-auth-page.tsx | 67 +++ .../sign/[token]/signing-field-container.tsx | 71 ++- .../sign/[token]/signing-page-view.tsx | 102 +++++ .../app/(signing)/sign/[token]/text-field.tsx | 60 ++- .../document/document-history-sheet.tsx | 26 +- .../components/form/form-error-message.tsx | 34 -- .../e2e/document-auth/access-auth.spec.ts | 97 ++++ .../e2e/document-auth/action-auth.spec.ts | 418 ++++++++++++++++++ .../e2e/document-flow/settings-step.spec.ts | 200 +++++++++ .../e2e/document-flow/signers-step.spec.ts | 118 +++++ .../app-tests/e2e/fixtures/authentication.ts | 62 ++- .../e2e/pr-718-add-stepper-component.spec.ts | 89 ++-- .../app-tests/e2e/teams/manage-team.spec.ts | 8 +- .../e2e/teams/team-documents.spec.ts | 16 +- .../app-tests/e2e/teams/team-email.spec.ts | 8 +- .../app-tests/e2e/teams/team-members.spec.ts | 8 +- .../app-tests/e2e/teams/transfer-team.spec.ts | 4 +- .../e2e/templates/manage-templates.spec.ts | 10 +- packages/app-tests/playwright.config.ts | 14 +- .../util/is-document-enterprise.ts | 56 +++ packages/lib/constants/document-auth.ts | 31 ++ packages/lib/errors/app-error.ts | 12 +- .../document/complete-document-with-token.ts | 69 ++- .../document/get-document-by-token.ts | 91 +++- .../document/is-recipient-authorized.ts | 86 ++++ .../document/send-completed-email.ts | 2 +- .../document/update-document-settings.ts | 178 ++++++++ .../server-only/document/viewed-document.ts | 11 +- .../field/sign-field-with-token.ts | 43 +- .../lib/server-only/field/update-field.ts | 80 ++-- .../recipient/set-recipients-for-document.ts | 42 +- packages/lib/types/document-audit-logs.ts | 85 +++- packages/lib/types/document-auth.ts | 121 +++++ packages/lib/utils/billing.ts | 15 + packages/lib/utils/document-audit-logs.ts | 68 ++- packages/lib/utils/document-auth.ts | 72 +++ .../migration.sql | 5 + packages/prisma/schema.prisma | 2 + packages/prisma/seed/documents.ts | 184 +++++++- .../seed/pr-718-add-stepper-component.ts | 29 -- packages/prisma/seed/subscriptions.ts | 19 + packages/prisma/seed/users.ts | 2 + .../trpc/server/document-router/router.ts | 73 ++- .../trpc/server/document-router/schema.ts | 32 +- packages/trpc/server/field-router/router.ts | 10 +- packages/trpc/server/field-router/schema.ts | 2 + .../trpc/server/recipient-router/router.ts | 5 +- .../trpc/server/recipient-router/schema.ts | 6 + packages/trpc/server/trpc.ts | 1 - .../animate/animate-generic-fade-in-out.tsx | 8 +- .../document/document-download-button.tsx | 2 +- packages/ui/primitives/checkbox.tsx | 2 +- .../primitives/document-flow/add-settings.tsx | 366 +++++++++++++++ .../document-flow/add-settings.types.ts | 42 ++ .../primitives/document-flow/add-signers.tsx | 399 +++++++++++------ .../document-flow/add-signers.types.ts | 6 + .../primitives/document-flow/add-subject.tsx | 133 +----- .../document-flow/add-subject.types.ts | 12 - .../ui/primitives/document-flow/add-title.tsx | 106 ----- .../document-flow/add-title.types.ts | 7 - packages/ui/primitives/input.tsx | 2 +- render.yaml | 2 + turbo.json | 3 + 77 files changed, 3904 insertions(+), 846 deletions(-) create mode 100644 apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx create mode 100644 apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx create mode 100644 apps/web/src/app/(signing)/sign/[token]/signing-auth-page.tsx create mode 100644 apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx delete mode 100644 apps/web/src/components/form/form-error-message.tsx create mode 100644 packages/app-tests/e2e/document-auth/access-auth.spec.ts create mode 100644 packages/app-tests/e2e/document-auth/action-auth.spec.ts create mode 100644 packages/app-tests/e2e/document-flow/settings-step.spec.ts create mode 100644 packages/app-tests/e2e/document-flow/signers-step.spec.ts create mode 100644 packages/ee/server-only/util/is-document-enterprise.ts create mode 100644 packages/lib/constants/document-auth.ts create mode 100644 packages/lib/server-only/document/is-recipient-authorized.ts create mode 100644 packages/lib/server-only/document/update-document-settings.ts create mode 100644 packages/lib/types/document-auth.ts create mode 100644 packages/lib/utils/document-auth.ts create mode 100644 packages/prisma/migrations/20240311113243_add_document_auth/migration.sql delete mode 100644 packages/prisma/seed/pr-718-add-stepper-component.ts create mode 100644 packages/prisma/seed/subscriptions.ts create mode 100644 packages/ui/primitives/document-flow/add-settings.tsx create mode 100644 packages/ui/primitives/document-flow/add-settings.types.ts delete mode 100644 packages/ui/primitives/document-flow/add-title.tsx delete mode 100644 packages/ui/primitives/document-flow/add-title.types.ts diff --git a/.env.example b/.env.example index a9b600c03..2fb7c3845 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,7 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5 NEXT_PRIVATE_STRIPE_API_KEY= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= +NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID= # [[FEATURES]] # OPTIONAL: Leave blank to disable PostHog and feature flags. diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index 48b967de8..3f1c11259 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -161,6 +161,7 @@ export const SinglePlayerClient = () => { signingStatus: 'NOT_SIGNED', sendStatus: 'NOT_SENT', role: 'SIGNER', + authOptions: null, }; const onFileDrop = async (file: File) => { 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 90f605602..2e2f0c889 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -8,19 +8,18 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META, } from '@documenso/lib/constants/trpc'; -import { DocumentStatus } 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'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields'; import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types'; +import { AddSettingsFormPartial } from '@documenso/ui/primitives/document-flow/add-settings'; +import type { TAddSettingsFormSchema } from '@documenso/ui/primitives/document-flow/add-settings.types'; import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers'; import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types'; import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject'; import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types'; -import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title'; -import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; @@ -33,15 +32,17 @@ export type EditDocumentFormProps = { className?: string; initialDocument: DocumentWithDetails; documentRootPath: string; + isDocumentEnterprise: boolean; }; -type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject'; -const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'subject']; +type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject'; +const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields', 'subject']; export const EditDocumentForm = ({ className, initialDocument, documentRootPath, + isDocumentEnterprise, }: EditDocumentFormProps) => { const { toast } = useToast(); @@ -67,7 +68,7 @@ export const EditDocumentForm = ({ const { Recipient: recipients, Field: fields } = document; - const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation({ + const { mutateAsync: setSettingsForDocument } = trpc.document.setSettingsForDocument.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: (newData) => { utils.document.getDocumentWithDetailsById.setData( @@ -123,9 +124,9 @@ export const EditDocumentForm = ({ trpc.document.setPasswordForDocument.useMutation(); const documentFlow: Record = { - title: { - title: 'Add Title', - description: 'Add the title to the document.', + settings: { + title: 'General', + description: 'Configure general settings for the document.', stepIndex: 1, }, signers: { @@ -149,8 +150,7 @@ export const EditDocumentForm = ({ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined; - let initialStep: EditDocumentStep = - document.status === DocumentStatus.DRAFT ? 'title' : 'signers'; + let initialStep: EditDocumentStep = 'settings'; if ( searchParamStep && @@ -163,12 +163,23 @@ export const EditDocumentForm = ({ return initialStep; }); - const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => { + const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => { try { - await addTitle({ + const { timezone, dateFormat, redirectUrl } = data.meta; + + await setSettingsForDocument({ documentId: document.id, teamId: team?.id, - title: data.title, + data: { + title: data.title, + globalAccessAuth: data.globalAccessAuth ?? null, + globalActionAuth: data.globalActionAuth ?? null, + }, + meta: { + timezone, + dateFormat, + redirectUrl, + }, }); // Router refresh is here to clear the router cache for when navigating to /documents. @@ -180,7 +191,7 @@ export const EditDocumentForm = ({ toast({ title: 'Error', - description: 'An error occurred while updating title.', + description: 'An error occurred while updating the document settings.', variant: 'destructive', }); } @@ -191,7 +202,11 @@ export const EditDocumentForm = ({ await addSigners({ documentId: document.id, teamId: team?.id, - signers: data.signers, + signers: data.signers.map((signer) => ({ + ...signer, + // Explicitly set to null to indicate we want to remove auth if required. + actionAuth: signer.actionAuth || null, + })), }); // Router refresh is here to clear the router cache for when navigating to /documents. @@ -232,7 +247,7 @@ export const EditDocumentForm = ({ }; const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { - const { subject, message, timezone, dateFormat, redirectUrl } = data.meta; + const { subject, message } = data.meta; try { await sendDocument({ @@ -241,9 +256,6 @@ export const EditDocumentForm = ({ meta: { subject, message, - dateFormat, - timezone, - redirectUrl, }, }); @@ -310,24 +322,26 @@ export const EditDocumentForm = ({ currentStep={currentDocumentFlow.stepIndex} setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])} > - + +
); 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 a64831804..c13d8636b 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -6,7 +6,9 @@ import { getServerSession } from 'next-auth'; import { match } from 'ts-pattern'; import signingCelebration from '@documenso/assets/images/signing-celebration.png'; +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized'; 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'; @@ -17,6 +19,7 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { truncateTitle } from '~/helpers/truncate-title'; +import { SigningAuthPageView } from '../signing-auth-page'; import { DocumentPreviewButton } from './document-preview-button'; export type CompletedSigningPageProps = { @@ -32,8 +35,11 @@ export default async function CompletedSigningPage({ return notFound(); } + const { user } = await getServerComponentSession(); + const document = await getDocumentAndSenderByToken({ token, + requireAccessAuth: false, }).catch(() => null); if (!document || !document.documentData) { @@ -53,6 +59,17 @@ export default async function CompletedSigningPage({ return notFound(); } + const isDocumentAccessValid = await isRecipientAuthorized({ + type: 'ACCESS', + document, + recipient, + userId: user?.id, + }); + + if (!isDocumentAccessValid) { + return ; + } + const signatures = await getRecipientSignatures({ recipientId: recipient.id }); const recipientName = diff --git a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx index a06e7f2f9..dc1799bc1 100644 --- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx @@ -12,6 +12,8 @@ import { } from '@documenso/lib/constants/date-formats'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; @@ -54,16 +56,23 @@ export const DateField = ({ const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`; - const onSign = async () => { + const onSign = async (authOptions?: TRecipientActionAuth) => { try { await signFieldWithToken({ token: recipient.token, fieldId: field.id, value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, + authOptions, }); startTransition(() => router.refresh()); } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + console.error(err); toast({ diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx new file mode 100644 index 000000000..7ab92f75c --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx @@ -0,0 +1,241 @@ +/** + * Note: This file has some commented out stuff for password auth which is no longer possible. + * + * Leaving it here until after we add passkeys and 2FA since it can be reused. + */ +import { useState } from 'react'; + +import { DateTime } from 'luxon'; +import { signOut } from 'next-auth/react'; +import { match } from 'ts-pattern'; + +import { + DocumentAuth, + type TRecipientActionAuth, + type TRecipientActionAuthTypes, +} from '@documenso/lib/types/document-auth'; +import type { FieldType } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; + +import { useRequiredDocumentAuthContext } from './document-auth-provider'; + +export type DocumentActionAuthDialogProps = { + title?: string; + documentAuthType: TRecipientActionAuthTypes; + description?: string; + actionTarget: FieldType | 'DOCUMENT'; + isSubmitting?: boolean; + open: boolean; + onOpenChange: (value: boolean) => void; + + /** + * The callback to run when the reauth form is filled out. + */ + onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; +}; + +// const ZReauthFormSchema = z.object({ +// password: ZCurrentPasswordSchema, +// }); +// type TReauthFormSchema = z.infer; + +export const DocumentActionAuthDialog = ({ + title, + description, + documentAuthType, + // onReauthFormSubmit, + isSubmitting, + open, + onOpenChange, +}: DocumentActionAuthDialogProps) => { + const { recipient } = useRequiredDocumentAuthContext(); + + // const form = useForm({ + // resolver: zodResolver(ZReauthFormSchema), + // defaultValues: { + // password: '', + // }, + // }); + + const [isSigningOut, setIsSigningOut] = useState(false); + + const isLoading = isSigningOut || isSubmitting; // || form.formState.isSubmitting; + + const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation(); + + // const [formErrorCode, setFormErrorCode] = useState(null); + // const onFormSubmit = async (_values: TReauthFormSchema) => { + // const documentAuthValue: TRecipientActionAuth = match(documentAuthType) + // // Todo: Add passkey. + // // .with(DocumentAuthType.PASSKEY, (type) => ({ + // // type, + // // value, + // // })) + // .otherwise((type) => ({ + // type, + // })); + + // try { + // await onReauthFormSubmit(documentAuthValue); + + // onOpenChange(false); + // } catch (e) { + // const error = AppError.parseError(e); + // setFormErrorCode(error.code); + + // // Suppress unauthorized errors since it's handled in this component. + // if (error.code === AppErrorCode.UNAUTHORIZED) { + // return; + // } + + // throw error; + // } + // }; + + const handleChangeAccount = async (email: string) => { + try { + setIsSigningOut(true); + + const encryptedEmail = await encryptSecondaryData({ + data: email, + expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), + }); + + await signOut({ + callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`, + }); + } catch { + setIsSigningOut(false); + + // Todo: Alert. + } + }; + + const handleOnOpenChange = (value: boolean) => { + if (isLoading) { + return; + } + + onOpenChange(value); + }; + + // useEffect(() => { + // form.reset(); + // setFormErrorCode(null); + // }, [open, form]); + + return ( + + + + {title || 'Sign field'} + + + {description || `Reauthentication is required to sign the field`} + + + + {match(documentAuthType) + .with(DocumentAuth.ACCOUNT, () => ( +
+ + + To sign this field, you need to be logged in as {recipient.email} + + + + + + + + +
+ )) + .with(DocumentAuth.EXPLICIT_NONE, () => null) + .exhaustive()} + + {/*
+ +
+ + Email + + + + + + + ( + + Password + + + + + + + + )} + /> + + {formErrorCode && ( + + {match(formErrorCode) + .with(AppErrorCode.UNAUTHORIZED, () => ( + <> + Unauthorized + + We were unable to verify your details. Please ensure the details are + correct + + + )) + .otherwise(() => ( + <> + Something went wrong + + We were unable to sign this field at this time. Please try again or + contact support. + + + ))} + + )} + + + + + + +
+
+ */} +
+
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx new file mode 100644 index 000000000..c216f3905 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { createContext, useContext, useMemo, useState } from 'react'; + +import { match } from 'ts-pattern'; + +import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; +import type { + TDocumentAuthOptions, + TRecipientAccessAuthTypes, + TRecipientActionAuthTypes, + TRecipientAuthOptions, +} from '@documenso/lib/types/document-auth'; +import { DocumentAuth } from '@documenso/lib/types/document-auth'; +import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { type Document, FieldType, type Recipient, type User } from '@documenso/prisma/client'; + +import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog'; +import { DocumentActionAuthDialog } from './document-action-auth-dialog'; + +export type DocumentAuthContextValue = { + executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise; + document: Document; + documentAuthOption: TDocumentAuthOptions; + setDocument: (_value: Document) => void; + recipient: Recipient; + recipientAuthOption: TRecipientAuthOptions; + setRecipient: (_value: Recipient) => void; + derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null; + derivedRecipientActionAuth: TRecipientActionAuthTypes | null; + isAuthRedirectRequired: boolean; + user?: User | null; +}; + +const DocumentAuthContext = createContext(null); + +export const useDocumentAuthContext = () => { + return useContext(DocumentAuthContext); +}; + +export const useRequiredDocumentAuthContext = () => { + const context = useDocumentAuthContext(); + + if (!context) { + throw new Error('Document auth context is required'); + } + + return context; +}; + +export interface DocumentAuthProviderProps { + document: Document; + recipient: Recipient; + user?: User | null; + children: React.ReactNode; +} + +export const DocumentAuthProvider = ({ + document: initialDocument, + recipient: initialRecipient, + user, + children, +}: DocumentAuthProviderProps) => { + const [document, setDocument] = useState(initialDocument); + const [recipient, setRecipient] = useState(initialRecipient); + + const { + documentAuthOption, + recipientAuthOption, + derivedRecipientAccessAuth, + derivedRecipientActionAuth, + } = useMemo( + () => + extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }), + [document, recipient], + ); + + const [documentAuthDialogPayload, setDocumentAuthDialogPayload] = + useState(null); + + /** + * The pre calculated auth payload if the current user is authenticated correctly + * for the `derivedRecipientActionAuth`. + * + * Will be `null` if the user still requires authentication, or if they don't need + * authentication. + */ + const preCalculatedActionAuthOptions = match(derivedRecipientActionAuth) + .with(DocumentAuth.ACCOUNT, () => { + if (recipient.email !== user?.email) { + return null; + } + + return { + type: DocumentAuth.ACCOUNT, + }; + }) + .with(DocumentAuth.EXPLICIT_NONE, () => ({ + type: DocumentAuth.EXPLICIT_NONE, + })) + .with(null, () => null) + .exhaustive(); + + const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { + // Directly run callback if no auth required. + if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) { + await options.onReauthFormSubmit(); + return; + } + + // Run callback with precalculated auth options if available. + if (preCalculatedActionAuthOptions) { + setDocumentAuthDialogPayload(null); + await options.onReauthFormSubmit(preCalculatedActionAuthOptions); + return; + } + + // Request the required auth from the user. + setDocumentAuthDialogPayload({ + ...options, + }); + }; + + const isAuthRedirectRequired = Boolean( + DOCUMENT_AUTH_TYPES[derivedRecipientActionAuth || '']?.isAuthRedirectRequired && + !preCalculatedActionAuthOptions, + ); + + return ( + + {children} + + {documentAuthDialogPayload && derivedRecipientActionAuth && ( + setDocumentAuthDialogPayload(null)} + onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit} + actionTarget={documentAuthDialogPayload.actionTarget} + documentAuthType={derivedRecipientActionAuth} + /> + )} + + ); +}; + +type ExecuteActionAuthProcedureOptions = Omit< + DocumentActionAuthDialogProps, + 'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' +>; + +DocumentAuthProvider.displayName = 'DocumentAuthProvider'; diff --git a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx index d81116c21..bacfa5a16 100644 --- a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx @@ -7,6 +7,8 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; @@ -39,17 +41,24 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => { const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; - const onSign = async () => { + const onSign = async (authOptions?: TRecipientActionAuth) => { try { await signFieldWithToken({ token: recipient.token, fieldId: field.id, value: providedEmail ?? '', isBase64: false, + authOptions, }); startTransition(() => router.refresh()); } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + console.error(err); toast({ diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 7e6cf26b8..2b9b9d294 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -8,6 +8,7 @@ import { useSession } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; @@ -64,9 +65,20 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin return; } + await completeDocument(); + + // Reauth is currently not required for completing the document. + // await executeActionAuthProcedure({ + // onReauthFormSubmit: completeDocument, + // actionTarget: 'DOCUMENT', + // }); + }; + + const completeDocument = async (authOptions?: TRecipientActionAuth) => { await completeDocumentWithToken({ token: recipient.token, documentId: document.id, + authOptions, }); analytics.capture('App: Recipient has completed signing', { diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx index 9fd72da2d..f34fb6777 100644 --- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx @@ -7,7 +7,9 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; -import type { Recipient } from '@documenso/prisma/client'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { type Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; @@ -16,6 +18,7 @@ import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredSigningContext } from './provider'; import { SigningFieldContainer } from './signing-field-container'; @@ -32,6 +35,8 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredSigningContext(); + const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); + const [isPending, startTransition] = useTransition(); const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = @@ -47,9 +52,33 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { const [showFullNameModal, setShowFullNameModal] = useState(false); const [localFullName, setLocalFullName] = useState(''); - const onSign = async (source: 'local' | 'provider' = 'provider') => { + const onPreSign = () => { + if (!providedFullName) { + setShowFullNameModal(true); + return false; + } + + return true; + }; + + /** + * When the user clicks the sign button in the dialog where they enter their full name. + */ + const onDialogSignClick = () => { + setShowFullNameModal(false); + setProvidedFullName(localFullName); + + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localFullName), + actionTarget: field.type, + }); + }; + + const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => { try { - if (!providedFullName && !localFullName) { + const value = name || providedFullName; + + if (!value) { setShowFullNameModal(true); return; } @@ -57,18 +86,19 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { await signFieldWithToken({ token: recipient.token, fieldId: field.id, - value: source === 'local' && localFullName ? localFullName : providedFullName ?? '', + value, isBase64: false, + authOptions, }); - if (source === 'local' && !providedFullName) { - setProvidedFullName(localFullName); - } - - setLocalFullName(''); - startTransition(() => router.refresh()); } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + console.error(err); toast({ @@ -99,7 +129,13 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { }; return ( - + {isLoading && (
@@ -148,10 +184,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { type="button" className="flex-1" disabled={!localFullName} - onClick={() => { - setShowFullNameModal(false); - void onSign('local'); - }} + onClick={() => onDialogSignClick()} > Sign diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 83cdb93e2..e83f675ce 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -1,35 +1,24 @@ import { headers } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; -import { match } from 'ts-pattern'; - import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; -import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; -import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; -import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; 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 { 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'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; -import { DocumentStatus, FieldType, RecipientRole, SigningStatus } 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 { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; -import { truncateTitle } from '~/helpers/truncate-title'; - -import { DateField } from './date-field'; -import { EmailField } from './email-field'; -import { SigningForm } from './form'; -import { NameField } from './name-field'; +import { DocumentAuthProvider } from './document-auth-provider'; import { NoLongerAvailable } from './no-longer-available'; import { SigningProvider } from './provider'; -import { SignatureField } from './signature-field'; -import { TextField } from './text-field'; +import { SigningAuthPageView } from './signing-auth-page'; +import { SigningPageView } from './signing-page-view'; export type SigningPageProps = { params: { @@ -42,6 +31,8 @@ export default async function SigningPage({ params: { token } }: SigningPageProp return notFound(); } + const { user } = await getServerComponentSession(); + const requestHeaders = Object.fromEntries(headers().entries()); const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); @@ -49,21 +40,40 @@ export default async function SigningPage({ params: { token } }: SigningPageProp const [document, fields, recipient] = await Promise.all([ getDocumentAndSenderByToken({ token, + userId: user?.id, + requireAccessAuth: false, }).catch(() => null), getFieldsForToken({ token }), getRecipientByToken({ token }).catch(() => null), - viewedDocument({ token, requestMetadata }).catch(() => null), ]); if (!document || !document.documentData || !recipient) { return notFound(); } - const truncatedTitle = truncateTitle(document.title); + const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }); - const { documentData, documentMeta } = document; + const isDocumentAccessValid = await isRecipientAuthorized({ + type: 'ACCESS', + document, + recipient, + userId: user?.id, + }); - const { user } = await getServerComponentSession(); + if (!isDocumentAccessValid) { + return ; + } + + await viewedDocument({ + token, + requestMetadata, + recipientAccessAuth: derivedRecipientAccessAuth, + }).catch(() => null); + + const { documentMeta } = document; if ( document.status === DocumentStatus.COMPLETED || @@ -109,73 +119,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp fullName={user?.email === recipient.email ? user.name : recipient.name} signature={user?.email === recipient.email ? user.signature : undefined} > -
-

- {truncatedTitle} -

- -
-

- {document.User.name} ({document.User.email}) has invited you to{' '} - {recipient.role === RecipientRole.VIEWER && 'view'} - {recipient.role === RecipientRole.SIGNER && 'sign'} - {recipient.role === RecipientRole.APPROVER && 'approve'} this document. -

-
- -
- - - - - - -
- -
-
- - - {fields.map((field) => - match(field.type) - .with(FieldType.SIGNATURE, () => ( - - )) - .with(FieldType.NAME, () => ( - - )) - .with(FieldType.DATE, () => ( - - )) - .with(FieldType.EMAIL, () => ( - - )) - .with(FieldType.TEXT, () => ( - - )) - .otherwise(() => null), - )} - -
+ + + ); } diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx index a9aedbc3d..9b2877033 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -33,8 +33,28 @@ export const SignDialog = ({ const truncatedTitle = truncateTitle(document.title); const isComplete = fields.every((field) => field.inserted); + const handleOpenChange = (open: boolean) => { + if (isSubmitting || !isComplete) { + return; + } + + // Reauth is currently not required for signing the document. + // if (isAuthRedirectRequired) { + // await executeActionAuthProcedure({ + // actionTarget: 'DOCUMENT', + // onReauthFormSubmit: () => { + // // Do nothing since the user should be redirected. + // }, + // }); + + // return; + // } + + setShowDialog(open); + }; + return ( - + diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-auth-page.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-auth-page.tsx new file mode 100644 index 000000000..fb19384cd --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/signing-auth-page.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useState } from 'react'; + +import { DateTime } from 'luxon'; +import { signOut } from 'next-auth/react'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type SigningAuthPageViewProps = { + email: string; +}; + +export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => { + const { toast } = useToast(); + + const [isSigningOut, setIsSigningOut] = useState(false); + + const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation(); + + const handleChangeAccount = async (email: string) => { + try { + setIsSigningOut(true); + + const encryptedEmail = await encryptSecondaryData({ + data: email, + expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), + }); + + await signOut({ + callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`, + }); + } catch { + toast({ + title: 'Something went wrong', + description: 'We were unable to log you out at this time.', + duration: 10000, + variant: 'destructive', + }); + } + + setIsSigningOut(false); + }; + + return ( +
+
+

Authentication required

+ +

+ You need to be logged in as {email} to view this page. +

+ + +
+
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx index b4805fa6b..825a15d0f 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx @@ -2,15 +2,38 @@ import React from 'react'; +import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { FieldType } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { FieldRootContainer } from '@documenso/ui/components/field/field'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import { useRequiredDocumentAuthContext } from './document-auth-provider'; + export type SignatureFieldProps = { field: FieldWithSignature; loading?: boolean; children: React.ReactNode; - onSign?: () => Promise | void; + + /** + * A function that is called before the field requires to be signed, or reauthed. + * + * Example, you may want to show a dialog prior to signing where they can enter a value. + * + * Once that action is complete, you will need to call `executeActionAuthProcedure` to proceed + * regardless if it requires reauth or not. + * + * If the function returns true, we will proceed with the signing process. Otherwise if + * false is returned we will not proceed. + */ + onPreSign?: () => Promise | boolean; + + /** + * The function required to be executed to insert the field. + * + * The auth values will be passed in if available. + */ + onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise | void; onRemove?: () => Promise | void; type?: 'Date' | 'Email' | 'Name' | 'Signature'; tooltipText?: string | null; @@ -19,18 +42,56 @@ export type SignatureFieldProps = { export const SigningFieldContainer = ({ field, loading, + onPreSign, onSign, onRemove, children, type, tooltipText, }: SignatureFieldProps) => { - const onSignFieldClick = async () => { - if (field.inserted) { + const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext(); + + const handleInsertField = async () => { + if (field.inserted || !onSign) { return; } - await onSign?.(); + // Bypass reauth for non signature fields. + if (field.type !== FieldType.SIGNATURE) { + const presignResult = await onPreSign?.(); + + if (presignResult === false) { + return; + } + + await onSign(); + return; + } + + if (isAuthRedirectRequired) { + await executeActionAuthProcedure({ + onReauthFormSubmit: () => { + // Do nothing since the user should be redirected. + }, + actionTarget: field.type, + }); + + return; + } + + // Handle any presign requirements, and halt if required. + if (onPreSign) { + const preSignResult = await onPreSign(); + + if (preSignResult === false) { + return; + } + } + + await executeActionAuthProcedure({ + onReauthFormSubmit: onSign, + actionTarget: field.type, + }); }; const onRemoveSignedFieldClick = async () => { @@ -47,7 +108,7 @@ export const SigningFieldContainer = ({ diff --git a/apps/web/src/components/document/document-history-sheet.tsx b/apps/web/src/components/document/document-history-sheet.tsx index 0d0c56aa2..fa9046ce5 100644 --- a/apps/web/src/components/document/document-history-sheet.tsx +++ b/apps/web/src/components/document/document-history-sheet.tsx @@ -7,6 +7,7 @@ import { match } from 'ts-pattern'; import { UAParser } from 'ua-parser-js'; import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs'; +import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs'; import { trpc } from '@documenso/trpc/react'; @@ -79,7 +80,11 @@ export const DocumentHistorySheet = ({ * @param text The text to format * @returns The formatted text */ - const formatGenericText = (text: string) => { + const formatGenericText = (text?: string | null) => { + if (!text) { + return ''; + } + return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' '); }; @@ -219,6 +224,24 @@ export const DocumentHistorySheet = ({ /> ), ) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, + ({ data }) => ( + + ), + ) .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => { if (data.changes.length === 0) { return null; @@ -281,6 +304,7 @@ export const DocumentHistorySheet = ({ ]} /> )) + .exhaustive()} {isUserDetailsVisible && ( diff --git a/apps/web/src/components/form/form-error-message.tsx b/apps/web/src/components/form/form-error-message.tsx deleted file mode 100644 index 6fa7c32b0..000000000 --- a/apps/web/src/components/form/form-error-message.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { AnimatePresence, motion } from 'framer-motion'; - -import { cn } from '@documenso/ui/lib/utils'; - -export type FormErrorMessageProps = { - className?: string; - error: { message?: string } | undefined; -}; - -export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => { - return ( - - {error && ( - - {error.message} - - )} - - ); -}; diff --git a/packages/app-tests/e2e/document-auth/access-auth.spec.ts b/packages/app-tests/e2e/document-auth/access-auth.spec.ts new file mode 100644 index 000000000..0306689ce --- /dev/null +++ b/packages/app-tests/e2e/document-auth/access-auth.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from '@playwright/test'; + +import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth'; +import { prisma } from '@documenso/prisma'; +import { seedPendingDocument } from '@documenso/prisma/seed/documents'; +import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page }) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const document = await seedPendingDocument(user, [ + recipientWithAccount, + 'recipientwithoutaccount@documenso.com', + ]); + + const recipients = await prisma.recipient.findMany({ + where: { + documentId: document.id, + }, + }); + + const tokens = recipients.map((recipient) => recipient.token); + + for (const token of tokens) { + await page.goto(`/sign/${token}`); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + } + + await unseedUser(user.id); +}); + +test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page }) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const document = await seedPendingDocument( + user, + [recipientWithAccount, 'recipientwithoutaccount@documenso.com'], + { + createDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: 'ACCOUNT', + globalActionAuth: null, + }), + }, + }, + ); + + const recipients = await prisma.recipient.findMany({ + where: { + documentId: document.id, + }, + }); + + // Check that both are denied access. + for (const recipient of recipients) { + const { email, token } = recipient; + + await page.goto(`/sign/${token}`); + await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible(); + await expect(page.getByRole('paragraph')).toContainText(email); + } + + await apiSignin({ + page, + email: recipientWithAccount.email, + redirectPath: '/', + }); + + // Check that the one logged in is granted access. + for (const recipient of recipients) { + const { email, token } = recipient; + + await page.goto(`/sign/${token}`); + + // Recipient should be granted access. + if (recipient.email === recipientWithAccount.email) { + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + } + + // Recipient should still be denied. + if (recipient.email !== recipientWithAccount.email) { + await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible(); + await expect(page.getByRole('paragraph')).toContainText(email); + } + } + + await unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); diff --git a/packages/app-tests/e2e/document-auth/action-auth.spec.ts b/packages/app-tests/e2e/document-auth/action-auth.spec.ts new file mode 100644 index 000000000..88ed1ac1d --- /dev/null +++ b/packages/app-tests/e2e/document-auth/action-auth.spec.ts @@ -0,0 +1,418 @@ +import { expect, test } from '@playwright/test'; + +import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; +import { + createDocumentAuthOptions, + createRecipientAuthOptions, +} from '@documenso/lib/utils/document-auth'; +import { FieldType } from '@documenso/prisma/client'; +import { + seedPendingDocumentNoFields, + seedPendingDocumentWithFullFields, +} from '@documenso/prisma/seed/documents'; +import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin, apiSignout } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [recipientWithAccount, seedTestEmail()], + }); + + // Check that both are granted access. + for (const recipient of recipients) { + const { token, Field } = recipient; + + const signUrl = `/sign/${token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + // Add signature. + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4); + await page.mouse.up(); + } + + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.getByLabel('Custom Text').fill('TEXT'); + await page.getByRole('button', { name: 'Save Text' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true'); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + } + + await unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); + +test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ page }) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [recipientWithAccount], + updateDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: null, + globalActionAuth: 'ACCOUNT', + }), + }, + }); + + const recipient = recipients[0]; + + const { token, Field } = recipient; + + const signUrl = `/sign/${token}`; + + await apiSignin({ + page, + email: recipientWithAccount.email, + redirectPath: signUrl, + }); + + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + // Add signature. + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4); + await page.mouse.up(); + } + + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.getByLabel('Custom Text').fill('TEXT'); + await page.getByRole('button', { name: 'Save Text' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true'); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + + await unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); + +// Currently document auth for signing/approving/viewing is not required. +test.skip('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({ + page, +}) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const { recipients } = await seedPendingDocumentNoFields({ + owner: user, + recipients: [recipientWithAccount], + updateDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: null, + globalActionAuth: 'ACCOUNT', + }), + }, + }); + + const recipient = recipients[0]; + + const { token } = recipient; + + await page.goto(`/sign/${token}`); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + await page.getByRole('button', { name: 'Complete' }).click(); + await expect(page.getByRole('paragraph')).toContainText( + 'Reauthentication is required to sign the document', + ); + + await unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); + +test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth', async ({ + page, +}) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [recipientWithAccount, seedTestEmail()], + updateDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: null, + globalActionAuth: 'ACCOUNT', + }), + }, + }); + + // Check that both are denied access. + for (const recipient of recipients) { + const { token, Field } = recipient; + + await page.goto(`/sign/${token}`); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + for (const field of Field) { + if (field.type !== FieldType.SIGNATURE) { + continue; + } + + await page.locator(`#field-${field.id}`).getByRole('button').click(); + await expect(page.getByRole('paragraph')).toContainText( + 'Reauthentication is required to sign the field', + ); + await page.getByRole('button', { name: 'Cancel' }).click(); + } + } + + await unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); + +test('[DOCUMENT_AUTH]: should allow field signing when required for recipient auth', async ({ + page, +}) => { + const user = await seedUser(); + + const recipientWithInheritAuth = await seedUser(); + const recipientWithExplicitNoneAuth = await seedUser(); + const recipientWithExplicitAccountAuth = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [ + recipientWithInheritAuth, + recipientWithExplicitNoneAuth, + recipientWithExplicitAccountAuth, + ], + recipientsCreateOptions: [ + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: null, + }), + }, + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: 'EXPLICIT_NONE', + }), + }, + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: 'ACCOUNT', + }), + }, + ], + fields: [FieldType.DATE], + }); + + for (const recipient of recipients) { + const { token, Field } = recipient; + const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); + + // This document has no global action auth, so only account should require auth. + const isAuthRequired = actionAuth === 'ACCOUNT'; + + const signUrl = `/sign/${token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + if (isAuthRequired) { + for (const field of Field) { + if (field.type !== FieldType.SIGNATURE) { + continue; + } + + await page.locator(`#field-${field.id}`).getByRole('button').click(); + await expect(page.getByRole('paragraph')).toContainText( + 'Reauthentication is required to sign the field', + ); + await page.getByRole('button', { name: 'Cancel' }).click(); + } + + // Sign in and it should work. + await apiSignin({ + page, + email: recipient.email, + redirectPath: signUrl, + }); + } + + // Add signature. + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4); + await page.mouse.up(); + } + + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.getByLabel('Custom Text').fill('TEXT'); + await page.getByRole('button', { name: 'Save Text' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', { + timeout: 5000, + }); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + + if (isAuthRequired) { + await apiSignout({ page }); + } + } +}); + +test('[DOCUMENT_AUTH]: should allow field signing when required for recipient and global auth', async ({ + page, +}) => { + const user = await seedUser(); + + const recipientWithInheritAuth = await seedUser(); + const recipientWithExplicitNoneAuth = await seedUser(); + const recipientWithExplicitAccountAuth = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [ + recipientWithInheritAuth, + recipientWithExplicitNoneAuth, + recipientWithExplicitAccountAuth, + ], + recipientsCreateOptions: [ + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: null, + }), + }, + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: 'EXPLICIT_NONE', + }), + }, + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: 'ACCOUNT', + }), + }, + ], + fields: [FieldType.DATE], + updateDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: null, + globalActionAuth: 'ACCOUNT', + }), + }, + }); + + for (const recipient of recipients) { + const { token, Field } = recipient; + const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); + + // This document HAS global action auth, so account and inherit should require auth. + const isAuthRequired = actionAuth === 'ACCOUNT' || actionAuth === null; + + const signUrl = `/sign/${token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + if (isAuthRequired) { + for (const field of Field) { + if (field.type !== FieldType.SIGNATURE) { + continue; + } + + await page.locator(`#field-${field.id}`).getByRole('button').click(); + await expect(page.getByRole('paragraph')).toContainText( + 'Reauthentication is required to sign the field', + ); + await page.getByRole('button', { name: 'Cancel' }).click(); + } + + // Sign in and it should work. + await apiSignin({ + page, + email: recipient.email, + redirectPath: signUrl, + }); + } + + // Add signature. + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4); + await page.mouse.up(); + } + + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.getByLabel('Custom Text').fill('TEXT'); + await page.getByRole('button', { name: 'Save Text' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', { + timeout: 5000, + }); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + + if (isAuthRequired) { + await apiSignout({ page }); + } + } +}); diff --git a/packages/app-tests/e2e/document-flow/settings-step.spec.ts b/packages/app-tests/e2e/document-flow/settings-step.spec.ts new file mode 100644 index 000000000..b416baa7c --- /dev/null +++ b/packages/app-tests/e2e/document-flow/settings-step.spec.ts @@ -0,0 +1,200 @@ +import { expect, test } from '@playwright/test'; + +import { + seedBlankDocument, + seedDraftDocument, + seedPendingDocument, +} from '@documenso/prisma/seed/documents'; +import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; +import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; +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('[DOCUMENT_FLOW] add action auth settings', async ({ page }) => { + const user = await seedUser(); + + await seedUserSubscription({ + userId: user.id, + priceId: enterprisePriceId, + }); + + const document = await seedBlankDocument(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + // 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'); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).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(); + + // 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 unseedUser(user.id); + }); + + test('[DOCUMENT_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 document = await seedBlankDocument(owner, { + createDocumentOptions: { + teamId: team.id, + }, + }); + + await apiSignin({ + page, + email: teamMemberUser.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit`, + }); + + // 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'); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Advanced settings should be visible. + await expect(page.getByLabel('Show advanced settings')).toBeVisible(); + + await unseedTeam(team.url); + }); + + test('[DOCUMENT_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 document = await seedBlankDocument(teamMemberUser); + + await apiSignin({ + page, + email: teamMemberUser.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + // 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 Signers' })).toBeVisible(); + + // Advanced settings should not be visible. + await expect(page.getByLabel('Show advanced settings')).not.toBeVisible(); + + await unseedTeam(team.url); + }); +}); + +test('[DOCUMENT_FLOW]: add settings', async ({ page }) => { + const user = await seedUser(); + const document = await seedBlankDocument(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + // 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 Signers' })).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(); + + // 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 unseedUser(user.id); +}); + +test('[DOCUMENT_FLOW]: title should be disabled depending on document status', async ({ page }) => { + const user = await seedUser(); + + const pendingDocument = await seedPendingDocument(user, []); + const draftDocument = await seedDraftDocument(user, []); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${pendingDocument.id}/edit`, + }); + + // Should be disabled for pending documents. + await expect(page.getByLabel('Title')).toBeDisabled(); + + // Should be enabled for draft documents. + await page.goto(`/documents/${draftDocument.id}/edit`); + await expect(page.getByLabel('Title')).toBeEnabled(); + + 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 new file mode 100644 index 000000000..30d6ba11f --- /dev/null +++ b/packages/app-tests/e2e/document-flow/signers-step.spec.ts @@ -0,0 +1,118 @@ +import { expect, test } from '@playwright/test'; + +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; +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('[DOCUMENT_FLOW] add EE settings', async ({ page }) => { + const user = await seedUser(); + + await seedUserSubscription({ + userId: user.id, + priceId: enterprisePriceId, + }); + + const document = await seedBlankDocument(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).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 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'); + + // Display advanced settings. + await page.getByLabel('Show advanced settings').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(); + + // Todo: Fix stepper component back issue before finishing test. + + await unseedUser(user.id); + }); +}); + +// 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); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).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 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'); + + // Advanced settings should not be visible for non EE users. + await expect(page.getByLabel('Show advanced settings')).toBeHidden(); + + // 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(); + + // 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/fixtures/authentication.ts b/packages/app-tests/e2e/fixtures/authentication.ts index d59fccd1c..9f3a50756 100644 --- a/packages/app-tests/e2e/fixtures/authentication.ts +++ b/packages/app-tests/e2e/fixtures/authentication.ts @@ -1,8 +1,8 @@ -import type { Page } from '@playwright/test'; +import { type Page } from '@playwright/test'; import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; -type ManualLoginOptions = { +type LoginOptions = { page: Page; email?: string; password?: string; @@ -18,7 +18,7 @@ export const manualLogin = async ({ email = 'example@documenso.com', password = 'password', redirectPath, -}: ManualLoginOptions) => { +}: LoginOptions) => { await page.goto(`${WEBAPP_BASE_URL}/signin`); await page.getByLabel('Email').click(); @@ -33,9 +33,63 @@ export const manualLogin = async ({ } }; -export const manualSignout = async ({ page }: ManualLoginOptions) => { +export const manualSignout = async ({ page }: LoginOptions) => { await page.waitForTimeout(1000); await page.getByTestId('menu-switcher').click(); await page.getByRole('menuitem', { name: 'Sign Out' }).click(); await page.waitForURL(`${WEBAPP_BASE_URL}/signin`); }; + +export const apiSignin = async ({ + page, + email = 'example@documenso.com', + password = 'password', + redirectPath = '/', +}: LoginOptions) => { + const { request } = page.context(); + + const csrfToken = await getCsrfToken(page); + + await request.post(`${WEBAPP_BASE_URL}/api/auth/callback/credentials`, { + form: { + email, + password, + json: true, + csrfToken, + }, + }); + + if (redirectPath) { + await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`); + } +}; + +export const apiSignout = async ({ page }: { page: Page }) => { + const { request } = page.context(); + + const csrfToken = await getCsrfToken(page); + + await request.post(`${WEBAPP_BASE_URL}/api/auth/signout`, { + form: { + csrfToken, + json: true, + }, + }); + + await page.goto(`${WEBAPP_BASE_URL}/signin`); +}; + +const getCsrfToken = async (page: Page) => { + const { request } = page.context(); + + const response = await request.fetch(`${WEBAPP_BASE_URL}/api/auth/csrf`, { + method: 'get', + }); + + const { csrfToken } = await response.json(); + if (!csrfToken) { + throw new Error('Invalid session'); + } + + return csrfToken; +}; 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 4327935bb..70b0cfe72 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 @@ -4,17 +4,21 @@ 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'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from './fixtures/authentication'; test(`[PR-718]: should be able to create 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(); + const user = await seedUser(); + + await apiSignin({ + page, + email: user.email, + }); // Upload document const [fileChooser] = await Promise.all([ @@ -31,8 +35,8 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => { // Wait to be redirected to the edit page await page.waitForURL(/\/documents\/\d+/); - // Set title - await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible(); + // Set general settings + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await page.getByLabel('Title').fill(documentTitle); @@ -82,10 +86,12 @@ test('should be able to create a document with multiple recipients', async ({ pa 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(); + const user = await seedUser(); + + await apiSignin({ + page, + email: user.email, + }); // Upload document const [fileChooser] = await Promise.all([ @@ -103,7 +109,7 @@ test('should be able to create a document with multiple recipients', async ({ pa await page.waitForURL(/\/documents\/\d+/); // Set title - await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await page.getByLabel('Title').fill(documentTitle); @@ -112,13 +118,12 @@ test('should be able to create a document with multiple recipients', async ({ pa // 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'); - + // Add 2 signers. + await page.getByPlaceholder('Email').fill('user1@example.com'); + await page.getByPlaceholder('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('textbox', { name: 'Email', exact: true }).fill('user2@example.com'); + await page.getByRole('textbox', { name: 'Name', exact: true }).fill('User 2'); await page.getByRole('button', { name: 'Continue' }).click(); @@ -177,10 +182,12 @@ test('should be able to create, send and sign a document', async ({ page }) => { 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(); + const user = await seedUser(); + + await apiSignin({ + page, + email: user.email, + }); // Upload document const [fileChooser] = await Promise.all([ @@ -198,7 +205,7 @@ test('should be able to create, send and sign a document', async ({ page }) => { await page.waitForURL(/\/documents\/\d+/); // Set title - await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await page.getByLabel('Title').fill(documentTitle); @@ -207,8 +214,8 @@ test('should be able to create, send and sign a document', async ({ page }) => { // 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.getByPlaceholder('Email').fill('user1@example.com'); + await page.getByPlaceholder('Name').fill('User 1'); await page.getByRole('button', { name: 'Continue' }).click(); @@ -225,8 +232,9 @@ test('should be able to create, send and sign a document', async ({ page }) => { // Assert document was created await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); await page.getByRole('link', { name: documentTitle }).click(); + await page.waitForURL(/\/documents\/\d+/); - const url = await page.url().split('/'); + const url = page.url().split('/'); const documentId = url[url.length - 1]; const { token } = await getRecipientByEmail({ @@ -260,10 +268,12 @@ test('should be able to create, send with redirect url, sign a document and redi 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(); + const user = await seedUser(); + + await apiSignin({ + page, + email: user.email, + }); // Upload document const [fileChooser] = await Promise.all([ @@ -280,18 +290,19 @@ test('should be able to create, send with redirect url, sign a document and redi // Wait to be redirected to the edit page await page.waitForURL(/\/documents\/\d+/); - // Set title - await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible(); - + // Set title & advanced redirect + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await page.getByLabel('Title').fill(documentTitle); + await page.getByRole('button', { name: 'Advanced Options' }).click(); + await page.getByLabel('Redirect URL').fill('https://documenso.com'); 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.getByPlaceholder('Email').fill('user1@example.com'); + await page.getByPlaceholder('Name').fill('User 1'); await page.getByRole('button', { name: 'Continue' }).click(); @@ -299,11 +310,6 @@ test('should be able to create, send with redirect url, sign a document and redi 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'); @@ -311,8 +317,9 @@ test('should be able to create, send with redirect url, sign a document and redi // Assert document was created await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); await page.getByRole('link', { name: documentTitle }).click(); + await page.waitForURL(/\/documents\/\d+/); - const url = await page.url().split('/'); + const url = page.url().split('/'); const documentId = url[url.length - 1]; const { token } = await getRecipientByEmail({ diff --git a/packages/app-tests/e2e/teams/manage-team.spec.ts b/packages/app-tests/e2e/teams/manage-team.spec.ts index aed56b2bc..a1deb1995 100644 --- a/packages/app-tests/e2e/teams/manage-team.spec.ts +++ b/packages/app-tests/e2e/teams/manage-team.spec.ts @@ -4,14 +4,14 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; import { seedUser } from '@documenso/prisma/seed/users'; -import { manualLogin } from '../fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; test.describe.configure({ mode: 'parallel' }); test('[TEAMS]: create team', async ({ page }) => { const user = await seedUser(); - await manualLogin({ + await apiSignin({ page, email: user.email, redirectPath: '/settings/teams', @@ -38,7 +38,7 @@ test('[TEAMS]: create team', async ({ page }) => { test('[TEAMS]: delete team', async ({ page }) => { const team = await seedTeam(); - await manualLogin({ + await apiSignin({ page, email: team.owner.email, redirectPath: `/t/${team.url}/settings`, @@ -56,7 +56,7 @@ test('[TEAMS]: delete team', async ({ page }) => { test('[TEAMS]: update team', async ({ page }) => { const team = await seedTeam(); - await manualLogin({ + await apiSignin({ page, email: team.owner.email, }); diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts index 210189ca7..8f70befc8 100644 --- a/packages/app-tests/e2e/teams/team-documents.spec.ts +++ b/packages/app-tests/e2e/teams/team-documents.spec.ts @@ -6,7 +6,7 @@ import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documen import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams'; import { seedUser } from '@documenso/prisma/seed/users'; -import { manualLogin, manualSignout } from '../fixtures/authentication'; +import { apiSignin, apiSignout } from '../fixtures/authentication'; test.describe.configure({ mode: 'parallel' }); @@ -30,7 +30,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => { // Run the test twice, once with the team owner and once with a team member to ensure the counts are the same. for (const user of [team.owner, teamMember2]) { - await manualLogin({ + await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents`, @@ -55,7 +55,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => { await checkDocumentTabCount(page, 'Draft', 1); await checkDocumentTabCount(page, 'All', 3); - await manualSignout({ page }); + await apiSignout({ page }); } await unseedTeam(team.url); @@ -126,7 +126,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa // 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 manualLogin({ + await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents`, @@ -151,7 +151,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa await checkDocumentTabCount(page, 'Draft', 1); await checkDocumentTabCount(page, 'All', 3); - await manualSignout({ page }); + await apiSignout({ page }); } await unseedTeamEmail({ teamId: team.id }); @@ -216,7 +216,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa }, ]); - await manualLogin({ + await apiSignin({ page, email: teamMember2.email, redirectPath: `/t/${team.url}/documents`, @@ -248,7 +248,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa test('[TEAMS]: delete pending team document', async ({ page }) => { const { team, teamMember2: currentUser } = await seedTeamDocuments(); - await manualLogin({ + await apiSignin({ page, email: currentUser.email, redirectPath: `/t/${team.url}/documents?status=PENDING`, @@ -266,7 +266,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => { test('[TEAMS]: resend pending team document', async ({ page }) => { const { team, teamMember2: currentUser } = await seedTeamDocuments(); - await manualLogin({ + await apiSignin({ page, email: currentUser.email, redirectPath: `/t/${team.url}/documents?status=PENDING`, diff --git a/packages/app-tests/e2e/teams/team-email.spec.ts b/packages/app-tests/e2e/teams/team-email.spec.ts index 953be5aaf..6ae820f59 100644 --- a/packages/app-tests/e2e/teams/team-email.spec.ts +++ b/packages/app-tests/e2e/teams/team-email.spec.ts @@ -4,14 +4,14 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams'; import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; -import { manualLogin } from '../fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; test.describe.configure({ mode: 'parallel' }); test('[TEAMS]: send team email request', async ({ page }) => { const team = await seedTeam(); - await manualLogin({ + await apiSignin({ page, email: team.owner.email, password: 'password', @@ -57,7 +57,7 @@ test('[TEAMS]: delete team email', async ({ page }) => { createTeamEmail: true, }); - await manualLogin({ + await apiSignin({ page, email: team.owner.email, redirectPath: `/t/${team.url}/settings`, @@ -86,7 +86,7 @@ test('[TEAMS]: team email owner removes access', async ({ page }) => { email: team.teamEmail.email, }); - await manualLogin({ + await apiSignin({ page, email: teamEmailOwner.email, redirectPath: `/settings/teams`, diff --git a/packages/app-tests/e2e/teams/team-members.spec.ts b/packages/app-tests/e2e/teams/team-members.spec.ts index 05f096c09..c85717729 100644 --- a/packages/app-tests/e2e/teams/team-members.spec.ts +++ b/packages/app-tests/e2e/teams/team-members.spec.ts @@ -4,7 +4,7 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams'; import { seedUser } from '@documenso/prisma/seed/users'; -import { manualLogin } from '../fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; test.describe.configure({ mode: 'parallel' }); @@ -13,7 +13,7 @@ test('[TEAMS]: update team member role', async ({ page }) => { createTeamMembers: 1, }); - await manualLogin({ + await apiSignin({ page, email: team.owner.email, password: 'password', @@ -75,7 +75,7 @@ test('[TEAMS]: member can leave team', async ({ page }) => { const teamMember = team.members[1]; - await manualLogin({ + await apiSignin({ page, email: teamMember.user.email, password: 'password', @@ -97,7 +97,7 @@ test('[TEAMS]: owner cannot leave team', async ({ page }) => { createTeamMembers: 1, }); - await manualLogin({ + await apiSignin({ page, email: team.owner.email, password: 'password', diff --git a/packages/app-tests/e2e/teams/transfer-team.spec.ts b/packages/app-tests/e2e/teams/transfer-team.spec.ts index a5d95b720..c8460baf8 100644 --- a/packages/app-tests/e2e/teams/transfer-team.spec.ts +++ b/packages/app-tests/e2e/teams/transfer-team.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams'; -import { manualLogin } from '../fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; test.describe.configure({ mode: 'parallel' }); @@ -14,7 +14,7 @@ test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => { const teamMember = team.members[1]; - await manualLogin({ + await apiSignin({ page, email: team.owner.email, password: 'password', diff --git a/packages/app-tests/e2e/templates/manage-templates.spec.ts b/packages/app-tests/e2e/templates/manage-templates.spec.ts index f89583097..a89b308eb 100644 --- a/packages/app-tests/e2e/templates/manage-templates.spec.ts +++ b/packages/app-tests/e2e/templates/manage-templates.spec.ts @@ -4,7 +4,7 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; import { seedTemplate } from '@documenso/prisma/seed/templates'; -import { manualLogin } from '../fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; test.describe.configure({ mode: 'parallel' }); @@ -36,7 +36,7 @@ test('[TEMPLATES]: view templates', async ({ page }) => { teamId: team.id, }); - await manualLogin({ + await apiSignin({ page, email: owner.email, redirectPath: '/templates', @@ -81,7 +81,7 @@ test('[TEMPLATES]: delete template', async ({ page }) => { teamId: team.id, }); - await manualLogin({ + await apiSignin({ page, email: owner.email, redirectPath: '/templates', @@ -135,7 +135,7 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => { teamId: team.id, }); - await manualLogin({ + await apiSignin({ page, email: owner.email, redirectPath: '/templates', @@ -181,7 +181,7 @@ test('[TEMPLATES]: use template', async ({ page }) => { teamId: team.id, }); - await manualLogin({ + await apiSignin({ page, email: owner.email, redirectPath: '/templates', diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts index 672c2f7ef..0796bb1e1 100644 --- a/packages/app-tests/playwright.config.ts +++ b/packages/app-tests/playwright.config.ts @@ -1,10 +1,14 @@ import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// require('dotenv').config(); +const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`]; + +ENV_FILES.forEach((file) => { + dotenv.config({ + path: path.join(__dirname, `../../${file}`), + }); +}); /** * See https://playwright.dev/docs/test-configuration. diff --git a/packages/ee/server-only/util/is-document-enterprise.ts b/packages/ee/server-only/util/is-document-enterprise.ts new file mode 100644 index 000000000..01c2d7327 --- /dev/null +++ b/packages/ee/server-only/util/is-document-enterprise.ts @@ -0,0 +1,56 @@ +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { subscriptionsContainActiveEnterprisePlan } from '@documenso/lib/utils/billing'; +import { prisma } from '@documenso/prisma'; +import type { Subscription } from '@documenso/prisma/client'; + +export type IsUserEnterpriseOptions = { + userId: number; + teamId?: number; +}; + +/** + * Whether the user is enterprise, or has permission to use enterprise features on + * behalf of their team. + * + * It is assumed that the provided user is part of the provided team. + */ +export const isUserEnterprise = async ({ + userId, + teamId, +}: IsUserEnterpriseOptions): Promise => { + let subscriptions: Subscription[] = []; + + if (!IS_BILLING_ENABLED()) { + return false; + } + + if (teamId) { + subscriptions = await prisma.team + .findFirstOrThrow({ + where: { + id: teamId, + }, + select: { + owner: { + include: { + Subscription: true, + }, + }, + }, + }) + .then((team) => team.owner.Subscription); + } else { + subscriptions = await prisma.user + .findFirstOrThrow({ + where: { + id: userId, + }, + select: { + Subscription: true, + }, + }) + .then((user) => user.Subscription); + } + + return subscriptionsContainActiveEnterprisePlan(subscriptions); +}; diff --git a/packages/lib/constants/document-auth.ts b/packages/lib/constants/document-auth.ts new file mode 100644 index 000000000..81f22236e --- /dev/null +++ b/packages/lib/constants/document-auth.ts @@ -0,0 +1,31 @@ +import type { TDocumentAuth } from '../types/document-auth'; +import { DocumentAuth } from '../types/document-auth'; + +type DocumentAuthTypeData = { + key: TDocumentAuth; + value: string; + + /** + * Whether this authentication event will require the user to halt and + * redirect. + * + * Defaults to false. + */ + isAuthRedirectRequired?: boolean; +}; + +export const DOCUMENT_AUTH_TYPES: Record = { + [DocumentAuth.ACCOUNT]: { + key: DocumentAuth.ACCOUNT, + value: 'Require account', + isAuthRedirectRequired: true, + }, + // [DocumentAuthType.PASSKEY]: { + // key: DocumentAuthType.PASSKEY, + // value: 'Require passkey', + // }, + [DocumentAuth.EXPLICIT_NONE]: { + key: DocumentAuth.EXPLICIT_NONE, + value: 'None (Overrides global settings)', + }, +} satisfies Record; diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index f43f9c3ba..120df5ed6 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -137,12 +137,16 @@ export class AppError extends Error { } static parseFromJSONString(jsonString: string): AppError | null { - const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString)); + try { + const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString)); - if (!parsed.success) { + if (!parsed.success) { + return null; + } + + return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage); + } catch { return null; } - - return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage); } } 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 5f58c5183..8e3b56002 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -7,6 +7,7 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; +import type { TRecipientActionAuth } from '../../types/document-auth'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { sealDocument } from './seal-document'; import { sendPendingEmail } from './send-pending-email'; @@ -14,6 +15,8 @@ import { sendPendingEmail } from './send-pending-email'; export type CompleteDocumentWithTokenOptions = { token: string; documentId: number; + userId?: number; + authOptions?: TRecipientActionAuth; requestMetadata?: RequestMetadata; }; @@ -71,32 +74,54 @@ export const completeDocumentWithToken = async ({ throw new Error(`Recipient ${recipient.id} has unsigned fields`); } - await prisma.recipient.update({ - where: { - id: recipient.id, - }, - data: { - signingStatus: SigningStatus.SIGNED, - signedAt: new Date(), - }, - }); + // Document reauth for completing documents is currently not required. - await prisma.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, - documentId: document.id, - user: { - name: recipient.name, - email: recipient.email, + // const { derivedRecipientActionAuth } = extractDocumentAuthMethods({ + // documentAuth: document.authOptions, + // recipientAuth: recipient.authOptions, + // }); + + // const isValid = await isRecipientAuthorized({ + // type: 'ACTION', + // document: document, + // recipient: recipient, + // userId, + // authOptions, + // }); + + // if (!isValid) { + // throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values'); + // } + + await prisma.$transaction(async (tx) => { + await tx.recipient.update({ + where: { + id: recipient.id, }, - requestMetadata, data: { - recipientEmail: recipient.email, - recipientName: recipient.name, - recipientId: recipient.id, - recipientRole: recipient.role, + signingStatus: SigningStatus.SIGNED, + signedAt: new Date(), }, - }), + }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, + documentId: document.id, + user: { + name: recipient.name, + email: recipient.email, + }, + requestMetadata, + data: { + recipientEmail: recipient.email, + recipientName: recipient.name, + recipientId: recipient.id, + recipientRole: recipient.role, + // actionAuth: derivedRecipientActionAuth || undefined, + }, + }), + }); }); const pendingRecipients = await prisma.recipient.count({ 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 1594efbe4..6add46c1d 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,39 @@ import { prisma } from '@documenso/prisma'; import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; +import { AppError, AppErrorCode } from '../../errors/app-error'; +import type { TDocumentAuthMethods } from '../../types/document-auth'; +import { isRecipientAuthorized } from './is-recipient-authorized'; + +export interface GetDocumentAndSenderByTokenOptions { + token: string; + userId?: number; + accessAuth?: TDocumentAuthMethods; + + /** + * Whether we enforce the access requirement. + * + * Defaults to true. + */ + requireAccessAuth?: boolean; +} + +export interface GetDocumentAndRecipientByTokenOptions { + token: string; + userId?: number; + accessAuth?: TDocumentAuthMethods; + + /** + * Whether we enforce the access requirement. + * + * Defaults to true. + */ + requireAccessAuth?: boolean; +} export type GetDocumentByTokenOptions = { token: string; }; -export type GetDocumentAndSenderByTokenOptions = GetDocumentByTokenOptions; -export type GetDocumentAndRecipientByTokenOptions = GetDocumentByTokenOptions; - export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) => { if (!token) { throw new Error('Missing token'); @@ -26,8 +52,13 @@ export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) = return result; }; +export type DocumentAndSender = Awaited>; + export const getDocumentAndSenderByToken = async ({ token, + userId, + accessAuth, + requireAccessAuth = true, }: GetDocumentAndSenderByTokenOptions) => { if (!token) { throw new Error('Missing token'); @@ -45,12 +76,40 @@ export const getDocumentAndSenderByToken = async ({ User: true, documentData: true, documentMeta: true, + Recipient: { + where: { + token, + }, + }, }, }); // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars const { password: _password, ...User } = result.User; + const recipient = result.Recipient[0]; + + // Sanity check, should not be possible. + if (!recipient) { + throw new Error('Missing recipient'); + } + + let documentAccessValid = true; + + if (requireAccessAuth) { + documentAccessValid = await isRecipientAuthorized({ + type: 'ACCESS', + document: result, + recipient, + userId, + authOptions: accessAuth, + }); + } + + if (!documentAccessValid) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values'); + } + return { ...result, User, @@ -62,6 +121,9 @@ export const getDocumentAndSenderByToken = async ({ */ export const getDocumentAndRecipientByToken = async ({ token, + userId, + accessAuth, + requireAccessAuth = true, }: GetDocumentAndRecipientByTokenOptions): Promise => { if (!token) { throw new Error('Missing token'); @@ -85,6 +147,29 @@ export const getDocumentAndRecipientByToken = async ({ }, }); + const recipient = result.Recipient[0]; + + // Sanity check, should not be possible. + if (!recipient) { + throw new Error('Missing recipient'); + } + + let documentAccessValid = true; + + if (requireAccessAuth) { + documentAccessValid = await isRecipientAuthorized({ + type: 'ACCESS', + document: result, + recipient, + userId, + authOptions: accessAuth, + }); + } + + if (!documentAccessValid) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values'); + } + return { ...result, Recipient: result.Recipient, diff --git a/packages/lib/server-only/document/is-recipient-authorized.ts b/packages/lib/server-only/document/is-recipient-authorized.ts new file mode 100644 index 000000000..2c7e9b6e4 --- /dev/null +++ b/packages/lib/server-only/document/is-recipient-authorized.ts @@ -0,0 +1,86 @@ +import { match } from 'ts-pattern'; + +import { prisma } from '@documenso/prisma'; +import type { Document, Recipient } from '@documenso/prisma/client'; + +import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth'; +import { DocumentAuth } from '../../types/document-auth'; +import { extractDocumentAuthMethods } from '../../utils/document-auth'; + +type IsRecipientAuthorizedOptions = { + type: 'ACCESS' | 'ACTION'; + document: Document; + recipient: Recipient; + + /** + * The ID of the user who initiated the request. + */ + userId?: number; + + /** + * The auth details to check. + * + * Optional because there are scenarios where no auth options are required such as + * using the user ID. + */ + authOptions?: TDocumentAuthMethods; +}; + +const getUserByEmail = async (email: string) => { + return await prisma.user.findFirst({ + where: { + email, + }, + select: { + id: true, + }, + }); +}; + +/** + * Whether the recipient is authorized to perform the requested operation on a + * document, given the provided auth options. + * + * @returns True if the recipient can perform the requested operation. + */ +export const isRecipientAuthorized = async ({ + type, + document, + recipient, + userId, + authOptions, +}: IsRecipientAuthorizedOptions): Promise => { + const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }); + + const authMethod: TDocumentAuth | null = + type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth; + + // Early true return when auth is not required. + if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) { + return true; + } + + // Authentication required does not match provided method. + if (authOptions && authOptions.type !== authMethod) { + return false; + } + + return await match(authMethod) + .with(DocumentAuth.ACCOUNT, async () => { + if (userId === undefined) { + return false; + } + + const recipientUser = await getUserByEmail(recipient.email); + + if (!recipientUser) { + return false; + } + + return recipientUser.id === userId; + }) + .exhaustive(); +}; diff --git a/packages/lib/server-only/document/send-completed-email.ts b/packages/lib/server-only/document/send-completed-email.ts index 7ff99bbdf..a397e47e7 100644 --- a/packages/lib/server-only/document/send-completed-email.ts +++ b/packages/lib/server-only/document/send-completed-email.ts @@ -95,7 +95,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo data: { emailType: 'DOCUMENT_COMPLETED', recipientEmail: owner.email, - recipientName: owner.name, + recipientName: owner.name ?? '', recipientId: owner.id, recipientRole: 'OWNER', isResending: false, diff --git a/packages/lib/server-only/document/update-document-settings.ts b/packages/lib/server-only/document/update-document-settings.ts new file mode 100644 index 000000000..73e0eec3b --- /dev/null +++ b/packages/lib/server-only/document/update-document-settings.ts @@ -0,0 +1,178 @@ +'use server'; + +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs'; +import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; +import { prisma } from '@documenso/prisma'; +import { DocumentStatus } 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 UpdateDocumentSettingsOptions = { + userId: number; + teamId?: number; + documentId: number; + data: { + title?: string; + globalAccessAuth?: TDocumentAccessAuthTypes | null; + globalActionAuth?: TDocumentActionAuthTypes | null; + }; + requestMetadata?: RequestMetadata; +}; + +export const updateDocumentSettings = async ({ + userId, + teamId, + documentId, + data, + requestMetadata, +}: UpdateDocumentSettingsOptions) => { + if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) { + throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update'); + } + + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + + const document = await prisma.document.findFirstOrThrow({ + where: { + id: documentId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + }); + + const { documentAuthOption } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + }); + + 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 isTitleSame = data.title === document.title; + const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth; + const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth; + + const auditLogs: CreateDocumentAuditLogDataResponse[] = []; + + if (!isTitleSame && document.status !== DocumentStatus.DRAFT) { + throw new AppError( + AppErrorCode.INVALID_BODY, + 'You cannot update the title if the document has been sent', + ); + } + + if (!isTitleSame) { + auditLogs.push( + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED, + documentId, + user, + requestMetadata, + data: { + from: document.title, + to: data.title || '', + }, + }), + ); + } + + if (!isGlobalAccessSame) { + auditLogs.push( + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED, + documentId, + user, + requestMetadata, + data: { + from: documentGlobalAccessAuth, + to: newGlobalAccessAuth, + }, + }), + ); + } + + if (!isGlobalActionSame) { + auditLogs.push( + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED, + documentId, + user, + requestMetadata, + data: { + from: documentGlobalActionAuth, + to: newGlobalActionAuth, + }, + }), + ); + } + + // Early return if nothing is required. + if (auditLogs.length === 0) { + return document; + } + + return await prisma.$transaction(async (tx) => { + const authOptions = createDocumentAuthOptions({ + globalAccessAuth: newGlobalAccessAuth, + globalActionAuth: newGlobalActionAuth, + }); + + const updatedDocument = await tx.document.update({ + where: { + id: documentId, + }, + data: { + title: data.title, + authOptions, + }, + }); + + await tx.documentAuditLog.createMany({ + data: auditLogs, + }); + + return updatedDocument; + }); +}; diff --git a/packages/lib/server-only/document/viewed-document.ts b/packages/lib/server-only/document/viewed-document.ts index 9722b4fbf..73ca606cc 100644 --- a/packages/lib/server-only/document/viewed-document.ts +++ b/packages/lib/server-only/document/viewed-document.ts @@ -5,15 +5,21 @@ import { prisma } from '@documenso/prisma'; import { ReadStatus } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; +import type { TDocumentAccessAuthTypes } from '../../types/document-auth'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { getDocumentAndRecipientByToken } from './get-document-by-token'; export type ViewedDocumentOptions = { token: string; + recipientAccessAuth?: TDocumentAccessAuthTypes | null; requestMetadata?: RequestMetadata; }; -export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentOptions) => { +export const viewedDocument = async ({ + token, + recipientAccessAuth, + requestMetadata, +}: ViewedDocumentOptions) => { const recipient = await prisma.recipient.findFirst({ where: { token, @@ -51,12 +57,13 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO recipientId: recipient.id, recipientName: recipient.name, recipientRole: recipient.role, + accessAuth: recipientAccessAuth || undefined, }, }), }); }); - const document = await getDocumentAndRecipientByToken({ token }); + const document = await getDocumentAndRecipientByToken({ token, requireAccessAuth: false }); await triggerWebhook({ event: WebhookTriggerEvents.DOCUMENT_OPENED, 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 aa3056f52..b8a5ccf8f 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -8,15 +8,21 @@ import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/clie import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; +import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import type { TRecipientActionAuth } from '../../types/document-auth'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; +import { extractDocumentAuthMethods } from '../../utils/document-auth'; +import { isRecipientAuthorized } from '../document/is-recipient-authorized'; export type SignFieldWithTokenOptions = { token: string; fieldId: number; value: string; isBase64?: boolean; + userId?: number; + authOptions?: TRecipientActionAuth; requestMetadata?: RequestMetadata; }; @@ -25,6 +31,8 @@ export const signFieldWithToken = async ({ fieldId, value, isBase64, + userId, + authOptions, requestMetadata, }: SignFieldWithTokenOptions) => { const field = await prisma.field.findFirstOrThrow({ @@ -71,6 +79,33 @@ export const signFieldWithToken = async ({ throw new Error(`Field ${fieldId} has no recipientId`); } + let { derivedRecipientActionAuth } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }); + + // Override all non-signature fields to not require any auth. + if (field.type !== FieldType.SIGNATURE) { + derivedRecipientActionAuth = null; + } + + let isValid = true; + + // Only require auth on signature fields for now. + if (field.type === FieldType.SIGNATURE) { + isValid = await isRecipientAuthorized({ + type: 'ACTION', + document: document, + recipient: recipient, + userId, + authOptions, + }); + } + + if (!isValid) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values'); + } + const documentMeta = await prisma.documentMeta.findFirst({ where: { documentId: document.id, @@ -158,9 +193,11 @@ export const signFieldWithToken = async ({ data: updatedField.customText, })) .exhaustive(), - fieldSecurity: { - type: 'NONE', - }, + fieldSecurity: derivedRecipientActionAuth + ? { + type: derivedRecipientActionAuth, + } + : undefined, }, }), }); diff --git a/packages/lib/server-only/field/update-field.ts b/packages/lib/server-only/field/update-field.ts index b59760cd2..84358d245 100644 --- a/packages/lib/server-only/field/update-field.ts +++ b/packages/lib/server-only/field/update-field.ts @@ -1,8 +1,9 @@ import { prisma } from '@documenso/prisma'; import type { FieldType, Team } from '@documenso/prisma/client'; +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 { createDocumentAuditLogData, diffFieldChanges } from '../../utils/document-audit-logs'; export type UpdateFieldOptions = { fieldId: number; @@ -33,7 +34,7 @@ export const updateField = async ({ pageHeight, requestMetadata, }: UpdateFieldOptions) => { - const field = await prisma.field.update({ + const oldField = await prisma.field.findFirstOrThrow({ where: { id: fieldId, Document: { @@ -55,23 +56,49 @@ export const updateField = async ({ }), }, }, - data: { - recipientId, - type, - page: pageNumber, - positionX: pageX, - positionY: pageY, - width: pageWidth, - height: pageHeight, - }, - include: { - Recipient: true, - }, }); - if (!field) { - throw new Error('Field not found'); - } + const field = prisma.$transaction(async (tx) => { + const updatedField = await tx.field.update({ + where: { + id: fieldId, + }, + data: { + recipientId, + type, + page: pageNumber, + positionX: pageX, + positionY: pageY, + width: pageWidth, + height: pageHeight, + }, + include: { + Recipient: true, + }, + }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED, + documentId, + user: { + id: team?.id ?? user.id, + email: team?.name ?? user.email, + name: team ? '' : user.name, + }, + data: { + fieldId: updatedField.secondaryId, + fieldRecipientEmail: updatedField.Recipient?.email ?? '', + fieldRecipientId: recipientId ?? -1, + fieldType: updatedField.type, + changes: diffFieldChanges(oldField, updatedField), + }, + requestMetadata, + }), + }); + + return updatedField; + }); const user = await prisma.user.findFirstOrThrow({ where: { @@ -99,24 +126,5 @@ export const updateField = async ({ }); } - await prisma.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: 'FIELD_UPDATED', - documentId, - user: { - id: team?.id ?? user.id, - email: team?.name ?? user.email, - name: team ? '' : user.name, - }, - data: { - fieldId: field.secondaryId, - fieldRecipientEmail: field.Recipient?.email ?? '', - fieldRecipientId: recipientId ?? -1, - fieldType: field.type, - }, - requestMetadata, - }), - }); - return field; }; diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts index f9f8426bc..2dfc599ef 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -1,15 +1,23 @@ +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { + type TRecipientActionAuthTypes, + ZRecipientAuthOptionsSchema, +} from '@documenso/lib/types/document-auth'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { nanoid } from '@documenso/lib/universal/id'; import { createDocumentAuditLogData, diffRecipientChanges, } from '@documenso/lib/utils/document-audit-logs'; +import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth'; import { prisma } from '@documenso/prisma'; import type { Recipient } from '@documenso/prisma/client'; import { RecipientRole } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client'; +import { AppError, AppErrorCode } from '../../errors/app-error'; + export interface SetRecipientsForDocumentOptions { userId: number; teamId?: number; @@ -19,6 +27,7 @@ export interface SetRecipientsForDocumentOptions { email: string; name: string; role: RecipientRole; + actionAuth?: TRecipientActionAuthTypes | null; }[]; requestMetadata?: RequestMetadata; } @@ -70,6 +79,23 @@ export const setRecipientsForDocument = async ({ throw new Error('Document already complete'); } + 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(), @@ -112,6 +138,15 @@ export const setRecipientsForDocument = async ({ 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, @@ -125,6 +160,7 @@ export const setRecipientsForDocument = async ({ sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, + authOptions, }, create: { name: recipient.name, @@ -135,6 +171,7 @@ export const setRecipientsForDocument = async ({ sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, + authOptions, }, }); @@ -188,7 +225,10 @@ export const setRecipientsForDocument = async ({ documentId: documentId, user, requestMetadata, - data: baseAuditLog, + data: { + ...baseAuditLog, + actionAuth: recipient.actionAuth || undefined, + }, }), }); } diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index 14d594786..4b37aa485 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -8,6 +8,8 @@ import { z } from 'zod'; import { FieldType } from '@documenso/prisma/client'; +import { ZRecipientActionAuthTypesSchema } from './document-auth'; + export const ZDocumentAuditLogTypeSchema = z.enum([ // Document actions. 'EMAIL_SENT', @@ -26,6 +28,8 @@ export const ZDocumentAuditLogTypeSchema = z.enum([ 'DOCUMENT_DELETED', // When the document is soft deleted. 'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient. 'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient. + 'DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED', // When the global access authentication is updated. + 'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated. 'DOCUMENT_META_UPDATED', // When the document meta data is updated. 'DOCUMENT_OPENED', // When the document is opened by a recipient. 'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document. @@ -51,7 +55,13 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([ ]); export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']); -export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']); +export const ZRecipientDiffTypeSchema = z.enum([ + 'NAME', + 'ROLE', + 'EMAIL', + 'ACCESS_AUTH', + 'ACTION_AUTH', +]); export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum; export const DOCUMENT_EMAIL_TYPE = ZDocumentAuditLogEmailTypeSchema.Enum; @@ -107,25 +117,34 @@ export const ZDocumentAuditLogFieldDiffSchema = z.union([ ZFieldDiffPositionSchema, ]); -export const ZRecipientDiffNameSchema = z.object({ +export const ZGenericFromToSchema = z.object({ + from: z.string().nullable(), + to: z.string().nullable(), +}); + +export const ZRecipientDiffActionAuthSchema = ZGenericFromToSchema.extend({ + type: z.literal(RECIPIENT_DIFF_TYPE.ACCESS_AUTH), +}); + +export const ZRecipientDiffAccessAuthSchema = ZGenericFromToSchema.extend({ + type: z.literal(RECIPIENT_DIFF_TYPE.ACTION_AUTH), +}); + +export const ZRecipientDiffNameSchema = ZGenericFromToSchema.extend({ type: z.literal(RECIPIENT_DIFF_TYPE.NAME), - from: z.string(), - to: z.string(), }); -export const ZRecipientDiffRoleSchema = z.object({ +export const ZRecipientDiffRoleSchema = ZGenericFromToSchema.extend({ type: z.literal(RECIPIENT_DIFF_TYPE.ROLE), - from: z.string(), - to: z.string(), }); -export const ZRecipientDiffEmailSchema = z.object({ +export const ZRecipientDiffEmailSchema = ZGenericFromToSchema.extend({ type: z.literal(RECIPIENT_DIFF_TYPE.EMAIL), - from: z.string(), - to: z.string(), }); -export const ZDocumentAuditLogRecipientDiffSchema = z.union([ +export const ZDocumentAuditLogRecipientDiffSchema = z.discriminatedUnion('type', [ + ZRecipientDiffActionAuthSchema, + ZRecipientDiffAccessAuthSchema, ZRecipientDiffNameSchema, ZRecipientDiffRoleSchema, ZRecipientDiffEmailSchema, @@ -217,11 +236,11 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({ data: z.string(), }), ]), - - // Todo: Replace with union once we have more field security types. - fieldSecurity: z.object({ - type: z.literal('NONE'), - }), + fieldSecurity: z + .object({ + type: ZRecipientActionAuthTypesSchema, + }) + .optional(), }), }); @@ -236,6 +255,22 @@ export const ZDocumentAuditLogEventDocumentFieldUninsertedSchema = z.object({ }), }); +/** + * Event: Document global authentication access updated. + */ +export const ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED), + data: ZGenericFromToSchema, +}); + +/** + * Event: Document global authentication action updated. + */ +export const ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED), + data: ZGenericFromToSchema, +}); + /** * Event: Document meta updated. */ @@ -251,7 +286,9 @@ export const ZDocumentAuditLogEventDocumentMetaUpdatedSchema = z.object({ */ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED), - data: ZBaseRecipientDataSchema, + data: ZBaseRecipientDataSchema.extend({ + accessAuth: z.string().optional(), + }), }); /** @@ -259,7 +296,9 @@ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({ */ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED), - data: ZBaseRecipientDataSchema, + data: ZBaseRecipientDataSchema.extend({ + actionAuth: z.string().optional(), + }), }); /** @@ -303,7 +342,9 @@ export const ZDocumentAuditLogEventFieldRemovedSchema = z.object({ export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED), data: ZBaseFieldEventDataSchema.extend({ - changes: z.array(ZDocumentAuditLogFieldDiffSchema), + // Provide an empty array as a migration workaround due to a mistake where we were + // not passing through any changes via API/v1 due to a type error. + changes: z.preprocess((x) => x || [], z.array(ZDocumentAuditLogFieldDiffSchema)), }), }); @@ -312,7 +353,9 @@ export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({ */ export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED), - data: ZBaseRecipientDataSchema, + data: ZBaseRecipientDataSchema.extend({ + actionAuth: ZRecipientActionAuthTypesSchema.optional(), + }), }); /** @@ -352,6 +395,8 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( ZDocumentAuditLogEventDocumentDeletedSchema, ZDocumentAuditLogEventDocumentFieldInsertedSchema, ZDocumentAuditLogEventDocumentFieldUninsertedSchema, + ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema, + ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema, ZDocumentAuditLogEventDocumentMetaUpdatedSchema, ZDocumentAuditLogEventDocumentOpenedSchema, ZDocumentAuditLogEventDocumentRecipientCompleteSchema, diff --git a/packages/lib/types/document-auth.ts b/packages/lib/types/document-auth.ts new file mode 100644 index 000000000..730806d0c --- /dev/null +++ b/packages/lib/types/document-auth.ts @@ -0,0 +1,121 @@ +import { z } from 'zod'; + +/** + * All the available types of document authentication options for both access and action. + */ +export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'EXPLICIT_NONE']); +export const DocumentAuth = ZDocumentAuthTypesSchema.Enum; + +const ZDocumentAuthAccountSchema = z.object({ + type: z.literal(DocumentAuth.ACCOUNT), +}); + +const ZDocumentAuthExplicitNoneSchema = z.object({ + type: z.literal(DocumentAuth.EXPLICIT_NONE), +}); + +/** + * All the document auth methods for both accessing and actioning. + */ +export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [ + ZDocumentAuthAccountSchema, + ZDocumentAuthExplicitNoneSchema, +]); + +/** + * The global document access auth methods. + * + * Must keep these two in sync. + */ +export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); +export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); + +/** + * The global document action auth methods. + * + * Must keep these two in sync. + */ +export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); // Todo: Add passkeys here. +export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); + +/** + * The recipient access auth methods. + * + * Must keep these two in sync. + */ +export const ZRecipientAccessAuthSchema = z.discriminatedUnion('type', [ + ZDocumentAuthAccountSchema, +]); +export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); + +/** + * The recipient action auth methods. + * + * Must keep these two in sync. + */ +export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [ + ZDocumentAuthAccountSchema, // Todo: Add passkeys here. + ZDocumentAuthExplicitNoneSchema, +]); +export const ZRecipientActionAuthTypesSchema = z.enum([ + DocumentAuth.ACCOUNT, + DocumentAuth.EXPLICIT_NONE, +]); + +export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum; +export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum; +export const RecipientAccessAuth = ZRecipientAccessAuthTypesSchema.Enum; +export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum; + +/** + * Authentication options attached to the document. + */ +export const ZDocumentAuthOptionsSchema = z.preprocess( + (unknownValue) => { + if (unknownValue) { + return unknownValue; + } + + return { + globalAccessAuth: null, + globalActionAuth: null, + }; + }, + z.object({ + globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable(), + globalActionAuth: ZDocumentActionAuthTypesSchema.nullable(), + }), +); + +/** + * Authentication options attached to the recipient. + */ +export const ZRecipientAuthOptionsSchema = z.preprocess( + (unknownValue) => { + if (unknownValue) { + return unknownValue; + } + + return { + accessAuth: null, + actionAuth: null, + }; + }, + z.object({ + accessAuth: ZRecipientAccessAuthTypesSchema.nullable(), + actionAuth: ZRecipientActionAuthTypesSchema.nullable(), + }), +); + +export type TDocumentAuth = z.infer; +export type TDocumentAuthMethods = z.infer; +export type TDocumentAuthOptions = z.infer; +export type TDocumentAccessAuth = z.infer; +export type TDocumentAccessAuthTypes = z.infer; +export type TDocumentActionAuth = z.infer; +export type TDocumentActionAuthTypes = z.infer; +export type TRecipientAccessAuth = z.infer; +export type TRecipientAccessAuthTypes = z.infer; +export type TRecipientActionAuth = z.infer; +export type TRecipientActionAuthTypes = z.infer; +export type TRecipientAuthOptions = z.infer; diff --git a/packages/lib/utils/billing.ts b/packages/lib/utils/billing.ts index 048fa6ee0..6d2926420 100644 --- a/packages/lib/utils/billing.ts +++ b/packages/lib/utils/billing.ts @@ -1,3 +1,6 @@ +import { env } from 'next-runtime-env'; + +import { IS_BILLING_ENABLED } from '../constants/app'; import type { Subscription } from '.prisma/client'; import { SubscriptionStatus } from '.prisma/client'; @@ -13,3 +16,15 @@ export const subscriptionsContainsActivePlan = ( subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId), ); }; + +export const subscriptionsContainActiveEnterprisePlan = ( + subscriptions?: Subscription[], +): boolean => { + const enterprisePlanId = env('NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID'); + + if (!enterprisePlanId || !subscriptions || !IS_BILLING_ENABLED()) { + return false; + } + + return subscriptionsContainsActivePlan(subscriptions, [enterprisePlanId]); +}; diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index 65ffb2817..97ef38c8b 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -22,6 +22,7 @@ import { RECIPIENT_DIFF_TYPE, ZDocumentAuditLogSchema, } from '../types/document-audit-logs'; +import { ZRecipientAuthOptionsSchema } from '../types/document-auth'; import type { RequestMetadata } from '../universal/extract-request-metadata'; type CreateDocumentAuditLogDataOptions = { @@ -32,20 +33,20 @@ type CreateDocumentAuditLogDataOptions = { requestMetadata?: RequestMetadata; }; -type CreateDocumentAuditLogDataResponse = Pick< +export type CreateDocumentAuditLogDataResponse = Pick< DocumentAuditLog, 'type' | 'ipAddress' | 'userAgent' | 'email' | 'userId' | 'name' | 'documentId' > & { data: TDocumentAuditLog['data']; }; -export const createDocumentAuditLogData = ({ +export const createDocumentAuditLogData = ({ documentId, type, data, user, requestMetadata, -}: CreateDocumentAuditLogDataOptions): CreateDocumentAuditLogDataResponse => { +}: CreateDocumentAuditLogDataOptions): CreateDocumentAuditLogDataResponse => { return { type, data, @@ -68,6 +69,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument // Handle any required migrations here. if (!data.success) { + // Todo: Alert us. console.error(data.error); throw new Error('Migration required'); } @@ -75,7 +77,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument return data.data; }; -type PartialRecipient = Pick; +type PartialRecipient = Pick; export const diffRecipientChanges = ( oldRecipient: PartialRecipient, @@ -83,6 +85,32 @@ export const diffRecipientChanges = ( ): TDocumentAuditLogRecipientDiffSchema[] => { const diffs: TDocumentAuditLogRecipientDiffSchema[] = []; + const oldAuthOptions = ZRecipientAuthOptionsSchema.parse(oldRecipient.authOptions); + const oldAccessAuth = oldAuthOptions.accessAuth; + const oldActionAuth = oldAuthOptions.actionAuth; + + const newAuthOptions = ZRecipientAuthOptionsSchema.parse(newRecipient.authOptions); + const newAccessAuth = + newAuthOptions?.accessAuth === undefined ? oldAccessAuth : newAuthOptions.accessAuth; + const newActionAuth = + newAuthOptions?.actionAuth === undefined ? oldActionAuth : newAuthOptions.actionAuth; + + if (oldAccessAuth !== newAccessAuth) { + diffs.push({ + type: RECIPIENT_DIFF_TYPE.ACCESS_AUTH, + from: oldAccessAuth ?? '', + to: newAccessAuth ?? '', + }); + } + + if (oldActionAuth !== newActionAuth) { + diffs.push({ + type: RECIPIENT_DIFF_TYPE.ACTION_AUTH, + from: oldActionAuth ?? '', + to: newActionAuth ?? '', + }); + } + if (oldRecipient.email !== newRecipient.email) { diffs.push({ type: RECIPIENT_DIFF_TYPE.EMAIL, @@ -166,7 +194,13 @@ export const diffDocumentMetaChanges = ( const oldPassword = oldData?.password ?? null; const oldRedirectUrl = oldData?.redirectUrl ?? ''; - if (oldDateFormat !== newData.dateFormat) { + const newDateFormat = newData?.dateFormat ?? ''; + const newMessage = newData?.message ?? ''; + const newSubject = newData?.subject ?? ''; + const newTimezone = newData?.timezone ?? ''; + const newRedirectUrl = newData?.redirectUrl ?? ''; + + if (oldDateFormat !== newDateFormat) { diffs.push({ type: DOCUMENT_META_DIFF_TYPE.DATE_FORMAT, from: oldData?.dateFormat ?? '', @@ -174,35 +208,35 @@ export const diffDocumentMetaChanges = ( }); } - if (oldMessage !== newData.message) { + if (oldMessage !== newMessage) { diffs.push({ type: DOCUMENT_META_DIFF_TYPE.MESSAGE, from: oldMessage, - to: newData.message, + to: newMessage, }); } - if (oldSubject !== newData.subject) { + if (oldSubject !== newSubject) { diffs.push({ type: DOCUMENT_META_DIFF_TYPE.SUBJECT, from: oldSubject, - to: newData.subject, + to: newSubject, }); } - if (oldTimezone !== newData.timezone) { + if (oldTimezone !== newTimezone) { diffs.push({ type: DOCUMENT_META_DIFF_TYPE.TIMEZONE, from: oldTimezone, - to: newData.timezone, + to: newTimezone, }); } - if (oldRedirectUrl !== newData.redirectUrl) { + if (oldRedirectUrl !== newRedirectUrl) { diffs.push({ type: DOCUMENT_META_DIFF_TYPE.REDIRECT_URL, from: oldRedirectUrl, - to: newData.redirectUrl, + to: newRedirectUrl, }); } @@ -278,6 +312,14 @@ export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId anonymous: 'Field unsigned', identified: 'unsigned a field', })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({ + anonymous: 'Document access auth updated', + identified: 'updated the document access auth requirements', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({ + anonymous: 'Document signing auth updated', + identified: 'updated the document signing auth requirements', + })) .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({ anonymous: 'Document updated', identified: 'updated the document', diff --git a/packages/lib/utils/document-auth.ts b/packages/lib/utils/document-auth.ts new file mode 100644 index 000000000..e1e536fc8 --- /dev/null +++ b/packages/lib/utils/document-auth.ts @@ -0,0 +1,72 @@ +import type { Document, Recipient } from '@documenso/prisma/client'; + +import type { + TDocumentAuthOptions, + TRecipientAccessAuthTypes, + TRecipientActionAuthTypes, + TRecipientAuthOptions, +} from '../types/document-auth'; +import { DocumentAuth } from '../types/document-auth'; +import { ZDocumentAuthOptionsSchema, ZRecipientAuthOptionsSchema } from '../types/document-auth'; + +type ExtractDocumentAuthMethodsOptions = { + documentAuth: Document['authOptions']; + recipientAuth?: Recipient['authOptions']; +}; + +/** + * Parses and extracts the document and recipient authentication values. + * + * Will combine the recipient and document auth values to derive the final + * auth values for a recipient if possible. + */ +export const extractDocumentAuthMethods = ({ + documentAuth, + recipientAuth, +}: ExtractDocumentAuthMethodsOptions) => { + const documentAuthOption = ZDocumentAuthOptionsSchema.parse(documentAuth); + const recipientAuthOption = ZRecipientAuthOptionsSchema.parse(recipientAuth); + + const derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null = + recipientAuthOption.accessAuth || documentAuthOption.globalAccessAuth; + + const derivedRecipientActionAuth: TRecipientActionAuthTypes | null = + recipientAuthOption.actionAuth || documentAuthOption.globalActionAuth; + + const recipientAccessAuthRequired = derivedRecipientAccessAuth !== null; + + const recipientActionAuthRequired = + derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE && + derivedRecipientActionAuth !== null; + + return { + derivedRecipientAccessAuth, + derivedRecipientActionAuth, + recipientAccessAuthRequired, + recipientActionAuthRequired, + documentAuthOption, + recipientAuthOption, + }; +}; + +/** + * Create document auth options in a type safe way. + */ +export const createDocumentAuthOptions = (options: TDocumentAuthOptions): TDocumentAuthOptions => { + return { + globalAccessAuth: options?.globalAccessAuth ?? null, + globalActionAuth: options?.globalActionAuth ?? null, + }; +}; + +/** + * Create recipient auth options in a type safe way. + */ +export const createRecipientAuthOptions = ( + options: TRecipientAuthOptions, +): TRecipientAuthOptions => { + return { + accessAuth: options?.accessAuth ?? null, + actionAuth: options?.actionAuth ?? null, + }; +}; diff --git a/packages/prisma/migrations/20240311113243_add_document_auth/migration.sql b/packages/prisma/migrations/20240311113243_add_document_auth/migration.sql new file mode 100644 index 000000000..8cb96765e --- /dev/null +++ b/packages/prisma/migrations/20240311113243_add_document_auth/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "authOptions" JSONB; + +-- AlterTable +ALTER TABLE "Recipient" ADD COLUMN "authOptions" JSONB; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index aa161fa1f..d632ae60e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -255,6 +255,7 @@ model Document { id Int @id @default(autoincrement()) userId Int User User @relation(fields: [userId], references: [id], onDelete: Cascade) + authOptions Json? title String status DocumentStatus @default(DRAFT) Recipient Recipient[] @@ -352,6 +353,7 @@ model Recipient { token String expired DateTime? signedAt DateTime? + authOptions Json? role RecipientRole @default(SIGNER) readStatus ReadStatus @default(NOT_OPENED) signingStatus SigningStatus @default(NOT_SIGNED) diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts index 1f1f5cab8..1fceca900 100644 --- a/packages/prisma/seed/documents.ts +++ b/packages/prisma/seed/documents.ts @@ -1,4 +1,4 @@ -import type { User } from '@prisma/client'; +import type { Document, User } from '@prisma/client'; import { nanoid } from 'nanoid'; import fs from 'node:fs'; import path from 'node:path'; @@ -33,19 +33,19 @@ export const seedDocuments = async (documents: DocumentToSeed[]) => { documents.map(async (document, i) => match(document.type) .with(DocumentStatus.DRAFT, async () => - createDraftDocument(document.sender, document.recipients, { + seedDraftDocument(document.sender, document.recipients, { key: i, createDocumentOptions: document.documentOptions, }), ) .with(DocumentStatus.PENDING, async () => - createPendingDocument(document.sender, document.recipients, { + seedPendingDocument(document.sender, document.recipients, { key: i, createDocumentOptions: document.documentOptions, }), ) .with(DocumentStatus.COMPLETED, async () => - createCompletedDocument(document.sender, document.recipients, { + seedCompletedDocument(document.sender, document.recipients, { key: i, createDocumentOptions: document.documentOptions, }), @@ -55,7 +55,37 @@ export const seedDocuments = async (documents: DocumentToSeed[]) => { ); }; -const createDraftDocument = async ( +export const seedBlankDocument = async (owner: User, options: CreateDocumentOptions = {}) => { + const { key, createDocumentOptions = {} } = options; + + const documentData = await prisma.documentData.create({ + data: { + type: DocumentDataType.BYTES_64, + data: examplePdf, + initialData: examplePdf, + }, + }); + + return await prisma.document.create({ + data: { + title: `[TEST] Document ${key} - Draft`, + status: DocumentStatus.DRAFT, + documentDataId: documentData.id, + userId: owner.id, + ...createDocumentOptions, + }, + }); +}; + +export const unseedDocument = async (documentId: number) => { + await prisma.document.delete({ + where: { + id: documentId, + }, + }); +}; + +export const seedDraftDocument = async ( sender: User, recipients: (User | string)[], options: CreateDocumentOptions = {}, @@ -114,6 +144,8 @@ const createDraftDocument = async ( }, }); } + + return document; }; type CreateDocumentOptions = { @@ -121,7 +153,7 @@ type CreateDocumentOptions = { createDocumentOptions?: Partial; }; -const createPendingDocument = async ( +export const seedPendingDocument = async ( sender: User, recipients: (User | string)[], options: CreateDocumentOptions = {}, @@ -180,9 +212,145 @@ const createPendingDocument = async ( }, }); } + + return document; }; -const createCompletedDocument = async ( +export const seedPendingDocumentNoFields = async ({ + owner, + recipients, + updateDocumentOptions, +}: { + owner: User; + recipients: (User | string)[]; + updateDocumentOptions?: Partial; +}) => { + const document: Document = await seedBlankDocument(owner); + + for (const recipient of recipients) { + const email = typeof recipient === 'string' ? recipient : recipient.email; + const name = typeof recipient === 'string' ? recipient : recipient.name ?? ''; + + await prisma.recipient.create({ + data: { + email, + name, + token: nanoid(), + readStatus: ReadStatus.OPENED, + sendStatus: SendStatus.SENT, + signingStatus: SigningStatus.NOT_SIGNED, + signedAt: new Date(), + Document: { + connect: { + id: document.id, + }, + }, + }, + }); + } + + const createdRecipients = await prisma.recipient.findMany({ + where: { + documentId: document.id, + }, + include: { + Field: true, + }, + }); + + const latestDocument = updateDocumentOptions + ? await prisma.document.update({ + where: { + id: document.id, + }, + data: updateDocumentOptions, + }) + : document; + + return { + document: latestDocument, + recipients: createdRecipients, + }; +}; + +export const seedPendingDocumentWithFullFields = async ({ + owner, + recipients, + recipientsCreateOptions, + updateDocumentOptions, + fields = [FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.SIGNATURE, FieldType.TEXT], +}: { + owner: User; + recipients: (User | string)[]; + recipientsCreateOptions?: Partial[]; + updateDocumentOptions?: Partial; + fields?: FieldType[]; +}) => { + const document: Document = await seedBlankDocument(owner); + + for (const [recipientIndex, recipient] of recipients.entries()) { + const email = typeof recipient === 'string' ? recipient : recipient.email; + const name = typeof recipient === 'string' ? recipient : recipient.name ?? ''; + + await prisma.recipient.create({ + data: { + email, + name, + token: nanoid(), + readStatus: ReadStatus.OPENED, + sendStatus: SendStatus.SENT, + signingStatus: SigningStatus.NOT_SIGNED, + signedAt: new Date(), + Document: { + connect: { + id: document.id, + }, + }, + Field: { + createMany: { + data: fields.map((fieldType, fieldIndex) => ({ + page: 1, + type: fieldType, + inserted: false, + customText: name, + positionX: new Prisma.Decimal((recipientIndex + 1) * 5), + positionY: new Prisma.Decimal((fieldIndex + 1) * 5), + width: new Prisma.Decimal(5), + height: new Prisma.Decimal(5), + documentId: document.id, + })), + }, + }, + ...(recipientsCreateOptions?.[recipientIndex] ?? {}), + }, + }); + } + + const createdRecipients = await prisma.recipient.findMany({ + where: { + documentId: document.id, + }, + include: { + Field: true, + }, + }); + + const latestDocument = updateDocumentOptions + ? await prisma.document.update({ + where: { + id: document.id, + }, + data: updateDocumentOptions, + }) + : document; + + return { + document: latestDocument, + recipients: createdRecipients, + }; +}; + +export const seedCompletedDocument = async ( sender: User, recipients: (User | string)[], options: CreateDocumentOptions = {}, @@ -241,6 +409,8 @@ const createCompletedDocument = async ( }, }); } + + return document; }; /** diff --git a/packages/prisma/seed/pr-718-add-stepper-component.ts b/packages/prisma/seed/pr-718-add-stepper-component.ts deleted file mode 100644 index d436a97b1..000000000 --- a/packages/prisma/seed/pr-718-add-stepper-component.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { hashSync } from '@documenso/lib/server-only/auth/hash'; - -import { prisma } from '..'; - -// -// https://github.com/documenso/documenso/pull/713 -// - -const PULL_REQUEST_NUMBER = 718; - -const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`; - -export const TEST_USER = { - name: 'User 1', - email: `user1@${EMAIL_DOMAIN}`, - password: 'Password123', -} as const; - -export const seedDatabase = async () => { - await prisma.user.create({ - data: { - name: TEST_USER.name, - email: TEST_USER.email, - password: hashSync(TEST_USER.password), - emailVerified: new Date(), - url: TEST_USER.email, - }, - }); -}; diff --git a/packages/prisma/seed/subscriptions.ts b/packages/prisma/seed/subscriptions.ts new file mode 100644 index 000000000..8e237299f --- /dev/null +++ b/packages/prisma/seed/subscriptions.ts @@ -0,0 +1,19 @@ +import { prisma } from '..'; + +export const seedTestEmail = () => `user-${Date.now()}@test.documenso.com`; + +type SeedSubscriptionOptions = { + userId: number; + priceId: string; +}; + +export const seedUserSubscription = async ({ userId, priceId }: SeedSubscriptionOptions) => { + return await prisma.subscription.create({ + data: { + userId, + planId: Date.now().toString(), + priceId, + status: 'ACTIVE', + }, + }); +}; diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts index 353683a1d..fd8706fea 100644 --- a/packages/prisma/seed/users.ts +++ b/packages/prisma/seed/users.ts @@ -2,6 +2,8 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash'; import { prisma } from '..'; +export const seedTestEmail = () => `user-${Date.now()}@test.documenso.com`; + type SeedUserOptions = { name?: string; email?: string; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 70cf15291..4a6f11e60 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -13,6 +13,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 { 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'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; @@ -29,6 +30,7 @@ import { ZSearchDocumentsMutationSchema, ZSendDocumentMutationSchema, ZSetPasswordForDocumentMutationSchema, + ZSetSettingsForDocumentMutationSchema, ZSetTitleForDocumentMutationSchema, } from './schema'; @@ -51,22 +53,25 @@ export const documentRouter = router({ } }), - getDocumentByToken: procedure.input(ZGetDocumentByTokenQuerySchema).query(async ({ input }) => { - try { - const { token } = input; + getDocumentByToken: procedure + .input(ZGetDocumentByTokenQuerySchema) + .query(async ({ input, ctx }) => { + try { + const { token } = input; - return await getDocumentAndSenderByToken({ - token, - }); - } catch (err) { - console.error(err); + return await getDocumentAndSenderByToken({ + token, + userId: ctx.user?.id, + }); + } catch (err) { + console.error(err); - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'We were unable to find this document. Please try again later.', - }); - } - }), + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to find this document. Please try again later.', + }); + } + }), getDocumentWithDetailsById: authenticatedProcedure .input(ZGetDocumentWithDetailsByIdQuerySchema) @@ -170,6 +175,46 @@ export const documentRouter = router({ } }), + // Todo: Add API + setSettingsForDocument: authenticatedProcedure + .input(ZSetSettingsForDocumentMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, teamId, data, meta } = input; + + const userId = ctx.user.id; + + const requestMetadata = extractNextApiRequestMetadata(ctx.req); + + if (meta.timezone || meta.dateFormat || meta.redirectUrl) { + await upsertDocumentMeta({ + documentId, + dateFormat: meta.dateFormat, + timezone: meta.timezone, + redirectUrl: meta.redirectUrl, + userId: ctx.user.id, + requestMetadata, + }); + } + + return await updateDocumentSettings({ + userId, + teamId, + documentId, + data, + requestMetadata, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'We were unable to update the settings for this document. Please try again later.', + }); + } + }), + setTitleForDocument: authenticatedProcedure .input(ZSetTitleForDocumentMutationSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 065552ee2..6ed6fcc4d 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -1,6 +1,10 @@ import { z } from 'zod'; import { URL_REGEX } from '@documenso/lib/constants/url-regex'; +import { + ZDocumentAccessAuthTypesSchema, + ZDocumentActionAuthTypesSchema, +} from '@documenso/lib/types/document-auth'; import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; import { FieldType, RecipientRole } from '@documenso/prisma/client'; @@ -46,6 +50,30 @@ export const ZCreateDocumentMutationSchema = z.object({ export type TCreateDocumentMutationSchema = z.infer; +export const ZSetSettingsForDocumentMutationSchema = z.object({ + documentId: 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({ + 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 type TSetGeneralSettingsForDocumentMutationSchema = z.infer< + typeof ZSetSettingsForDocumentMutationSchema +>; + export const ZSetTitleForDocumentMutationSchema = z.object({ documentId: z.number(), teamId: z.number().min(1).optional(), @@ -97,8 +125,8 @@ export const ZSendDocumentMutationSchema = z.object({ meta: z.object({ subject: z.string(), message: z.string(), - timezone: z.string(), - dateFormat: z.string(), + timezone: z.string().optional(), + dateFormat: z.string().optional(), redirectUrl: z .string() .optional() diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 4df1b1ddc..4b299b6a1 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server'; +import { AppError } from '@documenso/lib/errors/app-error'; import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token'; import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template'; @@ -71,22 +72,21 @@ export const fieldRouter = router({ .input(ZSignFieldWithTokenMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { token, fieldId, value, isBase64 } = input; + const { token, fieldId, value, isBase64, authOptions } = input; return await signFieldWithToken({ token, fieldId, value, isBase64, + userId: ctx.user?.id, + authOptions, requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'We were unable to sign this field. Please try again later.', - }); + throw AppError.parseErrorToTRPCError(err); } }), diff --git a/packages/trpc/server/field-router/schema.ts b/packages/trpc/server/field-router/schema.ts index 9bd576667..eaf5d5bc8 100644 --- a/packages/trpc/server/field-router/schema.ts +++ b/packages/trpc/server/field-router/schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { ZRecipientActionAuthSchema } from '@documenso/lib/types/document-auth'; import { FieldType } from '@documenso/prisma/client'; export const ZAddFieldsMutationSchema = z.object({ @@ -45,6 +46,7 @@ export const ZSignFieldWithTokenMutationSchema = z.object({ fieldId: z.number(), value: z.string().trim(), isBase64: z.boolean().optional(), + authOptions: ZRecipientActionAuthSchema.optional(), }); export type TSignFieldWithTokenMutationSchema = z.infer; diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index ac040f4f5..61740e9a0 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -28,6 +28,7 @@ export const recipientRouter = router({ email: signer.email, name: signer.name, role: signer.role, + actionAuth: signer.actionAuth, })), requestMetadata: extractNextApiRequestMetadata(ctx.req), }); @@ -71,11 +72,13 @@ export const recipientRouter = router({ .input(ZCompleteDocumentWithTokenMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { token, documentId } = input; + const { token, documentId, authOptions } = input; return await completeDocumentWithToken({ token, documentId, + authOptions, + userId: ctx.user?.id, requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index 6825137c4..4b5522150 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; +import { + ZRecipientActionAuthSchema, + ZRecipientActionAuthTypesSchema, +} from '@documenso/lib/types/document-auth'; import { RecipientRole } from '@documenso/prisma/client'; export const ZAddSignersMutationSchema = z @@ -12,6 +16,7 @@ export const ZAddSignersMutationSchema = z email: z.string().email().min(1), name: z.string(), role: z.nativeEnum(RecipientRole), + actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), }), ), }) @@ -54,6 +59,7 @@ export type TAddTemplateSignersMutationSchema = z.infer { return await next({ ctx: { ...ctx, - user: ctx.user, session: ctx.session, }, diff --git a/packages/ui/components/animate/animate-generic-fade-in-out.tsx b/packages/ui/components/animate/animate-generic-fade-in-out.tsx index 5f57c96df..78487b953 100644 --- a/packages/ui/components/animate/animate-generic-fade-in-out.tsx +++ b/packages/ui/components/animate/animate-generic-fade-in-out.tsx @@ -5,11 +5,17 @@ import { motion } from 'framer-motion'; type AnimateGenericFadeInOutProps = { children: React.ReactNode; className?: string; + motionKey?: string; }; -export const AnimateGenericFadeInOut = ({ children, className }: AnimateGenericFadeInOutProps) => { +export const AnimateGenericFadeInOut = ({ + children, + className, + motionKey, +}: AnimateGenericFadeInOutProps) => { return ( - + {!isLoading && } Download ); diff --git a/packages/ui/primitives/checkbox.tsx b/packages/ui/primitives/checkbox.tsx index 5acf35f9d..18ff53d47 100644 --- a/packages/ui/primitives/checkbox.tsx +++ b/packages/ui/primitives/checkbox.tsx @@ -16,7 +16,7 @@ const Checkbox = React.forwardRef< void; +}; + +export const AddSettingsFormPartial = ({ + documentFlow, + recipients, + fields, + isDocumentEnterprise, + isDocumentPdfLoaded, + document, + onSubmit, +}: AddSettingsFormProps) => { + const { documentAuthOption } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + }); + + const form = useForm({ + resolver: zodResolver(ZAddSettingsFormSchema), + defaultValues: { + title: document.title, + globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined, + globalActionAuth: documentAuthOption?.globalActionAuth || undefined, + meta: { + timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, + dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, + redirectUrl: document.documentMeta?.redirectUrl ?? '', + }, + }, + }); + + const { stepIndex, currentStep, totalSteps, previousStep } = useStep(); + + const documentHasBeenSent = recipients.some( + (recipient) => recipient.sendStatus === SendStatus.SENT, + ); + + // 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 && !documentHasBeenSent) { + form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone); + } + }, [documentHasBeenSent, form, form.setValue, form.formState.touchedFields.meta?.timezone]); + + return ( + <> + + + + {isDocumentPdfLoaded && + fields.map((field, index) => ( + + ))} + +
+
+ ( + + Title + + + + + + + )} + /> + + ( + + + 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 +
  • +
+
+
+
+ + + + +
+ )} + /> + + {isDocumentEnterprise && ( + ( + + + 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 +
  • +
  • + None - No authentication required +
  • +
+
+
+
+ + + + +
+ )} + /> + )} + + + + + Advanced Options + + + +
+ ( + + Date Format + + + + + + + + )} + /> + + ( + + Time Zone + + + value && field.onChange(value)} + disabled={documentHasBeenSent} + /> + + + + + )} + /> + + ( + + + Redirect URL{' '} + + + + + + + Add a URL to redirect the user to once the document is signed + + + + + + + + + + + )} + /> +
+
+
+
+
+
+
+ + + + + + + + ); +}; diff --git a/packages/ui/primitives/document-flow/add-settings.types.ts b/packages/ui/primitives/document-flow/add-settings.types.ts new file mode 100644 index 000000000..fb669999b --- /dev/null +++ b/packages/ui/primitives/document-flow/add-settings.types.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import { URL_REGEX } from '@documenso/lib/constants/url-regex'; +import { + ZDocumentAccessAuthTypesSchema, + ZDocumentActionAuthTypesSchema, +} from '@documenso/lib/types/document-auth'; + +export const ZMapNegativeOneToUndefinedSchema = z + .string() + .optional() + .transform((val) => { + if (val === '-1') { + return undefined; + } + + return val; + }); + +export const ZAddSettingsFormSchema = z.object({ + title: z.string().trim().min(1, { message: "Title can't be empty" }), + globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe( + ZDocumentAccessAuthTypesSchema.optional(), + ), + globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe( + ZDocumentActionAuthTypesSchema.optional(), + ), + meta: z.object({ + timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE), + dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT), + redirectUrl: z + .string() + .optional() + .refine((value) => value === undefined || value === '' || URL_REGEX.test(value), { + message: 'Please enter a valid URL', + }), + }), +}); + +export type TAddSettingsFormSchema = z.infer; diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 95f2c7983..3d1263914 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -1,25 +1,33 @@ 'use client'; -import React, { useId } from 'react'; +import React, { useId, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { AnimatePresence, motion } from 'framer-motion'; -import { Plus, Trash } from 'lucide-react'; -import { Controller, useFieldArray, useForm } from 'react-hook-form'; +import { motion } from 'framer-motion'; +import { InfoIcon, Plus, Trash } from 'lucide-react'; +import { useFieldArray, useForm } from 'react-hook-form'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; +import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; +import { + RecipientActionAuth, + ZRecipientAuthOptionsSchema, +} from '@documenso/lib/types/document-auth'; import { nanoid } from '@documenso/lib/universal/id'; import type { Field, Recipient } from '@documenso/prisma/client'; -import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client'; -import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; +import { RecipientRole, SendStatus } from '@documenso/prisma/client'; +import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; +import { cn } from '@documenso/ui/lib/utils'; import { Button } from '../button'; +import { Checkbox } from '../checkbox'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; import { FormErrorMessage } from '../form/form-error-message'; import { Input } from '../input'; -import { Label } from '../label'; import { ROLE_ICONS } from '../recipient-role-icons'; -import { Select, SelectContent, SelectItem, SelectTrigger } from '../select'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select'; import { useStep } from '../stepper'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; import { useToast } from '../use-toast'; import type { TAddSignersFormSchema } from './add-signers.types'; import { ZAddSignersFormSchema } from './add-signers.types'; @@ -37,7 +45,7 @@ export type AddSignersFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; fields: Field[]; - document: DocumentWithData; + isDocumentEnterprise: boolean; onSubmit: (_data: TAddSignersFormSchema) => void; isDocumentPdfLoaded: boolean; }; @@ -45,8 +53,8 @@ export type AddSignersFormProps = { export const AddSignersFormPartial = ({ documentFlow, recipients, - document, fields, + isDocumentEnterprise, onSubmit, isDocumentPdfLoaded, }: AddSignersFormProps) => { @@ -57,11 +65,7 @@ export const AddSignersFormPartial = ({ const { currentStep, totalSteps, previousStep } = useStep(); - const { - control, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ resolver: zodResolver(ZAddSignersFormSchema), defaultValues: { signers: @@ -72,6 +76,8 @@ export const AddSignersFormPartial = ({ name: recipient.name, email: recipient.email, role: recipient.role, + actionAuth: + ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, })) : [ { @@ -79,12 +85,33 @@ export const AddSignersFormPartial = ({ name: '', email: '', role: RecipientRole.SIGNER, + actionAuth: undefined, }, ], }, }); - const onFormSubmit = handleSubmit(onSubmit); + // Always show advanced settings if any recipient has auth options. + const alwaysShowAdvancedSettings = useMemo(() => { + const recipientHasAuthOptions = recipients.find((recipient) => { + const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); + + return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth; + }); + + const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth); + + return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined; + }, [recipients, form]); + + const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings); + + const { + formState: { errors, isSubmitting }, + control, + } = form; + + const onFormSubmit = form.handleSubmit(onSubmit); const { append: appendSigner, @@ -114,6 +141,7 @@ export const AddSignersFormPartial = ({ name: '', email: '', role: RecipientRole.SIGNER, + actionAuth: undefined, }); }; @@ -146,111 +174,201 @@ export const AddSignersFormPartial = ({ description={documentFlow.description} /> -
- {isDocumentPdfLoaded && - fields.map((field, index) => ( - - ))} + {isDocumentPdfLoaded && + fields.map((field, index) => ( + + ))} - - {signers.map((signer, index) => ( - -
- - - +
+
+ {signers.map((signer, index) => ( + + ( - + + {!showAdvancedSettings && index === 0 && ( + Email + )} + + + + + + + )} /> -
-
- - - ( - - )} - /> -
- -
- ( - + - -
- {ROLE_ICONS[RecipientRole.CC]} - Receives copy -
-
- - -
- {ROLE_ICONS[RecipientRole.APPROVER]} - Approver -
-
- - -
- {ROLE_ICONS[RecipientRole.VIEWER]} - Viewer -
-
- - + + + )} + /> + + {showAdvancedSettings && isDocumentEnterprise && ( + ( + + + + + + + + )} + /> + )} + + ( + + + + + + + )} /> -
-
+ + ))} +
+ + + +
+ + + {!alwaysShowAdvancedSettings && isDocumentEnterprise && ( +
+ setShowAdvancedSettings(Boolean(value))} + /> + +
- -
- - -
- - ))} - -
- - - -
- -
+ )} +
+ + @@ -297,7 +433,6 @@ export const AddSignersFormPartial = ({ /> { const { - control, register, handleSubmit, - formState: { errors, isSubmitting, touchedFields }, - setValue, + formState: { errors, isSubmitting }, } = useForm({ defaultValues: { meta: { subject: document.documentMeta?.subject ?? '', message: document.documentMeta?.message ?? '', - timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, - dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, - redirectUrl: document.documentMeta?.redirectUrl ?? '', }, }, resolver: zodResolver(ZAddSubjectFormSchema), @@ -83,20 +57,6 @@ export const AddSubjectFormPartial = ({ const onFormSubmit = handleSubmit(onSubmit); const { currentStep, totalSteps, previousStep } = useStep(); - const hasDateField = fields.find((field) => field.type === 'DATE'); - - const documentHasBeenSent = recipients.some( - (recipient) => recipient.sendStatus === SendStatus.SENT, - ); - - // We almost always want to set the timezone to the user's local timezone to avoid confusion - // when the document is signed. - useEffect(() => { - if (!touchedFields.meta?.timezone && !documentHasBeenSent) { - setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone); - } - }, [documentHasBeenSent, setValue, touchedFields.meta?.timezone]); - return ( <>
- - - - - Advanced Options - - - - {hasDateField && ( - <> -
- - - ( - - )} - /> -
- -
- - - ( - value && onChange(value)} - disabled={documentHasBeenSent} - /> - )} - /> -
- - )} - -
-
-
- - - - - -
-
-
-
-
-
diff --git a/packages/ui/primitives/document-flow/add-subject.types.ts b/packages/ui/primitives/document-flow/add-subject.types.ts index c9027c2a3..020e3c04b 100644 --- a/packages/ui/primitives/document-flow/add-subject.types.ts +++ b/packages/ui/primitives/document-flow/add-subject.types.ts @@ -1,21 +1,9 @@ import { z } from 'zod'; -import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; -import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; -import { URL_REGEX } from '@documenso/lib/constants/url-regex'; - export const ZAddSubjectFormSchema = z.object({ meta: z.object({ subject: z.string(), message: z.string(), - timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE), - dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT), - redirectUrl: z - .string() - .optional() - .refine((value) => value === undefined || value === '' || URL_REGEX.test(value), { - message: 'Please enter a valid URL', - }), }), }); diff --git a/packages/ui/primitives/document-flow/add-title.tsx b/packages/ui/primitives/document-flow/add-title.tsx deleted file mode 100644 index 5abe44003..000000000 --- a/packages/ui/primitives/document-flow/add-title.tsx +++ /dev/null @@ -1,106 +0,0 @@ -'use client'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm } from 'react-hook-form'; - -import type { Field, Recipient } from '@documenso/prisma/client'; -import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; - -import { FormErrorMessage } from '../form/form-error-message'; -import { Input } from '../input'; -import { Label } from '../label'; -import { useStep } from '../stepper'; -import type { TAddTitleFormSchema } from './add-title.types'; -import { ZAddTitleFormSchema } from './add-title.types'; -import { - DocumentFlowFormContainerActions, - DocumentFlowFormContainerContent, - DocumentFlowFormContainerFooter, - DocumentFlowFormContainerHeader, - DocumentFlowFormContainerStep, -} from './document-flow-root'; -import { ShowFieldItem } from './show-field-item'; -import type { DocumentFlowStep } from './types'; - -export type AddTitleFormProps = { - documentFlow: DocumentFlowStep; - recipients: Recipient[]; - fields: Field[]; - document: DocumentWithData; - onSubmit: (_data: TAddTitleFormSchema) => void; - isDocumentPdfLoaded: boolean; -}; - -export const AddTitleFormPartial = ({ - documentFlow, - recipients, - fields, - document, - onSubmit, - isDocumentPdfLoaded, -}: AddTitleFormProps) => { - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(ZAddTitleFormSchema), - defaultValues: { - title: document.title, - }, - }); - - const onFormSubmit = handleSubmit(onSubmit); - - const { stepIndex, currentStep, totalSteps, previousStep } = useStep(); - - return ( - <> - - - {isDocumentPdfLoaded && - fields.map((field, index) => ( - - ))} - -
-
-
- - - - - -
-
-
-
- - - - - void onFormSubmit()} - /> - - - ); -}; diff --git a/packages/ui/primitives/document-flow/add-title.types.ts b/packages/ui/primitives/document-flow/add-title.types.ts deleted file mode 100644 index b910c060a..000000000 --- a/packages/ui/primitives/document-flow/add-title.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod'; - -export const ZAddTitleFormSchema = z.object({ - title: z.string().trim().min(1, { message: "Title can't be empty" }), -}); - -export type TAddTitleFormSchema = z.infer; diff --git a/packages/ui/primitives/input.tsx b/packages/ui/primitives/input.tsx index 71b3cb521..f776c94c2 100644 --- a/packages/ui/primitives/input.tsx +++ b/packages/ui/primitives/input.tsx @@ -10,7 +10,7 @@ const Input = React.forwardRef( Date: Thu, 28 Mar 2024 06:55:01 +0000 Subject: [PATCH 195/299] fix: update email template and tidy code --- .../[id]/super-delete-document-dialog.tsx | 24 ++++++++++++------- ...tsx => template-document-super-delete.tsx} | 21 ++++++++++++---- ...t-delete.tsx => document-super-delete.tsx} | 9 +++---- .../document/super-delete-document.ts | 9 ++----- packages/trpc/server/admin-router/router.ts | 13 +++++++--- packages/trpc/server/admin-router/schema.ts | 1 - 6 files changed, 48 insertions(+), 29 deletions(-) rename packages/email/template-components/{template-document-delete.tsx => template-document-super-delete.tsx} (52%) rename packages/email/templates/{document-delete.tsx => document-super-delete.tsx} (87%) diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/super-delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/super-delete-document-dialog.tsx index 6d89c7e7b..63ad88a3f 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/[id]/super-delete-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/super-delete-document-dialog.tsx @@ -26,23 +26,29 @@ export type SuperDeleteDocumentDialogProps = { }; export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => { - const router = useRouter(); const { toast } = useToast(); + const router = useRouter(); + const [reason, setReason] = useState(''); + const { mutateAsync: deleteDocument, isLoading: isDeletingDocument } = trpc.admin.deleteDocument.useMutation(); const handleDeleteDocument = async () => { try { - if (reason) { - await deleteDocument({ id: document.id, userId: document.userId, reason }); - toast({ - title: 'Document deleted', - description: 'The Document has been deleted successfully.', - duration: 5000, - }); - router.push('/admin/documents'); + if (!reason) { + return; } + + await deleteDocument({ id: document.id, reason }); + + 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({ diff --git a/packages/email/template-components/template-document-delete.tsx b/packages/email/template-components/template-document-super-delete.tsx similarity index 52% rename from packages/email/template-components/template-document-delete.tsx rename to packages/email/template-components/template-document-super-delete.tsx index b23bddcc8..9cb0a9e71 100644 --- a/packages/email/template-components/template-document-delete.tsx +++ b/packages/email/template-components/template-document-super-delete.tsx @@ -17,14 +17,25 @@ export const TemplateDocumentDelete = ({
- + + Your document has been deleted by an admin! + + + + "{documentName}" has been deleted by an admin. + + + This document can not be recovered, if you would like to dispute the reason for future documents please contact support. -
"{documentName}"
- - Reason -
"{reason}" + + + The reason provided for deletion is the following: + + + + {reason}
diff --git a/packages/email/templates/document-delete.tsx b/packages/email/templates/document-super-delete.tsx similarity index 87% rename from packages/email/templates/document-delete.tsx rename to packages/email/templates/document-super-delete.tsx index 87e6b6e9a..68384e119 100644 --- a/packages/email/templates/document-delete.tsx +++ b/packages/email/templates/document-super-delete.tsx @@ -4,17 +4,17 @@ import { Body, Container, Head, Hr, Html, Img, Preview, Section, Tailwind } from import { TemplateDocumentDelete, type TemplateDocumentDeleteProps, -} from '../template-components/template-document-delete'; +} from '../template-components/template-document-super-delete'; import { TemplateFooter } from '../template-components/template-footer'; export type DocumentDeleteEmailTemplateProps = Partial; -export const DocumentDeleteEmailTemplate = ({ +export const DocumentSuperDeleteEmailTemplate = ({ documentName = 'Open Source Pledge.pdf', assetBaseUrl = 'http://localhost:3002', reason = 'Unknown', }: DocumentDeleteEmailTemplateProps) => { - const previewText = `Admin has deleted your document ${documentName}.`; + const previewText = `An admin has deleted your document "${documentName}".`; const getAssetUrl = (path: string) => { return new URL(path, assetBaseUrl).toString(); @@ -42,6 +42,7 @@ export const DocumentDeleteEmailTemplate = ({ alt="Documenso Logo" className="mb-4 h-6" /> + { +export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDocumentOptions) => { const document = await prisma.document.findUnique({ where: { id, - userId, }, include: { Recipient: true, @@ -85,6 +79,7 @@ export const superDeleteDocument = async ({ }, }), }); + return await tx.document.delete({ where: { id } }); }); }; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 459c9a396..b37510be7 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -9,6 +9,7 @@ import { superDeleteDocument } from '@documenso/lib/server-only/document/super-d 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'; +import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { adminProcedure, router } from '../trpc'; import { @@ -121,15 +122,21 @@ export const adminRouter = router({ }); } }), + deleteDocument: adminProcedure .input(ZAdminDeleteDocumentMutationSchema) - .mutation(async ({ input }) => { - const { id, userId, reason } = input; + .mutation(async ({ ctx, input }) => { + const { id, reason } = input; try { await sendDeleteEmail({ documentId: id, reason }); - return await superDeleteDocument({ id, userId }); + + return await superDeleteDocument({ + id, + requestMetadata: extractNextApiRequestMetadata(ctx.req), + }); } catch (err) { console.log(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to delete the specified document. Please try again.', diff --git a/packages/trpc/server/admin-router/schema.ts b/packages/trpc/server/admin-router/schema.ts index 91b0df3c1..6bb567dbd 100644 --- a/packages/trpc/server/admin-router/schema.ts +++ b/packages/trpc/server/admin-router/schema.ts @@ -51,7 +51,6 @@ export type TAdminDeleteUserMutationSchema = z.infer Date: Thu, 28 Mar 2024 07:01:57 +0000 Subject: [PATCH 196/299] fix: build error from renaming --- packages/lib/server-only/document/send-delete-email.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lib/server-only/document/send-delete-email.ts b/packages/lib/server-only/document/send-delete-email.ts index 4046d5f0f..cc1101942 100644 --- a/packages/lib/server-only/document/send-delete-email.ts +++ b/packages/lib/server-only/document/send-delete-email.ts @@ -2,7 +2,7 @@ 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 { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete'; import { prisma } from '@documenso/prisma'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; @@ -30,7 +30,7 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; - const template = createElement(DocumentDeleteEmailTemplate, { + const template = createElement(DocumentSuperDeleteEmailTemplate, { documentName: document.title, reason, assetBaseUrl, From 117d9427c301b334f3ae53b96f39427bef8c4358 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 28 Mar 2024 19:06:19 +0800 Subject: [PATCH 197/299] fix: passkey login --- packages/trpc/server/auth-router/router.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 0272e54b9..165882856 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -132,7 +132,10 @@ export const authRouter = router({ }), createPasskeySigninOptions: procedure.mutation(async ({ ctx }) => { - const sessionIdToken = parse(ctx.req.headers.cookie ?? '')['next-auth.csrf-token']; + const cookies = parse(ctx.req.headers.cookie ?? ''); + + const sessionIdToken = + cookies['__Host-next-auth.csrf-token'] || cookies['next-auth.csrf-token']; if (!sessionIdToken) { throw new Error('Missing CSRF token'); From 5c00b82894868abecff75730719bc652cee02c34 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 29 Mar 2024 10:10:35 +0000 Subject: [PATCH 198/299] chore: add status widget --- apps/marketing/package.json | 1 + .../src/components/(marketing)/footer.tsx | 6 ++ .../(marketing)/status-widget-container.tsx | 24 +++++++ .../components/(marketing)/status-widget.tsx | 70 +++++++++++++++++++ package-lock.json | 9 +++ 5 files changed, 110 insertions(+) create mode 100644 apps/marketing/src/components/(marketing)/status-widget-container.tsx create mode 100644 apps/marketing/src/components/(marketing)/status-widget.tsx diff --git a/apps/marketing/package.json b/apps/marketing/package.json index f6af3a9ff..2a7f5a024 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -19,6 +19,7 @@ "@documenso/trpc": "*", "@documenso/ui": "*", "@hookform/resolvers": "^3.1.0", + "@openstatus/react": "^0.0.3", "contentlayer": "^0.3.4", "framer-motion": "^10.12.8", "lucide-react": "^0.279.0", diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index a687af0d3..8d2e0c1d4 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 = [ @@ -62,6 +64,10 @@ export const Footer = ({ className, ...props }: FooterProps) => { ))}
+ +
+ +
diff --git a/apps/marketing/src/components/(marketing)/status-widget-container.tsx b/apps/marketing/src/components/(marketing)/status-widget-container.tsx new file mode 100644 index 000000000..ebcc4df2f --- /dev/null +++ b/apps/marketing/src/components/(marketing)/status-widget-container.tsx @@ -0,0 +1,24 @@ +import { Suspense, use } from 'react'; + +import { getStatus } from '@openstatus/react'; + +import { StatusWidget } from './status-widget'; + +export function StatusWidgetContainer() { + const res = use(getStatus('documenso')); + + return ( + }> + + + ); +} + +function StatusWidgetFallback() { + return ( +
+ + +
+ ); +} diff --git a/apps/marketing/src/components/(marketing)/status-widget.tsx b/apps/marketing/src/components/(marketing)/status-widget.tsx new file mode 100644 index 000000000..b5cef32ab --- /dev/null +++ b/apps/marketing/src/components/(marketing)/status-widget.tsx @@ -0,0 +1,70 @@ +import type { Status } from '@openstatus/react'; + +import { cn } from '@documenso/ui/lib/utils'; + +const getStatusLevel = (level: Status) => { + return { + operational: { + label: 'Operational', + color: 'bg-green-500', + color2: 'bg-green-400', + }, + degraded_performance: { + label: 'Degraded Performance', + color: 'bg-yellow-500', + color2: 'bg-yellow-400', + }, + partial_outage: { + label: 'Partial Outage', + color: 'bg-yellow-500', + color2: 'bg-yellow-400', + }, + major_outage: { + label: 'Major Outage', + color: 'bg-red-500', + color2: 'bg-red-400', + }, + unknown: { + label: 'Unknown', + color: 'bg-gray-500', + color2: 'bg-gray-400', + }, + incident: { + label: 'Incident', + color: 'bg-yellow-500', + color2: 'bg-yellow-400', + }, + under_maintenance: { + label: 'Under Maintenance', + color: 'bg-gray-500', + color2: 'bg-gray-400', + }, + }[level]; +}; + +export function StatusWidget({ status }: { status: Status }) { + const level = getStatusLevel(status); + + return ( + +
+

{level.label}

+
+ + + + + +
+ ); +} diff --git a/package-lock.json b/package-lock.json index 3dc4e9776..d2d29e5c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "@documenso/trpc": "*", "@documenso/ui": "*", "@hookform/resolvers": "^3.1.0", + "@openstatus/react": "^0.0.3", "contentlayer": "^0.3.4", "framer-motion": "^10.12.8", "lucide-react": "^0.279.0", @@ -4138,6 +4139,14 @@ "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==" }, + "node_modules/@openstatus/react": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@openstatus/react/-/react-0.0.3.tgz", + "integrity": "sha512-uDiegz7e3H67pG8lTT+op+6w5keTT7XpcENrREaqlWl5j53TYyO8nheOG1PeNw2/Qgd5KaGeRJJFn1crhTUSYw==", + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz", From 171b8008f804d4e01bc06a02d64fd5f3b56a8173 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 29 Mar 2024 10:15:12 +0000 Subject: [PATCH 199/299] chore: credit Co-authored-by: mxkaske --- .../src/components/(marketing)/status-widget-container.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/marketing/src/components/(marketing)/status-widget-container.tsx b/apps/marketing/src/components/(marketing)/status-widget-container.tsx index ebcc4df2f..ebcc00cf1 100644 --- a/apps/marketing/src/components/(marketing)/status-widget-container.tsx +++ b/apps/marketing/src/components/(marketing)/status-widget-container.tsx @@ -1,3 +1,4 @@ +// https://github.com/documenso/documenso/pull/1044/files#r1538258462 import { Suspense, use } from 'react'; import { getStatus } from '@openstatus/react'; From cc60437dcd76293deac80449c566681fbeba9d38 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 29 Mar 2024 10:20:09 +0000 Subject: [PATCH 200/299] fix: correct slug --- .../src/components/(marketing)/status-widget-container.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/src/components/(marketing)/status-widget-container.tsx b/apps/marketing/src/components/(marketing)/status-widget-container.tsx index ebcc00cf1..19206024f 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 { getStatus } from '@openstatus/react'; import { StatusWidget } from './status-widget'; export function StatusWidgetContainer() { - const res = use(getStatus('documenso')); + const res = use(getStatus('documenso-status')); return ( }> From 81ab220f1eea36ea37295307d20ff4e50221e1db Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 29 Mar 2024 11:14:44 +0000 Subject: [PATCH 201/299] fix: wrap use with suspense skill issue --- .../(marketing)/status-widget-container.tsx | 27 ++++++++++++------- .../components/(marketing)/status-widget.tsx | 7 +++-- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/marketing/src/components/(marketing)/status-widget-container.tsx b/apps/marketing/src/components/(marketing)/status-widget-container.tsx index 19206024f..ffc83bff0 100644 --- a/apps/marketing/src/components/(marketing)/status-widget-container.tsx +++ b/apps/marketing/src/components/(marketing)/status-widget-container.tsx @@ -1,25 +1,32 @@ // https://github.com/documenso/documenso/pull/1044/files#r1538258462 -import { Suspense, use } from 'react'; - -import { getStatus } from '@openstatus/react'; +import { Suspense } from 'react'; import { StatusWidget } from './status-widget'; export function StatusWidgetContainer() { - const res = use(getStatus('documenso-status')); - return ( }> - + ); } 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 b5cef32ab..d53a79f43 100644 --- a/apps/marketing/src/components/(marketing)/status-widget.tsx +++ b/apps/marketing/src/components/(marketing)/status-widget.tsx @@ -1,4 +1,6 @@ -import type { Status } from '@openstatus/react'; +import { use } from 'react'; + +import { type Status, getStatus } from '@openstatus/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -42,7 +44,8 @@ const getStatusLevel = (level: Status) => { }[level]; }; -export function StatusWidget({ status }: { status: Status }) { +export function StatusWidget() { + const { status } = use(getStatus('documenso-status')); const level = getStatusLevel(status); return ( From 81ee582f1c4591737a2e30998e9a9a03c0b45c8f Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sat, 30 Mar 2024 13:43:28 +0800 Subject: [PATCH 202/299] fix: linting warnings (#1069) ## Description Cleaned up code that was being highlighted in the dev tools --- apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx | 1 + apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx | 2 +- apps/web/src/components/branding/logo.tsx | 2 +- apps/web/src/components/formatter/template-type.tsx | 4 ++-- .../src/components/forms/2fa/view-recovery-codes-dialog.tsx | 3 --- apps/web/src/components/forms/profile.tsx | 3 --- apps/web/src/components/ui/background.tsx | 2 +- apps/web/src/providers/next-theme.tsx | 2 +- 8 files changed, 7 insertions(+), 12 deletions(-) diff --git a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx index a8e02ca9f..0cb523c3f 100644 --- a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx @@ -58,6 +58,7 @@ export const UsersDataTable = ({ perPage, }); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedSearchString]); const onPaginationChange = (page: number, perPage: number) => { diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx index 91f470f74..69e7d1142 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import { Recipient } from '@documenso/prisma/client'; +import type { Recipient } from '@documenso/prisma/client'; import { StackAvatar } from './stack-avatar'; diff --git a/apps/web/src/components/branding/logo.tsx b/apps/web/src/components/branding/logo.tsx index 6cd4c550c..92087a149 100644 --- a/apps/web/src/components/branding/logo.tsx +++ b/apps/web/src/components/branding/logo.tsx @@ -1,4 +1,4 @@ -import { SVGAttributes } from 'react'; +import type { SVGAttributes } from 'react'; export type LogoProps = SVGAttributes; diff --git a/apps/web/src/components/formatter/template-type.tsx b/apps/web/src/components/formatter/template-type.tsx index a7f10105e..3bcb3b05e 100644 --- a/apps/web/src/components/formatter/template-type.tsx +++ b/apps/web/src/components/formatter/template-type.tsx @@ -1,9 +1,9 @@ -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import { Globe, Lock } from 'lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react'; -import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client'; +import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; type TemplateTypeIcon = { diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx index 66df7bbab..8a6177b5b 100644 --- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -47,12 +47,9 @@ export const ViewRecoveryCodesDialog = () => { data: recoveryCodes, mutate, isLoading, - isError, error, } = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); - // error?.data?.code - const viewRecoveryCodesForm = useForm({ defaultValues: { token: '', diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index c3f8eca37..42d69047f 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -55,11 +55,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { }); const isSubmitting = form.formState.isSubmitting; - const hasTwoFactorAuthentication = user.twoFactorEnabled; const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); - const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } = - trpc.profile.deleteAccount.useMutation(); const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => { try { diff --git a/apps/web/src/components/ui/background.tsx b/apps/web/src/components/ui/background.tsx index 5763967ec..0e0bea5ca 100644 --- a/apps/web/src/components/ui/background.tsx +++ b/apps/web/src/components/ui/background.tsx @@ -1,4 +1,4 @@ -import { SVGAttributes } from 'react'; +import type { SVGAttributes } from 'react'; export type BackgroundProps = Omit, 'viewBox'>; diff --git a/apps/web/src/providers/next-theme.tsx b/apps/web/src/providers/next-theme.tsx index 6e9122e5a..d15114606 100644 --- a/apps/web/src/providers/next-theme.tsx +++ b/apps/web/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}; From b436331d7d217706b0df37249febb1f7c9e966db Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sat, 30 Mar 2024 14:00:34 +0800 Subject: [PATCH 203/299] fix: improve error log coverage --- packages/trpc/server/admin-router/router.ts | 9 + .../trpc/server/api-token-router/router.ts | 16 +- .../trpc/server/document-router/router.ts | 26 +- packages/trpc/server/field-router/router.ts | 34 ++- packages/trpc/server/profile-router/router.ts | 14 + .../trpc/server/singleplayer-router/router.ts | 256 +++++++++--------- .../trpc/server/template-router/router.ts | 2 + packages/trpc/server/webhook-router/router.ts | 12 + 8 files changed, 218 insertions(+), 151 deletions(-) diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index b37510be7..05ee84736 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -29,6 +29,8 @@ export const adminRouter = router({ try { return await findDocuments({ term, page, perPage }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to retrieve the documents. Please try again.', @@ -44,6 +46,8 @@ export const adminRouter = router({ try { return await updateUser({ id, name, email, roles }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to retrieve the specified account. Please try again.', @@ -59,6 +63,8 @@ export const adminRouter = router({ try { return await updateRecipient({ id, name, email }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to update the recipient provided.', @@ -79,6 +85,8 @@ export const adminRouter = router({ userId: ctx.user.id, }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to update the site setting provided.', @@ -95,6 +103,7 @@ export const adminRouter = router({ return await sealDocument({ documentId: id, isResealing: true }); } catch (err) { console.log('resealDocument error', err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to reseal the document provided.', diff --git a/packages/trpc/server/api-token-router/router.ts b/packages/trpc/server/api-token-router/router.ts index 14e75e001..55129f029 100644 --- a/packages/trpc/server/api-token-router/router.ts +++ b/packages/trpc/server/api-token-router/router.ts @@ -16,7 +16,9 @@ export const apiTokenRouter = router({ getTokens: authenticatedProcedure.query(async ({ ctx }) => { try { return await getUserTokens({ userId: ctx.user.id }); - } catch (e) { + } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to find your API tokens. Please try again.', @@ -34,7 +36,9 @@ export const apiTokenRouter = router({ id, userId: ctx.user.id, }); - } catch (e) { + } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to find this API token. Please try again.', @@ -54,7 +58,9 @@ export const apiTokenRouter = router({ tokenName, expiresIn: expirationDate, }); - } catch (e) { + } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to create an API token. Please try again.', @@ -73,7 +79,9 @@ export const apiTokenRouter = router({ teamId, userId: ctx.user.id, }); - } catch (e) { + } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to delete this API Token. Please try again.', diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 4a6f11e60..6e7e8764f 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -115,6 +115,8 @@ export const documentRouter = router({ requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { + console.error(err); + if (err instanceof TRPCError) { throw err; } @@ -222,13 +224,19 @@ export const documentRouter = router({ const userId = ctx.user.id; - return await updateTitle({ - title, - userId, - teamId, - documentId, - requestMetadata: extractNextApiRequestMetadata(ctx.req), - }); + try { + return await updateTitle({ + title, + userId, + teamId, + documentId, + requestMetadata: extractNextApiRequestMetadata(ctx.req), + }); + } catch (err) { + console.error(err); + + throw err; + } }), setPasswordForDocument: authenticatedProcedure @@ -347,7 +355,9 @@ export const documentRouter = router({ userId: ctx.user.id, }); return documents; - } catch (error) { + } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We are unable to search for documents. Please try again later.', diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 4b299b6a1..354e937a5 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -52,20 +52,26 @@ export const fieldRouter = router({ .mutation(async ({ input, ctx }) => { const { templateId, fields } = input; - await setFieldsForTemplate({ - userId: ctx.user.id, - templateId, - fields: fields.map((field) => ({ - id: field.nativeId, - signerEmail: field.signerEmail, - type: field.type, - pageNumber: field.pageNumber, - pageX: field.pageX, - pageY: field.pageY, - pageWidth: field.pageWidth, - pageHeight: field.pageHeight, - })), - }); + try { + await setFieldsForTemplate({ + userId: ctx.user.id, + templateId, + fields: fields.map((field) => ({ + id: field.nativeId, + signerEmail: field.signerEmail, + type: field.type, + pageNumber: field.pageNumber, + pageX: field.pageX, + pageY: field.pageY, + pageWidth: field.pageWidth, + pageHeight: field.pageHeight, + })), + }); + } catch (err) { + console.error(err); + + throw err; + } }), signFieldWithToken: procedure diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 542ac2807..eb5f54274 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -37,6 +37,8 @@ export const profileRouter = router({ ...input, }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to find user security audit logs. Please try again.', @@ -50,6 +52,8 @@ export const profileRouter = router({ return await getUserById({ id }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to retrieve the specified account. Please try again.', @@ -108,6 +112,8 @@ export const profileRouter = router({ return { success: true, url: user.url }; } catch (err) { + console.error(err); + const error = AppError.parseError(err); if (error.code !== AppErrorCode.UNKNOWN_ERROR) { @@ -135,6 +141,8 @@ export const profileRouter = router({ requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { + console.error(err); + let message = 'We were unable to update your profile. Please review the information you provided and try again.'; @@ -171,6 +179,8 @@ export const profileRouter = router({ requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { + console.error(err); + let message = 'We were unable to reset your password. Please try again.'; if (err instanceof Error) { @@ -192,6 +202,8 @@ export const profileRouter = router({ return await sendConfirmationToken({ email }); } catch (err) { + console.error(err); + let message = 'We were unable to send a confirmation email. Please try again.'; if (err instanceof Error) { @@ -211,6 +223,8 @@ export const profileRouter = router({ id: ctx.user.id, }); } catch (err) { + console.error(err); + let message = 'We were unable to delete your account. Please try again.'; if (err instanceof Error) { diff --git a/packages/trpc/server/singleplayer-router/router.ts b/packages/trpc/server/singleplayer-router/router.ts index 2095d7d42..33b125110 100644 --- a/packages/trpc/server/singleplayer-router/router.ts +++ b/packages/trpc/server/singleplayer-router/router.ts @@ -29,151 +29,157 @@ export const singleplayerRouter = router({ createSinglePlayerDocument: procedure .input(ZCreateSinglePlayerDocumentMutationSchema) .mutation(async ({ input }) => { - const { signer, fields, documentData, documentName } = input; + try { + const { signer, fields, documentData, documentName } = input; - const document = await getFile({ - data: documentData.data, - type: documentData.type, - }); - - const doc = await PDFDocument.load(document); - - const createdAt = new Date(); - - const isBase64 = signer.signature.startsWith('data:image/png;base64,'); - const signatureImageAsBase64 = isBase64 ? signer.signature : null; - const typedSignature = !isBase64 ? signer.signature : null; - - // Update the document with the fields inserted. - for (const field of fields) { - const isSignatureField = field.type === FieldType.SIGNATURE; - - await insertFieldInPDF(doc, { - ...mapField(field, signer), - Signature: isSignatureField - ? { - created: createdAt, - signatureImageAsBase64, - typedSignature, - // Dummy data. - id: -1, - recipientId: -1, - fieldId: -1, - } - : null, - // Dummy data. - id: -1, - secondaryId: '-1', - documentId: -1, - templateId: null, - recipientId: -1, + const document = await getFile({ + data: documentData.data, + type: documentData.type, }); - } - const unsignedPdfBytes = await doc.save(); + const doc = await PDFDocument.load(document); - const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) }); + const createdAt = new Date(); - const { token } = await prisma.$transaction( - async (tx) => { - const token = alphaid(); + const isBase64 = signer.signature.startsWith('data:image/png;base64,'); + const signatureImageAsBase64 = isBase64 ? signer.signature : null; + const typedSignature = !isBase64 ? signer.signature : null; - // Fetch service user who will be the owner of the document. - const serviceUser = await tx.user.findFirstOrThrow({ - where: { - email: SERVICE_USER_EMAIL, - }, + // Update the document with the fields inserted. + for (const field of fields) { + const isSignatureField = field.type === FieldType.SIGNATURE; + + await insertFieldInPDF(doc, { + ...mapField(field, signer), + Signature: isSignatureField + ? { + created: createdAt, + signatureImageAsBase64, + typedSignature, + // Dummy data. + id: -1, + recipientId: -1, + fieldId: -1, + } + : null, + // Dummy data. + id: -1, + secondaryId: '-1', + documentId: -1, + templateId: null, + recipientId: -1, }); + } - const { id: documentDataId } = await putFile({ - name: `${documentName}.pdf`, - type: 'application/pdf', - arrayBuffer: async () => Promise.resolve(signedPdfBuffer), - }); + const unsignedPdfBytes = await doc.save(); - // Create document. - const document = await tx.document.create({ - data: { - title: documentName, - status: DocumentStatus.COMPLETED, - documentDataId, - userId: serviceUser.id, - createdAt, - }, - }); + const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) }); - // Create recipient. - const recipient = await tx.recipient.create({ - data: { - documentId: document.id, - name: signer.name, - email: signer.email, - token, - signedAt: createdAt, - readStatus: ReadStatus.OPENED, - signingStatus: SigningStatus.SIGNED, - sendStatus: SendStatus.SENT, - }, - }); + const { token } = await prisma.$transaction( + async (tx) => { + const token = alphaid(); - // Create fields and signatures. - await Promise.all( - fields.map(async (field) => { - const insertedField = await tx.field.create({ - data: { - documentId: document.id, - recipientId: recipient.id, - ...mapField(field, signer), - }, - }); + // Fetch service user who will be the owner of the document. + const serviceUser = await tx.user.findFirstOrThrow({ + where: { + email: SERVICE_USER_EMAIL, + }, + }); - if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) { - await tx.signature.create({ + const { id: documentDataId } = await putFile({ + name: `${documentName}.pdf`, + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(signedPdfBuffer), + }); + + // Create document. + const document = await tx.document.create({ + data: { + title: documentName, + status: DocumentStatus.COMPLETED, + documentDataId, + userId: serviceUser.id, + createdAt, + }, + }); + + // Create recipient. + const recipient = await tx.recipient.create({ + data: { + documentId: document.id, + name: signer.name, + email: signer.email, + token, + signedAt: createdAt, + readStatus: ReadStatus.OPENED, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + }, + }); + + // Create fields and signatures. + await Promise.all( + fields.map(async (field) => { + const insertedField = await tx.field.create({ data: { - fieldId: insertedField.id, - signatureImageAsBase64, - typedSignature, + documentId: document.id, recipientId: recipient.id, + ...mapField(field, signer), }, }); - } - }), - ); - return { document, token }; - }, - { - maxWait: 5000, - timeout: 30000, - }, - ); + if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) { + await tx.signature.create({ + data: { + fieldId: insertedField.id, + signatureImageAsBase64, + typedSignature, + recipientId: recipient.id, + }, + }); + } + }), + ); - const template = createElement(DocumentSelfSignedEmailTemplate, { - documentName: documentName, - assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000', - }); + return { document, token }; + }, + { + maxWait: 5000, + timeout: 30000, + }, + ); - const [html, text] = await Promise.all([ - renderAsync(template), - renderAsync(template, { plainText: true }), - ]); + const template = createElement(DocumentSelfSignedEmailTemplate, { + documentName: documentName, + assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000', + }); - // Send email to signer. - await mailer.sendMail({ - to: { - address: signer.email, - name: signer.name, - }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, - subject: 'Document signed', - html, - text, - attachments: [{ content: signedPdfBuffer, filename: documentName }], - }); + const [html, text] = await Promise.all([ + renderAsync(template), + renderAsync(template, { plainText: true }), + ]); - return token; + // Send email to signer. + await mailer.sendMail({ + to: { + address: signer.email, + name: signer.name, + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: 'Document signed', + html, + text, + attachments: [{ content: signedPdfBuffer, filename: documentName }], + }); + + return token; + } catch (err) { + console.error(err); + + throw err; + } }), }); diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 2dd4d51c8..4ed567b2b 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -56,6 +56,8 @@ export const templateRouter = router({ recipients: input.recipients, }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to create this document. Please try again later.', diff --git a/packages/trpc/server/webhook-router/router.ts b/packages/trpc/server/webhook-router/router.ts index 08b1b9bce..d1479457b 100644 --- a/packages/trpc/server/webhook-router/router.ts +++ b/packages/trpc/server/webhook-router/router.ts @@ -21,6 +21,8 @@ export const webhookRouter = router({ try { return await getWebhooksByUserId(ctx.user.id); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to fetch your webhooks. Please try again later.', @@ -36,6 +38,8 @@ export const webhookRouter = router({ try { return await getWebhooksByTeamId(teamId, ctx.user.id); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to fetch your webhooks. Please try again later.', @@ -55,6 +59,8 @@ export const webhookRouter = router({ teamId, }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to fetch your webhook. Please try again later.', @@ -77,6 +83,8 @@ export const webhookRouter = router({ userId: ctx.user.id, }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to create this webhook. Please try again later.', @@ -96,6 +104,8 @@ export const webhookRouter = router({ userId: ctx.user.id, }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to create this webhook. Please try again later.', @@ -116,6 +126,8 @@ export const webhookRouter = router({ teamId, }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to create this webhook. Please try again later.', From cbe62704940ee0877f62a417c078db855c9c9fe7 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sun, 31 Mar 2024 15:49:12 +0800 Subject: [PATCH 204/299] feat: add passkey and 2FA document action auth options (#1065) ## Description Add the following document action auth options: - 2FA - Passkey If the user does not have the required auth setup, we onboard them directly. ## Changes made Note: Added secondaryId to the VerificationToken schema ## Testing Performed Tested locally, pending preview tests ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have added/updated tests that prove the effectiveness of these changes. - [X] I have followed the project's coding style guidelines. ## Summary by CodeRabbit - **New Features** - Introduced components for 2FA, account, and passkey authentication during document signing. - Added "Require passkey" option to document settings and signer authentication settings. - Enhanced form submission and loading states for improved user experience. - **Refactor** - Optimized authentication components to efficiently support multiple authentication methods. - **Chores** - Updated and renamed functions and components for clarity and consistency across the authentication system. - Refined sorting options and database schema to support new authentication features. - **Bug Fixes** - Adjusted SignInForm to verify browser support for WebAuthn before proceeding. --- .../passkeys/create-passkey-dialog.tsx | 4 +- .../sign/[token]/document-action-auth-2fa.tsx | 172 ++++++++++++ .../[token]/document-action-auth-account.tsx | 79 ++++++ .../[token]/document-action-auth-dialog.tsx | 207 ++------------ .../[token]/document-action-auth-passkey.tsx | 252 ++++++++++++++++++ .../sign/[token]/document-auth-provider.tsx | 74 ++++- .../src/app/(signing)/sign/[token]/form.tsx | 8 +- .../2fa/enable-authenticator-app-dialog.tsx | 10 +- apps/web/src/components/forms/signin.tsx | 2 +- .../e2e/document-auth/action-auth.spec.ts | 6 +- .../e2e/pr-718-add-stepper-component.spec.ts | 22 +- packages/lib/constants/document-auth.ts | 21 +- packages/lib/next-auth/auth-options.ts | 4 +- .../create-passkey-authentication-options.ts | 76 ++++++ .../create-passkey-registration-options.ts | 4 +- .../auth/create-passkey-signin-options.ts | 4 +- .../lib/server-only/auth/create-passkey.ts | 4 +- .../lib/server-only/auth/find-passkeys.ts | 9 +- .../document/is-recipient-authorized.ts | 141 +++++++++- packages/lib/types/document-auth.ts | 40 ++- packages/lib/utils/authenticator.ts | 2 +- .../migration.sql | 18 ++ packages/prisma/schema.prisma | 15 +- packages/trpc/server/auth-router/router.ts | 21 ++ packages/trpc/server/auth-router/schema.ts | 6 + .../primitives/document-flow/add-settings.tsx | 4 + .../primitives/document-flow/add-signers.tsx | 4 + 27 files changed, 966 insertions(+), 243 deletions(-) create mode 100644 apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx create mode 100644 apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx create mode 100644 apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx create mode 100644 packages/lib/server-only/auth/create-passkey-authentication-options.ts create mode 100644 packages/prisma/migrations/20240327074701_add_secondary_verification_id/migration.sql diff --git a/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx b/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx index c07d638c0..f6db55e10 100644 --- a/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx @@ -38,6 +38,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type CreatePasskeyDialogProps = { trigger?: React.ReactNode; + onSuccess?: () => void; } & Omit; const ZCreatePasskeyFormSchema = z.object({ @@ -48,7 +49,7 @@ type TCreatePasskeyFormSchema = z.infer; const parser = new UAParser(); -export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogProps) => { +export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => { const [open, setOpen] = useState(false); const [formError, setFormError] = useState(null); @@ -84,6 +85,7 @@ export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogPr duration: 5000, }); + onSuccess?.(); setOpen(false); } catch (err) { if (err.name === 'NotAllowedError') { diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx new file mode 100644 index 000000000..98bcacf10 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { RecipientRole } from '@documenso/prisma/client'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DialogFooter } from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; + +import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog'; + +import { useRequiredDocumentAuthContext } from './document-auth-provider'; + +export type DocumentActionAuth2FAProps = { + actionTarget?: 'FIELD' | 'DOCUMENT'; + actionVerb?: string; + open: boolean; + onOpenChange: (value: boolean) => void; + onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; +}; + +const Z2FAAuthFormSchema = z.object({ + token: z + .string() + .min(4, { message: 'Token must at least 4 characters long' }) + .max(10, { message: 'Token must be at most 10 characters long' }), +}); + +type T2FAAuthFormSchema = z.infer; + +export const DocumentActionAuth2FA = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onReauthFormSubmit, + open, + onOpenChange, +}: DocumentActionAuth2FAProps) => { + const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = + useRequiredDocumentAuthContext(); + + const form = useForm({ + resolver: zodResolver(Z2FAAuthFormSchema), + defaultValues: { + token: '', + }, + }); + + const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false); + const [formErrorCode, setFormErrorCode] = useState(null); + + const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => { + try { + setIsCurrentlyAuthenticating(true); + + await onReauthFormSubmit({ + type: DocumentAuth.TWO_FACTOR_AUTH, + token, + }); + + setIsCurrentlyAuthenticating(false); + + onOpenChange(false); + } catch (err) { + setIsCurrentlyAuthenticating(false); + + const error = AppError.parseError(err); + setFormErrorCode(error.code); + + // Todo: Alert. + } + }; + + useEffect(() => { + form.reset({ + token: '', + }); + + setIs2FASetupSuccessful(false); + setFormErrorCode(null); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + if (!user?.twoFactorEnabled && !is2FASetupSuccessful) { + return ( +
+ + +

+ {recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' + ? 'You need to setup 2FA to mark this document as viewed.' + : `You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`} +

+ + {user?.identityProvider === 'DOCUMENSO' && ( +

+ By enabling 2FA, you will be required to enter a code from your authenticator app + every time you sign in. +

+ )} +
+
+ + + + + setIs2FASetupSuccessful(true)} /> + +
+ ); + } + + return ( +
+ +
+
+ ( + + 2FA token + + + + + + + + )} + /> + + {formErrorCode && ( + + Unauthorized + + We were unable to verify your details. Please try again or contact support + + + )} + + + + + + +
+
+
+ + ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx new file mode 100644 index 000000000..c09a60189 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; + +import { DateTime } from 'luxon'; +import { signOut } from 'next-auth/react'; + +import { RecipientRole } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DialogFooter } from '@documenso/ui/primitives/dialog'; + +import { useRequiredDocumentAuthContext } from './document-auth-provider'; + +export type DocumentActionAuthAccountProps = { + actionTarget?: 'FIELD' | 'DOCUMENT'; + actionVerb?: string; + onOpenChange: (value: boolean) => void; +}; + +export const DocumentActionAuthAccount = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onOpenChange, +}: DocumentActionAuthAccountProps) => { + const { recipient } = useRequiredDocumentAuthContext(); + + const [isSigningOut, setIsSigningOut] = useState(false); + + const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation(); + + const handleChangeAccount = async (email: string) => { + try { + setIsSigningOut(true); + + const encryptedEmail = await encryptSecondaryData({ + data: email, + expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), + }); + + await signOut({ + callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`, + }); + } catch { + setIsSigningOut(false); + + // Todo: Alert. + } + }; + + return ( +
+ + + {actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? ( + + To mark this document as viewed, you need to be logged in as{' '} + {recipient.email} + + ) : ( + + To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged + in as {recipient.email} + + )} + + + + + + + + +
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx index 7ab92f75c..0aed60be0 100644 --- a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx @@ -1,13 +1,4 @@ -/** - * Note: This file has some commented out stuff for password auth which is no longer possible. - * - * Leaving it here until after we add passkeys and 2FA since it can be reused. - */ -import { useState } from 'react'; - -import { DateTime } from 'luxon'; -import { signOut } from 'next-auth/react'; -import { match } from 'ts-pattern'; +import { P, match } from 'ts-pattern'; import { DocumentAuth, @@ -15,18 +6,17 @@ import { type TRecipientActionAuthTypes, } from '@documenso/lib/types/document-auth'; import type { FieldType } from '@documenso/prisma/client'; -import { trpc } from '@documenso/trpc/react'; -import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; -import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from '@documenso/ui/primitives/dialog'; +import { DocumentActionAuth2FA } from './document-action-auth-2fa'; +import { DocumentActionAuthAccount } from './document-action-auth-account'; +import { DocumentActionAuthPasskey } from './document-action-auth-passkey'; import { useRequiredDocumentAuthContext } from './document-auth-provider'; export type DocumentActionAuthDialogProps = { @@ -34,7 +24,6 @@ export type DocumentActionAuthDialogProps = { documentAuthType: TRecipientActionAuthTypes; description?: string; actionTarget: FieldType | 'DOCUMENT'; - isSubmitting?: boolean; open: boolean; onOpenChange: (value: boolean) => void; @@ -44,96 +33,24 @@ export type DocumentActionAuthDialogProps = { onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; }; -// const ZReauthFormSchema = z.object({ -// password: ZCurrentPasswordSchema, -// }); -// type TReauthFormSchema = z.infer; - export const DocumentActionAuthDialog = ({ title, description, documentAuthType, - // onReauthFormSubmit, - isSubmitting, open, onOpenChange, + onReauthFormSubmit, }: DocumentActionAuthDialogProps) => { - const { recipient } = useRequiredDocumentAuthContext(); - - // const form = useForm({ - // resolver: zodResolver(ZReauthFormSchema), - // defaultValues: { - // password: '', - // }, - // }); - - const [isSigningOut, setIsSigningOut] = useState(false); - - const isLoading = isSigningOut || isSubmitting; // || form.formState.isSubmitting; - - const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation(); - - // const [formErrorCode, setFormErrorCode] = useState(null); - // const onFormSubmit = async (_values: TReauthFormSchema) => { - // const documentAuthValue: TRecipientActionAuth = match(documentAuthType) - // // Todo: Add passkey. - // // .with(DocumentAuthType.PASSKEY, (type) => ({ - // // type, - // // value, - // // })) - // .otherwise((type) => ({ - // type, - // })); - - // try { - // await onReauthFormSubmit(documentAuthValue); - - // onOpenChange(false); - // } catch (e) { - // const error = AppError.parseError(e); - // setFormErrorCode(error.code); - - // // Suppress unauthorized errors since it's handled in this component. - // if (error.code === AppErrorCode.UNAUTHORIZED) { - // return; - // } - - // throw error; - // } - // }; - - const handleChangeAccount = async (email: string) => { - try { - setIsSigningOut(true); - - const encryptedEmail = await encryptSecondaryData({ - data: email, - expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), - }); - - await signOut({ - callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`, - }); - } catch { - setIsSigningOut(false); - - // Todo: Alert. - } - }; + const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentAuthContext(); const handleOnOpenChange = (value: boolean) => { - if (isLoading) { + if (isCurrentlyAuthenticating) { return; } onOpenChange(value); }; - // useEffect(() => { - // form.reset(); - // setFormErrorCode(null); - // }, [open, form]); - return ( @@ -141,100 +58,32 @@ export const DocumentActionAuthDialog = ({ {title || 'Sign field'} - {description || `Reauthentication is required to sign the field`} + {description || 'Reauthentication is required to sign this field'} - {match(documentAuthType) - .with(DocumentAuth.ACCOUNT, () => ( -
- - - To sign this field, you need to be logged in as {recipient.email} - - - - - - - - -
+ {match({ documentAuthType, user }) + .with( + { documentAuthType: DocumentAuth.ACCOUNT }, + { user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in. + () => , + ) + .with({ documentAuthType: DocumentAuth.PASSKEY }, () => ( + )) - .with(DocumentAuth.EXPLICIT_NONE, () => null) + .with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => ( + + )) + .with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null) .exhaustive()} - - {/*
- -
- - Email - - - - - - - ( - - Password - - - - - - - - )} - /> - - {formErrorCode && ( - - {match(formErrorCode) - .with(AppErrorCode.UNAUTHORIZED, () => ( - <> - Unauthorized - - We were unable to verify your details. Please ensure the details are - correct - - - )) - .otherwise(() => ( - <> - Something went wrong - - We were unable to sign this field at this time. Please try again or - contact support. - - - ))} - - )} - - - - - - -
-
- */}
); diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx new file mode 100644 index 000000000..da1be1f38 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx @@ -0,0 +1,252 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'; +import { Loader } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { RecipientRole } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DialogFooter } from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; + +import { CreatePasskeyDialog } from '~/app/(dashboard)/settings/security/passkeys/create-passkey-dialog'; + +import { useRequiredDocumentAuthContext } from './document-auth-provider'; + +export type DocumentActionAuthPasskeyProps = { + actionTarget?: 'FIELD' | 'DOCUMENT'; + actionVerb?: string; + open: boolean; + onOpenChange: (value: boolean) => void; + onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; +}; + +const ZPasskeyAuthFormSchema = z.object({ + passkeyId: z.string(), +}); + +type TPasskeyAuthFormSchema = z.infer; + +export const DocumentActionAuthPasskey = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onReauthFormSubmit, + open, + onOpenChange, +}: DocumentActionAuthPasskeyProps) => { + const { + recipient, + passkeyData, + preferredPasskeyId, + setPreferredPasskeyId, + isCurrentlyAuthenticating, + setIsCurrentlyAuthenticating, + refetchPasskeys, + } = useRequiredDocumentAuthContext(); + + const form = useForm({ + resolver: zodResolver(ZPasskeyAuthFormSchema), + defaultValues: { + passkeyId: preferredPasskeyId || '', + }, + }); + + const { mutateAsync: createPasskeyAuthenticationOptions } = + trpc.auth.createPasskeyAuthenticationOptions.useMutation(); + + const [formErrorCode, setFormErrorCode] = useState(null); + + const onFormSubmit = async ({ passkeyId }: TPasskeyAuthFormSchema) => { + try { + setPreferredPasskeyId(passkeyId); + setIsCurrentlyAuthenticating(true); + + const { options, tokenReference } = await createPasskeyAuthenticationOptions({ + preferredPasskeyId: passkeyId, + }); + + const authenticationResponse = await startAuthentication(options); + + await onReauthFormSubmit({ + type: DocumentAuth.PASSKEY, + authenticationResponse, + tokenReference, + }); + + setIsCurrentlyAuthenticating(false); + + onOpenChange(false); + } catch (err) { + setIsCurrentlyAuthenticating(false); + + if (err.name === 'NotAllowedError') { + return; + } + + const error = AppError.parseError(err); + setFormErrorCode(error.code); + + // Todo: Alert. + } + }; + + useEffect(() => { + form.reset({ + passkeyId: preferredPasskeyId || '', + }); + + setFormErrorCode(null); + }, [open, form, preferredPasskeyId]); + + if (!browserSupportsWebAuthn()) { + return ( +
+ + + Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '} + this {actionTarget.toLowerCase()}. + + + + + + +
+ ); + } + + if (passkeyData.isInitialLoading || (passkeyData.isError && passkeyData.passkeys.length === 0)) { + return ( +
+ +
+ ); + } + + if (passkeyData.isError) { + return ( +
+ + Something went wrong while loading your passkeys. + + + + + + + +
+ ); + } + + if (passkeyData.passkeys.length === 0) { + return ( +
+ + + {recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' + ? 'You need to setup a passkey to mark this document as viewed.' + : `You need to setup a passkey to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`} + + + + + + + refetchPasskeys()} + trigger={} + /> + +
+ ); + } + + return ( +
+ +
+
+ ( + + Passkey + + + + + + + + )} + /> + + {formErrorCode && ( + + Unauthorized + + We were unable to verify your details. Please try again or contact support + + + )} + + + + + + +
+
+
+ + ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx index c216f3905..86f673db0 100644 --- a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx @@ -1,10 +1,10 @@ 'use client'; -import { createContext, useContext, useMemo, useState } from 'react'; +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { match } from 'ts-pattern'; -import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; +import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth'; import type { TDocumentAuthOptions, TRecipientAccessAuthTypes, @@ -13,11 +13,25 @@ import type { } from '@documenso/lib/types/document-auth'; import { DocumentAuth } from '@documenso/lib/types/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; -import { type Document, FieldType, type Recipient, type User } from '@documenso/prisma/client'; +import { + type Document, + FieldType, + type Passkey, + type Recipient, + type User, +} from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog'; import { DocumentActionAuthDialog } from './document-action-auth-dialog'; +type PasskeyData = { + passkeys: Omit[]; + isInitialLoading: boolean; + isRefetching: boolean; + isError: boolean; +}; + export type DocumentAuthContextValue = { executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise; document: Document; @@ -29,7 +43,13 @@ export type DocumentAuthContextValue = { derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null; derivedRecipientActionAuth: TRecipientActionAuthTypes | null; isAuthRedirectRequired: boolean; + isCurrentlyAuthenticating: boolean; + setIsCurrentlyAuthenticating: (_value: boolean) => void; + passkeyData: PasskeyData; + preferredPasskeyId: string | null; + setPreferredPasskeyId: (_value: string | null) => void; user?: User | null; + refetchPasskeys: () => Promise; }; const DocumentAuthContext = createContext(null); @@ -64,6 +84,9 @@ export const DocumentAuthProvider = ({ const [document, setDocument] = useState(initialDocument); const [recipient, setRecipient] = useState(initialRecipient); + const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false); + const [preferredPasskeyId, setPreferredPasskeyId] = useState(null); + const { documentAuthOption, recipientAuthOption, @@ -78,6 +101,23 @@ export const DocumentAuthProvider = ({ [document, recipient], ); + const passkeyQuery = trpc.auth.findPasskeys.useQuery( + { + perPage: MAXIMUM_PASSKEYS, + }, + { + keepPreviousData: true, + enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY, + }, + ); + + const passkeyData: PasskeyData = { + passkeys: passkeyQuery.data?.data || [], + isInitialLoading: passkeyQuery.isInitialLoading, + isRefetching: passkeyQuery.isRefetching, + isError: passkeyQuery.isError, + }; + const [documentAuthDialogPayload, setDocumentAuthDialogPayload] = useState(null); @@ -101,7 +141,7 @@ export const DocumentAuthProvider = ({ .with(DocumentAuth.EXPLICIT_NONE, () => ({ type: DocumentAuth.EXPLICIT_NONE, })) - .with(null, () => null) + .with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null) .exhaustive(); const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { @@ -124,11 +164,27 @@ export const DocumentAuthProvider = ({ }); }; + useEffect(() => { + const { passkeys } = passkeyData; + + if (!preferredPasskeyId && passkeys.length > 0) { + setPreferredPasskeyId(passkeys[0].id); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [passkeyData.passkeys]); + + // Assume that a user must be logged in for any auth requirements. const isAuthRedirectRequired = Boolean( - DOCUMENT_AUTH_TYPES[derivedRecipientActionAuth || '']?.isAuthRedirectRequired && - !preCalculatedActionAuthOptions, + derivedRecipientActionAuth && + derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE && + user?.email !== recipient.email, ); + const refetchPasskeys = async () => { + await passkeyQuery.refetch(); + }; + return ( {children} diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 2b9b9d294..70897a716 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -42,10 +42,10 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin const { mutateAsync: completeDocumentWithToken } = trpc.recipient.completeDocumentWithToken.useMutation(); - const { - handleSubmit, - formState: { isSubmitting }, - } = useForm(); + const { handleSubmit, formState } = useForm(); + + // Keep the loading state going if successful since the redirect may take some time. + const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful; const uninsertedFields = useMemo(() => { return sortFieldsByPosition(fields.filter((field) => !field.inserted)); 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 0a6aac5dc..ce0b66ba4 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 @@ -41,8 +41,13 @@ export const ZEnable2FAForm = z.object({ export type TEnable2FAForm = z.infer; -export const EnableAuthenticatorAppDialog = () => { +export type EnableAuthenticatorAppDialogProps = { + onSuccess?: () => void; +}; + +export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => { const { toast } = useToast(); + const router = useRouter(); const [isOpen, setIsOpen] = useState(false); @@ -79,6 +84,7 @@ export const EnableAuthenticatorAppDialog = () => { const data = await enable2FA({ code: token }); setRecoveryCodes(data.recoveryCodes); + onSuccess?.(); toast({ title: 'Two-factor authentication enabled', @@ -89,7 +95,7 @@ export const EnableAuthenticatorAppDialog = () => { toast({ title: 'Unable to setup two-factor authentication', description: - 'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.', + 'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.', variant: 'destructive', }); } diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 6fa5492ac..8d4dd7cd0 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -124,7 +124,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign }; const onSignInWithPasskey = async () => { - if (!browserSupportsWebAuthn) { + if (!browserSupportsWebAuthn()) { toast({ title: 'Not supported', description: 'Passkeys are not supported on this browser', diff --git a/packages/app-tests/e2e/document-auth/action-auth.spec.ts b/packages/app-tests/e2e/document-auth/action-auth.spec.ts index 88ed1ac1d..b263dbd04 100644 --- a/packages/app-tests/e2e/document-auth/action-auth.spec.ts +++ b/packages/app-tests/e2e/document-auth/action-auth.spec.ts @@ -191,7 +191,7 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth' await page.locator(`#field-${field.id}`).getByRole('button').click(); await expect(page.getByRole('paragraph')).toContainText( - 'Reauthentication is required to sign the field', + 'Reauthentication is required to sign this field', ); await page.getByRole('button', { name: 'Cancel' }).click(); } @@ -260,7 +260,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au await page.locator(`#field-${field.id}`).getByRole('button').click(); await expect(page.getByRole('paragraph')).toContainText( - 'Reauthentication is required to sign the field', + 'Reauthentication is required to sign this field', ); await page.getByRole('button', { name: 'Cancel' }).click(); } @@ -371,7 +371,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an await page.locator(`#field-${field.id}`).getByRole('button').click(); await expect(page.getByRole('paragraph')).toContainText( - 'Reauthentication is required to sign the field', + 'Reauthentication is required to sign this field', ); await page.getByRole('button', { name: 'Cancel' }).click(); } 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 70b0cfe72..142133367 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,13 +1,25 @@ 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 { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@documenso/prisma/client'; import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from './fixtures/authentication'; +const getDocumentByToken = async (token: string) => { + return await prisma.document.findFirstOrThrow({ + where: { + Recipient: { + some: { + token, + }, + }, + }, + }); +}; + test(`[PR-718]: should be able to create a document`, async ({ page }) => { await page.goto('/signin'); @@ -246,7 +258,7 @@ test('should be able to create, send and sign a document', async ({ page }) => { await page.waitForURL(`/sign/${token}`); // Check if document has been viewed - const { status } = await getDocumentByToken({ token }); + const { status } = await getDocumentByToken(token); expect(status).toBe(DocumentStatus.PENDING); await page.getByRole('button', { name: 'Complete' }).click(); @@ -257,7 +269,7 @@ test('should be able to create, send and sign a document', async ({ page }) => { await expect(page.getByText('You have signed')).toBeVisible(); // Check if document has been signed - const { status: completedStatus } = await getDocumentByToken({ token }); + const { status: completedStatus } = await getDocumentByToken(token); expect(completedStatus).toBe(DocumentStatus.COMPLETED); }); @@ -331,7 +343,7 @@ test('should be able to create, send with redirect url, sign a document and redi await page.waitForURL(`/sign/${token}`); // Check if document has been viewed - const { status } = await getDocumentByToken({ token }); + const { status } = await getDocumentByToken(token); expect(status).toBe(DocumentStatus.PENDING); await page.getByRole('button', { name: 'Complete' }).click(); @@ -341,6 +353,6 @@ test('should be able to create, send with redirect url, sign a document and redi await page.waitForURL('https://documenso.com'); // Check if document has been signed - const { status: completedStatus } = await getDocumentByToken({ token }); + const { status: completedStatus } = await getDocumentByToken(token); expect(completedStatus).toBe(DocumentStatus.COMPLETED); }); diff --git a/packages/lib/constants/document-auth.ts b/packages/lib/constants/document-auth.ts index 81f22236e..77c8d7b58 100644 --- a/packages/lib/constants/document-auth.ts +++ b/packages/lib/constants/document-auth.ts @@ -4,26 +4,21 @@ import { DocumentAuth } from '../types/document-auth'; type DocumentAuthTypeData = { key: TDocumentAuth; value: string; - - /** - * Whether this authentication event will require the user to halt and - * redirect. - * - * Defaults to false. - */ - isAuthRedirectRequired?: boolean; }; export const DOCUMENT_AUTH_TYPES: Record = { [DocumentAuth.ACCOUNT]: { key: DocumentAuth.ACCOUNT, value: 'Require account', - isAuthRedirectRequired: true, }, - // [DocumentAuthType.PASSKEY]: { - // key: DocumentAuthType.PASSKEY, - // value: 'Require passkey', - // }, + [DocumentAuth.PASSKEY]: { + key: DocumentAuth.PASSKEY, + value: 'Require passkey', + }, + [DocumentAuth.TWO_FACTOR_AUTH]: { + key: DocumentAuth.TWO_FACTOR_AUTH, + value: 'Require 2FA', + }, [DocumentAuth.EXPLICIT_NONE]: { key: DocumentAuth.EXPLICIT_NONE, value: 'None (Overrides global settings)', diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 425c7e70a..6805eedbe 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -22,7 +22,7 @@ import { sendConfirmationToken } from '../server-only/user/send-confirmation-tok import type { TAuthenticationResponseJSONSchema } from '../types/webauthn'; import { ZAuthenticationResponseJSONSchema } from '../types/webauthn'; import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata'; -import { getAuthenticatorRegistrationOptions } from '../utils/authenticator'; +import { getAuthenticatorOptions } from '../utils/authenticator'; import { ErrorCode } from './error-codes'; export const NEXT_AUTH_OPTIONS: AuthOptions = { @@ -196,7 +196,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { const user = passkey.User; - const { rpId, origin } = getAuthenticatorRegistrationOptions(); + const { rpId, origin } = getAuthenticatorOptions(); const verification = await verifyAuthenticationResponse({ response: requestBodyCrediential, diff --git a/packages/lib/server-only/auth/create-passkey-authentication-options.ts b/packages/lib/server-only/auth/create-passkey-authentication-options.ts new file mode 100644 index 000000000..e7c4178d6 --- /dev/null +++ b/packages/lib/server-only/auth/create-passkey-authentication-options.ts @@ -0,0 +1,76 @@ +import { generateAuthenticationOptions } from '@simplewebauthn/server'; +import type { AuthenticatorTransportFuture } from '@simplewebauthn/types'; +import { DateTime } from 'luxon'; + +import { prisma } from '@documenso/prisma'; +import type { Passkey } from '@documenso/prisma/client'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { getAuthenticatorOptions } from '../../utils/authenticator'; + +type CreatePasskeyAuthenticationOptions = { + userId: number; + + /** + * The ID of the passkey to request authentication for. + * + * If not set, we allow the browser client to handle choosing. + */ + preferredPasskeyId?: string; +}; + +export const createPasskeyAuthenticationOptions = async ({ + userId, + preferredPasskeyId, +}: CreatePasskeyAuthenticationOptions) => { + const { rpId, timeout } = getAuthenticatorOptions(); + + let preferredPasskey: Pick | null = null; + + if (preferredPasskeyId) { + preferredPasskey = await prisma.passkey.findFirst({ + where: { + userId, + id: preferredPasskeyId, + }, + select: { + credentialId: true, + transports: true, + }, + }); + + if (!preferredPasskey) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Requested passkey not found'); + } + } + + const options = await generateAuthenticationOptions({ + rpID: rpId, + userVerification: 'preferred', + timeout, + allowCredentials: preferredPasskey + ? [ + { + id: preferredPasskey.credentialId, + type: 'public-key', + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + transports: preferredPasskey.transports as AuthenticatorTransportFuture[], + }, + ] + : undefined, + }); + + const { secondaryId } = await prisma.verificationToken.create({ + data: { + userId, + token: options.challenge, + expires: DateTime.now().plus({ minutes: 2 }).toJSDate(), + identifier: 'PASSKEY_CHALLENGE', + }, + }); + + return { + tokenReference: secondaryId, + options, + }; +}; diff --git a/packages/lib/server-only/auth/create-passkey-registration-options.ts b/packages/lib/server-only/auth/create-passkey-registration-options.ts index 5c9d73b8a..8f2b3d53a 100644 --- a/packages/lib/server-only/auth/create-passkey-registration-options.ts +++ b/packages/lib/server-only/auth/create-passkey-registration-options.ts @@ -5,7 +5,7 @@ import { DateTime } from 'luxon'; import { prisma } from '@documenso/prisma'; import { PASSKEY_TIMEOUT } from '../../constants/auth'; -import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; +import { getAuthenticatorOptions } from '../../utils/authenticator'; type CreatePasskeyRegistrationOptions = { userId: number; @@ -27,7 +27,7 @@ export const createPasskeyRegistrationOptions = async ({ const { passkeys } = user; - const { rpName, rpId: rpID } = getAuthenticatorRegistrationOptions(); + const { rpName, rpId: rpID } = getAuthenticatorOptions(); const options = await generateRegistrationOptions({ rpName, diff --git a/packages/lib/server-only/auth/create-passkey-signin-options.ts b/packages/lib/server-only/auth/create-passkey-signin-options.ts index 03241edd0..e6f9a7152 100644 --- a/packages/lib/server-only/auth/create-passkey-signin-options.ts +++ b/packages/lib/server-only/auth/create-passkey-signin-options.ts @@ -3,14 +3,14 @@ import { DateTime } from 'luxon'; import { prisma } from '@documenso/prisma'; -import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; +import { getAuthenticatorOptions } from '../../utils/authenticator'; type CreatePasskeySigninOptions = { sessionId: string; }; export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => { - const { rpId, timeout } = getAuthenticatorRegistrationOptions(); + const { rpId, timeout } = getAuthenticatorOptions(); const options = await generateAuthenticationOptions({ rpID: rpId, diff --git a/packages/lib/server-only/auth/create-passkey.ts b/packages/lib/server-only/auth/create-passkey.ts index c493d8205..0ec86845d 100644 --- a/packages/lib/server-only/auth/create-passkey.ts +++ b/packages/lib/server-only/auth/create-passkey.ts @@ -7,7 +7,7 @@ import { UserSecurityAuditLogType } from '@documenso/prisma/client'; import { MAXIMUM_PASSKEYS } from '../../constants/auth'; import { AppError, AppErrorCode } from '../../errors/app-error'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; -import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; +import { getAuthenticatorOptions } from '../../utils/authenticator'; type CreatePasskeyOptions = { userId: number; @@ -64,7 +64,7 @@ export const createPasskey = async ({ throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired'); } - const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorRegistrationOptions(); + const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorOptions(); const verification = await verifyRegistrationResponse({ response: verificationResponse, diff --git a/packages/lib/server-only/auth/find-passkeys.ts b/packages/lib/server-only/auth/find-passkeys.ts index 26eac95c3..8f21c8aa6 100644 --- a/packages/lib/server-only/auth/find-passkeys.ts +++ b/packages/lib/server-only/auth/find-passkeys.ts @@ -11,6 +11,7 @@ export interface FindPasskeysOptions { orderBy?: { column: keyof Passkey; direction: 'asc' | 'desc'; + nulls?: Prisma.NullsOrder; }; } @@ -21,8 +22,9 @@ export const findPasskeys = async ({ perPage = 10, orderBy, }: FindPasskeysOptions) => { - const orderByColumn = orderBy?.column ?? 'name'; + const orderByColumn = orderBy?.column ?? 'lastUsedAt'; const orderByDirection = orderBy?.direction ?? 'desc'; + const orderByNulls: Prisma.NullsOrder | undefined = orderBy?.nulls ?? 'last'; const whereClause: Prisma.PasskeyWhereInput = { userId, @@ -41,7 +43,10 @@ export const findPasskeys = async ({ skip: Math.max(page - 1, 0) * perPage, take: perPage, orderBy: { - [orderByColumn]: orderByDirection, + [orderByColumn]: { + sort: orderByDirection, + nulls: orderByNulls, + }, }, select: { id: true, diff --git a/packages/lib/server-only/document/is-recipient-authorized.ts b/packages/lib/server-only/document/is-recipient-authorized.ts index 2c7e9b6e4..5da50d6c7 100644 --- a/packages/lib/server-only/document/is-recipient-authorized.ts +++ b/packages/lib/server-only/document/is-recipient-authorized.ts @@ -1,10 +1,15 @@ +import { verifyAuthenticationResponse } from '@simplewebauthn/server'; import { match } from 'ts-pattern'; import { prisma } from '@documenso/prisma'; import type { Document, Recipient } from '@documenso/prisma/client'; +import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token'; +import { AppError, AppErrorCode } from '../../errors/app-error'; import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth'; import { DocumentAuth } from '../../types/document-auth'; +import type { TAuthenticationResponseJSONSchema } from '../../types/webauthn'; +import { getAuthenticatorOptions } from '../../utils/authenticator'; import { extractDocumentAuthMethods } from '../../utils/document-auth'; type IsRecipientAuthorizedOptions = { @@ -63,17 +68,20 @@ export const isRecipientAuthorized = async ({ return true; } + // Create auth options when none are passed for account. + if (!authOptions && authMethod === DocumentAuth.ACCOUNT) { + authOptions = { + type: DocumentAuth.ACCOUNT, + }; + } + // Authentication required does not match provided method. - if (authOptions && authOptions.type !== authMethod) { + if (!authOptions || authOptions.type !== authMethod || !userId) { return false; } - return await match(authMethod) - .with(DocumentAuth.ACCOUNT, async () => { - if (userId === undefined) { - return false; - } - + return await match(authOptions) + .with({ type: DocumentAuth.ACCOUNT }, async () => { const recipientUser = await getUserByEmail(recipient.email); if (!recipientUser) { @@ -82,5 +90,124 @@ export const isRecipientAuthorized = async ({ return recipientUser.id === userId; }) + .with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => { + return await isPasskeyAuthValid({ + userId, + authenticationResponse, + tokenReference, + }); + }) + .with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => { + const user = await prisma.user.findFirst({ + where: { + id: userId, + }, + }); + + // Should not be possible. + if (!user) { + throw new AppError(AppErrorCode.NOT_FOUND, 'User not found'); + } + + return await verifyTwoFactorAuthenticationToken({ + user, + totpCode: token, + }); + }) .exhaustive(); }; + +type VerifyPasskeyOptions = { + /** + * The ID of the user who initiated the request. + */ + userId: number; + + /** + * The secondary ID of the verification token. + */ + tokenReference: string; + + /** + * The response from the passkey authenticator. + */ + authenticationResponse: TAuthenticationResponseJSONSchema; +}; + +/** + * Whether the provided passkey authenticator response is valid and the user is + * authenticated. + */ +const isPasskeyAuthValid = async (options: VerifyPasskeyOptions): Promise => { + return verifyPasskey(options) + .then(() => true) + .catch(() => false); +}; + +/** + * Verifies whether the provided passkey authenticator is valid and the user is + * authenticated. + * + * Will throw an error if the user should not be authenticated. + */ +const verifyPasskey = async ({ + userId, + tokenReference, + authenticationResponse, +}: VerifyPasskeyOptions): Promise => { + const passkey = await prisma.passkey.findFirst({ + where: { + credentialId: Buffer.from(authenticationResponse.id, 'base64'), + userId, + }, + }); + + if (!passkey) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found'); + } + + const verificationToken = await prisma.verificationToken + .delete({ + where: { + userId, + secondaryId: tokenReference, + }, + }) + .catch(() => null); + + if (!verificationToken) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Token not found'); + } + + if (verificationToken.expires < new Date()) { + throw new AppError(AppErrorCode.EXPIRED_CODE, 'Token expired'); + } + + const { rpId, origin } = getAuthenticatorOptions(); + + const verification = await verifyAuthenticationResponse({ + response: authenticationResponse, + expectedChallenge: verificationToken.token, + expectedOrigin: origin, + expectedRPID: rpId, + authenticator: { + credentialID: new Uint8Array(Array.from(passkey.credentialId)), + credentialPublicKey: new Uint8Array(passkey.credentialPublicKey), + counter: Number(passkey.counter), + }, + }).catch(() => null); // May want to log this for insights. + + if (verification?.verified !== true) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'User is not authorized'); + } + + await prisma.passkey.update({ + where: { + id: passkey.id, + }, + data: { + lastUsedAt: new Date(), + counter: verification.authenticationInfo.newCounter, + }, + }); +}; diff --git a/packages/lib/types/document-auth.ts b/packages/lib/types/document-auth.ts index 730806d0c..eccd119eb 100644 --- a/packages/lib/types/document-auth.ts +++ b/packages/lib/types/document-auth.ts @@ -1,9 +1,16 @@ import { z } from 'zod'; +import { ZAuthenticationResponseJSONSchema } from './webauthn'; + /** * All the available types of document authentication options for both access and action. */ -export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'EXPLICIT_NONE']); +export const ZDocumentAuthTypesSchema = z.enum([ + 'ACCOUNT', + 'PASSKEY', + 'TWO_FACTOR_AUTH', + 'EXPLICIT_NONE', +]); export const DocumentAuth = ZDocumentAuthTypesSchema.Enum; const ZDocumentAuthAccountSchema = z.object({ @@ -14,12 +21,25 @@ const ZDocumentAuthExplicitNoneSchema = z.object({ type: z.literal(DocumentAuth.EXPLICIT_NONE), }); +const ZDocumentAuthPasskeySchema = z.object({ + type: z.literal(DocumentAuth.PASSKEY), + authenticationResponse: ZAuthenticationResponseJSONSchema, + tokenReference: z.string().min(1), +}); + +const ZDocumentAuth2FASchema = z.object({ + type: z.literal(DocumentAuth.TWO_FACTOR_AUTH), + token: z.string().min(4).max(10), +}); + /** * All the document auth methods for both accessing and actioning. */ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [ ZDocumentAuthAccountSchema, ZDocumentAuthExplicitNoneSchema, + ZDocumentAuthPasskeySchema, + ZDocumentAuth2FASchema, ]); /** @@ -35,8 +55,16 @@ export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); * * Must keep these two in sync. */ -export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); // Todo: Add passkeys here. -export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); +export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ + ZDocumentAuthAccountSchema, + ZDocumentAuthPasskeySchema, + ZDocumentAuth2FASchema, +]); +export const ZDocumentActionAuthTypesSchema = z.enum([ + DocumentAuth.ACCOUNT, + DocumentAuth.PASSKEY, + DocumentAuth.TWO_FACTOR_AUTH, +]); /** * The recipient access auth methods. @@ -54,11 +82,15 @@ export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); * Must keep these two in sync. */ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [ - ZDocumentAuthAccountSchema, // Todo: Add passkeys here. + ZDocumentAuthAccountSchema, + ZDocumentAuthPasskeySchema, + ZDocumentAuth2FASchema, ZDocumentAuthExplicitNoneSchema, ]); export const ZRecipientActionAuthTypesSchema = z.enum([ DocumentAuth.ACCOUNT, + DocumentAuth.PASSKEY, + DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.EXPLICIT_NONE, ]); diff --git a/packages/lib/utils/authenticator.ts b/packages/lib/utils/authenticator.ts index b5563a4ed..b689d82e9 100644 --- a/packages/lib/utils/authenticator.ts +++ b/packages/lib/utils/authenticator.ts @@ -4,7 +4,7 @@ import { PASSKEY_TIMEOUT } from '../constants/auth'; /** * Extracts common fields to identify the RP (relying party) */ -export const getAuthenticatorRegistrationOptions = () => { +export const getAuthenticatorOptions = () => { const webAppBaseUrl = new URL(WEBAPP_BASE_URL); const rpId = webAppBaseUrl.hostname; diff --git a/packages/prisma/migrations/20240327074701_add_secondary_verification_id/migration.sql b/packages/prisma/migrations/20240327074701_add_secondary_verification_id/migration.sql new file mode 100644 index 000000000..7f7aa53c8 --- /dev/null +++ b/packages/prisma/migrations/20240327074701_add_secondary_verification_id/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - A unique constraint covering the columns `[secondaryId]` on the table `VerificationToken` will be added. If there are existing duplicate values, this will fail. + - The required column `secondaryId` was added to the `VerificationToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + +*/ +-- AlterTable +ALTER TABLE "VerificationToken" ADD COLUMN "secondaryId" TEXT; + +-- Set all null secondaryId fields to a uuid +UPDATE "VerificationToken" SET "secondaryId" = gen_random_uuid()::text WHERE "secondaryId" IS NULL; + +-- Restrict the VerificationToken to required +ALTER TABLE "VerificationToken" ALTER COLUMN "secondaryId" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_secondaryId_key" ON "VerificationToken"("secondaryId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index d632ae60e..868b8d8e1 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -126,13 +126,14 @@ model AnonymousVerificationToken { } model VerificationToken { - id Int @id @default(autoincrement()) - identifier String - token String @unique - expires DateTime - createdAt DateTime @default(now()) - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + secondaryId String @unique @default(cuid()) + identifier String + token String @unique + expires DateTime + createdAt DateTime @default(now()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } enum WebhookTriggerEvents { diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 165882856..f9a1795d7 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -7,6 +7,7 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey'; +import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options'; import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options'; import { createPasskeySigninOptions } from '@documenso/lib/server-only/auth/create-passkey-signin-options'; import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey'; @@ -19,6 +20,7 @@ import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract- import { authenticatedProcedure, procedure, router } from '../trpc'; import { + ZCreatePasskeyAuthenticationOptionsMutationSchema, ZCreatePasskeyMutationSchema, ZDeletePasskeyMutationSchema, ZFindPasskeysQuerySchema, @@ -115,6 +117,25 @@ export const authRouter = router({ } }), + createPasskeyAuthenticationOptions: authenticatedProcedure + .input(ZCreatePasskeyAuthenticationOptionsMutationSchema) + .mutation(async ({ ctx, input }) => { + try { + return await createPasskeyAuthenticationOptions({ + userId: ctx.user.id, + preferredPasskeyId: input?.preferredPasskeyId, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'We were unable to create the authentication options for the passkey. Please try again later.', + }); + } + }), + createPasskeyRegistrationOptions: authenticatedProcedure.mutation(async ({ ctx }) => { try { return await createPasskeyRegistrationOptions({ diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index d78b429fc..b84c5e1c9 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -40,6 +40,12 @@ export const ZCreatePasskeyMutationSchema = z.object({ verificationResponse: ZRegistrationResponseJSONSchema, }); +export const ZCreatePasskeyAuthenticationOptionsMutationSchema = z + .object({ + preferredPasskeyId: z.string().optional(), + }) + .optional(); + export const ZDeletePasskeyMutationSchema = z.object({ passkeyId: z.string().trim().min(1), }); diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index e56640f06..a5c0c5d9e 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -219,6 +219,10 @@ 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 +
  • None - No authentication required
  • diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 3d1263914..f815ca4cd 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -287,6 +287,10 @@ export const AddSignersFormPartial = ({ Require account - The recipient must be signed in +
  • + Require passkey - The recipient must have + an account and passkey configured via their settings +
  • None - No authentication required
  • From 48a8f5fe076358465c353f3f83cdf8b61693273f Mon Sep 17 00:00:00 2001 From: Mythie Date: Tue, 2 Apr 2024 14:16:36 +0700 Subject: [PATCH 205/299] chore: add disclosure --- .../(signing)/sign/[token]/sign-dialog.tsx | 42 +++++-- .../sign/[token]/signature-field.tsx | 4 + .../articles/signature-disclosure/page.tsx | 108 ++++++++++++++++++ .../components/general/signing-disclosure.tsx | 29 +++++ 4 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/app/(unauthenticated)/articles/signature-disclosure/page.tsx create mode 100644 apps/web/src/components/general/signing-disclosure.tsx diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx index 9b2877033..3e6961467 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -7,9 +7,11 @@ import { Dialog, DialogContent, DialogFooter, + DialogTitle, DialogTrigger, } from '@documenso/ui/primitives/dialog'; +import { SigningDisclosure } from '~/components/general/signing-disclosure'; import { truncateTitle } from '~/helpers/truncate-title'; export type SignDialogProps = { @@ -66,23 +68,39 @@ export const SignDialog = ({ {isComplete ? 'Complete' : 'Next field'} + -
    +
    - {role === RecipientRole.VIEWER && 'Mark Document as Viewed'} - {role === RecipientRole.SIGNER && 'Sign Document'} - {role === RecipientRole.APPROVER && 'Approve Document'} -
    -
    - {role === RecipientRole.VIEWER && - `You are about to finish viewing "${truncatedTitle}". Are you sure?`} - {role === RecipientRole.SIGNER && - `You are about to finish signing "${truncatedTitle}". Are you sure?`} - {role === RecipientRole.APPROVER && - `You are about to finish approving "${truncatedTitle}". Are you sure?`} + {role === RecipientRole.VIEWER && 'Complete Viewing'} + {role === RecipientRole.SIGNER && 'Complete Signing'} + {role === RecipientRole.APPROVER && 'Complete Approval'}
    +
    + +
    + {role === RecipientRole.VIEWER && ( + + You are about to complete viewing "{truncatedTitle}". +
    Are you sure? +
    + )} + {role === RecipientRole.SIGNER && ( + + You are about to complete signing "{truncatedTitle}". +
    Are you sure? +
    + )} + {role === RecipientRole.APPROVER && ( + + You are about to complete approving "{truncatedTitle}". +
    Are you sure? +
    + )}
    + +
    + +
    +
    +
    + ); +} diff --git a/apps/web/src/components/general/signing-disclosure.tsx b/apps/web/src/components/general/signing-disclosure.tsx new file mode 100644 index 000000000..bd1ef9707 --- /dev/null +++ b/apps/web/src/components/general/signing-disclosure.tsx @@ -0,0 +1,29 @@ +import type { HTMLAttributes } from 'react'; + +import Link from 'next/link'; + +import { cn } from '@documenso/ui/lib/utils'; + +export type SigningDisclosureProps = HTMLAttributes; + +export const SigningDisclosure = ({ className, ...props }: SigningDisclosureProps) => { + return ( +

    + By proceeding with your electronic signature, you acknowledge and consent that it will be used + to sign the given document and holds the same legal validity as a handwritten signature. By + completing the electronic signing process, you affirm your understanding and acceptance of + these conditions. + + Read the full{' '} + + signature disclosure + + . + +

    + ); +}; From 484f603a6bead91e092876bcd64c9d3fdb30a8c3 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Wed, 3 Apr 2024 12:35:47 +0700 Subject: [PATCH 206/299] chore: remove coming soon (#1074) **Description:** This PR removes the coming soon text from the connections bento card --------- Signed-off-by: Adithya Krishna --- .../(marketing)/share-connect-paid-widget-bento.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx b/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx index 144810203..666580cf1 100644 --- a/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx +++ b/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import Image from 'next/image'; @@ -51,7 +51,7 @@ export const ShareConnectPaidWidgetBento = ({

    - Connections (Soon). + Connections Create connections and automations with Zapier and more to integrate with your favorite tools.

    From 58481f66b8f8f63d0cd13c9c4cf53c6b6880e2e6 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 3 Apr 2024 15:18:36 +0800 Subject: [PATCH 207/299] fix: enforce 2FA for email password SSO linked accounts (#1072) ## Description Fixed issue where accounts that were initially created via email/password, then linked to an SSO account, can bypass the 2FA during login if they use their email password. ## Testing Performed Tested locally, and 2FA is now required for linked SSO accounts --- .../app-tests/e2e/pr-718-add-stepper-component.spec.ts | 4 ++-- packages/app-tests/e2e/test-auth-flow.spec.ts | 2 +- packages/lib/server-only/2fa/is-2fa-availble.ts | 8 ++------ 3 files changed, 5 insertions(+), 9 deletions(-) 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 142133367..f24d74076 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 @@ -262,7 +262,7 @@ test('should be able to create, send and sign a document', async ({ page }) => { expect(status).toBe(DocumentStatus.PENDING); await page.getByRole('button', { name: 'Complete' }).click(); - await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible(); + await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible(); await page.getByRole('button', { name: 'Sign' }).click(); await page.waitForURL(`/sign/${token}/complete`); @@ -347,7 +347,7 @@ test('should be able to create, send with redirect url, sign a document and redi expect(status).toBe(DocumentStatus.PENDING); await page.getByRole('button', { name: 'Complete' }).click(); - await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible(); + await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible(); await page.getByRole('button', { name: 'Sign' }).click(); await page.waitForURL('https://documenso.com'); diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/test-auth-flow.spec.ts index 9c9500053..3b07371f1 100644 --- a/packages/app-tests/e2e/test-auth-flow.spec.ts +++ b/packages/app-tests/e2e/test-auth-flow.spec.ts @@ -30,7 +30,7 @@ test('user can sign up with email and password', async ({ page }: { page: Page } } await page.getByRole('button', { name: 'Next', exact: true }).click(); - await page.getByLabel('Public profile username').fill('username-123'); + await page.getByLabel('Public profile username').fill(Date.now().toString()); await page.getByRole('button', { name: 'Complete', exact: true }).click(); diff --git a/packages/lib/server-only/2fa/is-2fa-availble.ts b/packages/lib/server-only/2fa/is-2fa-availble.ts index d06a0085d..605d45215 100644 --- a/packages/lib/server-only/2fa/is-2fa-availble.ts +++ b/packages/lib/server-only/2fa/is-2fa-availble.ts @@ -1,4 +1,4 @@ -import { User } from '@documenso/prisma/client'; +import type { User } from '@documenso/prisma/client'; import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; @@ -9,9 +9,5 @@ type IsTwoFactorAuthenticationEnabledOptions = { export const isTwoFactorAuthenticationEnabled = ({ user, }: IsTwoFactorAuthenticationEnabledOptions) => { - return ( - user.twoFactorEnabled && - user.identityProvider === 'DOCUMENSO' && - typeof DOCUMENSO_ENCRYPTION_KEY === 'string' - ); + return user.twoFactorEnabled && typeof DOCUMENSO_ENCRYPTION_KEY === 'string'; }; From d1ffcb00f307bac8c8763e3ac39c868dd668059b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 3 Apr 2024 15:32:34 +0800 Subject: [PATCH 208/299] feat: add axiom web vitals (#1071) ## Description Added support for Axiom web vitals https://axiom.co/docs/apps/vercel#web-vitals --- apps/marketing/next.config.js | 3 ++- apps/marketing/package.json | 1 + apps/marketing/src/app/layout.tsx | 3 +++ apps/web/next.config.js | 3 ++- apps/web/package.json | 1 + apps/web/src/app/layout.tsx | 3 +++ package-lock.json | 23 +++++++++++++++++++++++ 7 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/marketing/next.config.js b/apps/marketing/next.config.js index 940536efa..0f7b7ad5c 100644 --- a/apps/marketing/next.config.js +++ b/apps/marketing/next.config.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); const { withContentlayer } = require('next-contentlayer'); +const { withAxiom } = require('next-axiom'); const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`]; @@ -95,4 +96,4 @@ const config = { }, }; -module.exports = withContentlayer(config); +module.exports = withAxiom(withContentlayer(config)); diff --git a/apps/marketing/package.json b/apps/marketing/package.json index f6af3a9ff..b9cee4f45 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -26,6 +26,7 @@ "micro": "^10.0.1", "next": "14.0.3", "next-auth": "4.24.5", + "next-axiom": "^1.1.1", "next-contentlayer": "^0.3.4", "next-plausible": "^3.10.1", "perfect-freehand": "^1.2.0", diff --git a/apps/marketing/src/app/layout.tsx b/apps/marketing/src/app/layout.tsx index 99a1a6483..2790adb35 100644 --- a/apps/marketing/src/app/layout.tsx +++ b/apps/marketing/src/app/layout.tsx @@ -2,6 +2,7 @@ import { Suspense } from 'react'; import { Caveat, Inter } from 'next/font/google'; +import { AxiomWebVitals } from 'next-axiom'; import { PublicEnvScript } from 'next-runtime-env'; import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag'; @@ -67,6 +68,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo + + diff --git a/apps/web/next.config.js b/apps/web/next.config.js index d670dab47..af82847c0 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); const { version } = require('./package.json'); +const { withAxiom } = require('next-axiom'); const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`]; @@ -91,4 +92,4 @@ const config = { }, }; -module.exports = config; +module.exports = withAxiom(config); diff --git a/apps/web/package.json b/apps/web/package.json index 4f6617d1e..484659740 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "micro": "^10.0.1", "next": "14.0.3", "next-auth": "4.24.5", + "next-axiom": "^1.1.1", "next-plausible": "^3.10.1", "next-themes": "^0.2.1", "perfect-freehand": "^1.2.0", diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 7753e1e53..0f3d1607f 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import { Suspense } from 'react'; import { Caveat, Inter } from 'next/font/google'; +import { AxiomWebVitals } from 'next-axiom'; import { PublicEnvScript } from 'next-runtime-env'; import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag'; @@ -71,6 +72,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo + + diff --git a/package-lock.json b/package-lock.json index 3dc4e9776..5b1f8f0be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "micro": "^10.0.1", "next": "14.0.3", "next-auth": "4.24.5", + "next-axiom": "^1.1.1", "next-contentlayer": "^0.3.4", "next-plausible": "^3.10.1", "perfect-freehand": "^1.2.0", @@ -111,6 +112,7 @@ "micro": "^10.0.1", "next": "14.0.3", "next-auth": "4.24.5", + "next-axiom": "^1.1.1", "next-plausible": "^3.10.1", "next-themes": "^0.2.1", "perfect-freehand": "^1.2.0", @@ -16668,6 +16670,22 @@ } } }, + "node_modules/next-axiom": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/next-axiom/-/next-axiom-1.1.1.tgz", + "integrity": "sha512-0r/TJ+/zetD+uDc7B+2E7WpC86hEtQ1U+DuWYrP/JNmUz+ZdPFbrZgzOSqaZ6TwYbXP56VVlPfYwq1YsKHTHYQ==", + "dependencies": { + "remeda": "^1.29.0", + "whatwg-fetch": "^3.6.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "next": ">=13.4", + "react": ">=18.0.0" + } + }, "node_modules/next-contentlayer": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/next-contentlayer/-/next-contentlayer-0.3.4.tgz", @@ -22936,6 +22954,11 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", From 56c550c9d21de9f1c2b3e5693d53cb2fad2f70af Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 3 Apr 2024 17:13:35 +0800 Subject: [PATCH 209/299] fix: refactor tests (#1066) ## Changes Made - Refactor/optimise tests - Reduce flakiness - Add parallel tests (if there's enough CPU capacity) - Removed explicit worker count when running parallel tests. Defaults to 50% of CPU capacity. Might want to consider sharding the test across runners in the future as our tests grows. --- .../e2e/command-menu/document-search.spec.ts | 54 +++++ .../e2e/document-auth/access-auth.spec.ts | 1 - .../e2e/document-auth/action-auth.spec.ts | 2 +- .../stepper-component.spec.ts} | 111 ++++----- .../e2e/documents/delete-documents.spec.ts | 172 ++++++++++++++ .../app-tests/e2e/fixtures/authentication.ts | 33 +-- .../e2e/pr-711-deletion-of-documents.spec.ts | 159 ------------- ...dd-document-search-to-command-menu.spec.ts | 54 ----- .../app-tests/e2e/teams/manage-team.spec.ts | 8 +- .../e2e/templates/manage-templates.spec.ts | 2 +- .../auth-flow.spec.ts} | 7 +- .../delete-account.spec.ts} | 11 +- .../update-name.spec.ts} | 10 +- packages/app-tests/playwright.config.ts | 5 +- packages/prisma/seed/documents.ts | 9 +- .../seed/pr-711-deletion-of-documents.ts | 223 ------------------ ...713-add-document-search-to-command-menu.ts | 168 ------------- packages/prisma/seed/teams.ts | 5 +- packages/prisma/seed/users.ts | 16 +- 19 files changed, 318 insertions(+), 732 deletions(-) create mode 100644 packages/app-tests/e2e/command-menu/document-search.spec.ts rename packages/app-tests/e2e/{pr-718-add-stepper-component.spec.ts => document-flow/stepper-component.spec.ts} (81%) create mode 100644 packages/app-tests/e2e/documents/delete-documents.spec.ts delete mode 100644 packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts delete mode 100644 packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts rename packages/app-tests/e2e/{test-auth-flow.spec.ts => user/auth-flow.spec.ts} (88%) rename packages/app-tests/e2e/{test-delete-user.spec.ts => user/delete-account.spec.ts} (80%) rename packages/app-tests/e2e/{test-update-user-name.spec.ts => user/update-name.spec.ts} (81%) delete mode 100644 packages/prisma/seed/pr-711-deletion-of-documents.ts delete mode 100644 packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts diff --git a/packages/app-tests/e2e/command-menu/document-search.spec.ts b/packages/app-tests/e2e/command-menu/document-search.spec.ts new file mode 100644 index 000000000..bc1a934d0 --- /dev/null +++ b/packages/app-tests/e2e/command-menu/document-search.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test'; + +import { seedPendingDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test('[COMMAND_MENU]: should see sent documents', async ({ page }) => { + const user = await seedUser(); + const recipient = await seedUser(); + const document = await seedPendingDocument(user, [recipient]); + + await apiSignin({ + page, + email: user.email, + }); + + await page.keyboard.press('Meta+K'); + + await page.getByPlaceholder('Type a command or search...').first().fill(document.title); + await expect(page.getByRole('option', { name: document.title })).toBeVisible(); +}); + +test('[COMMAND_MENU]: should see received documents', async ({ page }) => { + const user = await seedUser(); + const recipient = await seedUser(); + const document = await seedPendingDocument(user, [recipient]); + + await apiSignin({ + page, + email: recipient.email, + }); + + await page.keyboard.press('Meta+K'); + + await page.getByPlaceholder('Type a command or search...').first().fill(document.title); + await expect(page.getByRole('option', { name: document.title })).toBeVisible(); +}); + +test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) => { + const user = await seedUser(); + const recipient = await seedUser(); + const document = await seedPendingDocument(user, [recipient]); + + await apiSignin({ + page, + email: recipient.email, + }); + + await page.keyboard.press('Meta+K'); + + await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email); + await expect(page.getByRole('option', { name: document.title })).toBeVisible(); +}); diff --git a/packages/app-tests/e2e/document-auth/access-auth.spec.ts b/packages/app-tests/e2e/document-auth/access-auth.spec.ts index 0306689ce..b57969b50 100644 --- a/packages/app-tests/e2e/document-auth/access-auth.spec.ts +++ b/packages/app-tests/e2e/document-auth/access-auth.spec.ts @@ -71,7 +71,6 @@ test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page await apiSignin({ page, email: recipientWithAccount.email, - redirectPath: '/', }); // Check that the one logged in is granted access. diff --git a/packages/app-tests/e2e/document-auth/action-auth.spec.ts b/packages/app-tests/e2e/document-auth/action-auth.spec.ts index b263dbd04..ac69a6c22 100644 --- a/packages/app-tests/e2e/document-auth/action-auth.spec.ts +++ b/packages/app-tests/e2e/document-auth/action-auth.spec.ts @@ -14,7 +14,7 @@ import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/user import { apiSignin, apiSignout } from '../fixtures/authentication'; -test.describe.configure({ mode: 'parallel' }); +test.describe.configure({ mode: 'parallel', timeout: 60000 }); test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }) => { const user = await seedUser(); diff --git a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts similarity index 81% rename from packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts rename to packages/app-tests/e2e/document-flow/stepper-component.spec.ts index f24d74076..ee6b160cc 100644 --- a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -4,10 +4,13 @@ import path from 'node:path'; import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email'; import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@documenso/prisma/client'; -import { seedUser } from '@documenso/prisma/seed/users'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; -import { apiSignin } from './fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; +// Can't use the function in server-only/document due to it indirectly using +// require imports. const getDocumentByToken = async (token: string) => { return await prisma.document.findFirstOrThrow({ where: { @@ -20,11 +23,7 @@ const getDocumentByToken = async (token: string) => { }); }; -test(`[PR-718]: should be able to create a document`, async ({ page }) => { - await page.goto('/signin'); - - const documentTitle = `example-${Date.now()}.pdf`; - +test('[DOCUMENT_FLOW]: should be able to upload a PDF document', async ({ page }) => { const user = await seedUser(); await apiSignin({ @@ -32,7 +31,7 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => { email: user.email, }); - // Upload document + // Upload document. const [fileChooser] = await Promise.all([ page.waitForEvent('filechooser'), page.locator('input[type=file]').evaluate((e) => { @@ -42,10 +41,23 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => { }), ]); - await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf')); + await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf')); - // Wait to be redirected to the edit page + // Wait to be redirected to the edit page. await page.waitForURL(/\/documents\/\d+/); +}); + +test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) => { + const user = await seedUser(); + const document = await seedBlankDocument(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + const documentTitle = `example-${Date.now()}.pdf`; // Set general settings await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); @@ -91,34 +103,23 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => { // Assert document was created await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); + + await unseedUser(user.id); }); -test('should be able to create a document with multiple recipients', async ({ page }) => { - await page.goto('/signin'); - - const documentTitle = `example-${Date.now()}.pdf`; - +test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients', async ({ + page, +}) => { const user = await seedUser(); + const document = await seedBlankDocument(user); await apiSignin({ page, email: user.email, + redirectPath: `/documents/${document.id}/edit`, }); - // 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+/); + const documentTitle = `example-${Date.now()}.pdf`; // Set title await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); @@ -187,34 +188,21 @@ 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(); + + await unseedUser(user.id); }); -test('should be able to create, send and sign a document', async ({ page }) => { - await page.goto('/signin'); - - const documentTitle = `example-${Date.now()}.pdf`; - +test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', async ({ page }) => { const user = await seedUser(); + const document = await seedBlankDocument(user); await apiSignin({ page, email: user.email, + redirectPath: `/documents/${document.id}/edit`, }); - // 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+/); + const documentTitle = `example-${Date.now()}.pdf`; // Set title await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); @@ -271,36 +259,23 @@ test('should be able to create, send and sign a document', async ({ page }) => { // Check if document has been signed const { status: completedStatus } = await getDocumentByToken(token); expect(completedStatus).toBe(DocumentStatus.COMPLETED); + + await unseedUser(user.id); }); -test('should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({ +test('[DOCUMENT_FLOW]: 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`; - const user = await seedUser(); + const document = await seedBlankDocument(user); await apiSignin({ page, email: user.email, + redirectPath: `/documents/${document.id}/edit`, }); - // 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+/); + const documentTitle = `example-${Date.now()}.pdf`; // Set title & advanced redirect await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); @@ -355,4 +330,6 @@ test('should be able to create, send with redirect url, sign a document and redi // Check if document has been signed const { status: completedStatus } = await getDocumentByToken(token); expect(completedStatus).toBe(DocumentStatus.COMPLETED); + + await unseedUser(user.id); }); diff --git a/packages/app-tests/e2e/documents/delete-documents.spec.ts b/packages/app-tests/e2e/documents/delete-documents.spec.ts new file mode 100644 index 000000000..3658f1bc9 --- /dev/null +++ b/packages/app-tests/e2e/documents/delete-documents.spec.ts @@ -0,0 +1,172 @@ +import { expect, test } from '@playwright/test'; + +import { + seedCompletedDocument, + seedDraftDocument, + seedPendingDocument, +} from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin, apiSignout } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'serial' }); + +const seedDeleteDocumentsTestRequirements = async () => { + const [sender, recipientA, recipientB] = await Promise.all([seedUser(), seedUser(), seedUser()]); + + const [draftDocument, pendingDocument, completedDocument] = await Promise.all([ + seedDraftDocument(sender, [recipientA, recipientB], { + createDocumentOptions: { title: 'Document 1 - Draft' }, + }), + seedPendingDocument(sender, [recipientA, recipientB], { + createDocumentOptions: { title: 'Document 1 - Pending' }, + }), + seedCompletedDocument(sender, [recipientA, recipientB], { + createDocumentOptions: { title: 'Document 1 - Completed' }, + }), + ]); + + return { + sender, + recipients: [recipientA, recipientB], + draftDocument, + pendingDocument, + completedDocument, + }; +}; + +test('[DOCUMENTS]: seeded documents should be visible', async ({ page }) => { + const { sender, recipients } = await seedDeleteDocumentsTestRequirements(); + + await apiSignin({ + page, + email: sender.email, + }); + + await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible(); + + await apiSignout({ page }); + + for (const recipient of recipients) { + await apiSignin({ + page, + email: recipient.email, + }); + + await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible(); + + await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible(); + + await apiSignout({ page }); + } +}); + +test('[DOCUMENTS]: deleting a completed document should not remove it from recipients', async ({ + page, +}) => { + const { sender, recipients } = await seedDeleteDocumentsTestRequirements(); + + await apiSignin({ + page, + email: sender.email, + }); + + // open actions menu + await page + .locator('tr', { hasText: 'Document 1 - Completed' }) + .getByRole('cell', { name: 'Download' }) + .getByRole('button') + .nth(1) + .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 - Completed/ })).not.toBeVisible(); + + await apiSignout({ page }); + + for (const recipient of recipients) { + await apiSignin({ + page, + email: recipient.email, + }); + + await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible(); + await page.getByRole('link', { name: 'Document 1 - Completed' }).click(); + await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible(); + + await apiSignout({ page }); + } +}); + +test('[DOCUMENTS]: deleting a pending document should remove it from recipients', async ({ + page, +}) => { + const { sender, pendingDocument } = await seedDeleteDocumentsTestRequirements(); + + await apiSignin({ + page, + email: sender.email, + }); + + // open actions menu + await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).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(); + + // signout + await apiSignout({ page }); + + for (const recipient of pendingDocument.Recipient) { + await apiSignin({ + page, + email: recipient.email, + }); + + 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, +}) => { + const { sender } = await seedDeleteDocumentsTestRequirements(); + + await apiSignin({ + page, + email: sender.email, + }); + + // open actions menu + await page + .locator('tr', { hasText: 'Document 1 - Draft' }) + .getByRole('cell', { name: 'Edit' }) + .getByRole('button') + .click(); + + // delete document + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible(); + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible(); +}); diff --git a/packages/app-tests/e2e/fixtures/authentication.ts b/packages/app-tests/e2e/fixtures/authentication.ts index 9f3a50756..fe52b65d8 100644 --- a/packages/app-tests/e2e/fixtures/authentication.ts +++ b/packages/app-tests/e2e/fixtures/authentication.ts @@ -13,38 +13,11 @@ type LoginOptions = { redirectPath?: string; }; -export const manualLogin = async ({ - page, - email = 'example@documenso.com', - password = 'password', - redirectPath, -}: LoginOptions) => { - await page.goto(`${WEBAPP_BASE_URL}/signin`); - - await page.getByLabel('Email').click(); - await page.getByLabel('Email').fill(email); - - await page.getByLabel('Password', { exact: true }).fill(password); - await page.getByLabel('Password', { exact: true }).press('Enter'); - - if (redirectPath) { - await page.waitForURL(`${WEBAPP_BASE_URL}/documents`); - await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`); - } -}; - -export const manualSignout = async ({ page }: LoginOptions) => { - await page.waitForTimeout(1000); - await page.getByTestId('menu-switcher').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); - await page.waitForURL(`${WEBAPP_BASE_URL}/signin`); -}; - export const apiSignin = async ({ page, email = 'example@documenso.com', password = 'password', - redirectPath = '/', + redirectPath = '/documents', }: LoginOptions) => { const { request } = page.context(); @@ -59,9 +32,7 @@ export const apiSignin = async ({ }, }); - if (redirectPath) { - await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`); - } + await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`); }; export const apiSignout = async ({ page }: { page: Page }) => { diff --git a/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts b/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts deleted file mode 100644 index da95c66f0..000000000 --- a/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { expect, test } from '@playwright/test'; - -import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents'; - -import { manualLogin, manualSignout } from './fixtures/authentication'; - -test.describe.configure({ mode: 'serial' }); - -test('[PR-711]: seeded documents should be visible', async ({ page }) => { - const [sender, ...recipients] = TEST_USERS; - - await page.goto('/signin'); - - await page.getByLabel('Email').fill(sender.email); - await page.getByLabel('Password', { exact: true }).fill(sender.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - - await page.waitForURL('/documents'); - - await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible(); - await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible(); - await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible(); - - await manualSignout({ page }); - - for (const recipient of recipients) { - await page.waitForURL('/signin'); - await manualLogin({ page, email: recipient.email, password: recipient.password }); - - await page.waitForURL('/documents'); - - await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible(); - await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible(); - - await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible(); - - await manualSignout({ page }); - } -}); - -test('[PR-711]: deleting a completed document should not remove it from recipients', async ({ - page, -}) => { - const [sender, ...recipients] = TEST_USERS; - - await page.goto('/signin'); - - // sign in - await page.getByLabel('Email').fill(sender.email); - await page.getByLabel('Password', { exact: true }).fill(sender.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - - await page.waitForURL('/documents'); - - // open actions menu - await page - .locator('tr', { hasText: 'Document 1 - Completed' }) - .getByRole('cell', { name: 'Download' }) - .getByRole('button') - .nth(1) - .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 - Completed/ })).not.toBeVisible(); - - await manualSignout({ page }); - - for (const recipient of recipients) { - await page.waitForURL('/signin'); - await page.goto('/signin'); - - // sign in - await page.getByLabel('Email').fill(recipient.email); - await page.getByLabel('Password', { exact: true }).fill(recipient.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - - await page.waitForURL('/documents'); - - await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible(); - - await page.goto(`/sign/completed-token-${recipients.indexOf(recipient)}`); - await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible(); - - await page.goto('/documents'); - await manualSignout({ page }); - } -}); - -test('[PR-711]: deleting a pending document should remove it from recipients', async ({ page }) => { - const [sender, ...recipients] = TEST_USERS; - - for (const recipient of recipients) { - await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`); - - await expect(page.getByText('Waiting for others to sign').nth(0)).toBeVisible(); - } - - await page.goto('/signin'); - - await manualLogin({ page, email: sender.email, password: sender.password }); - await page.waitForURL('/documents'); - - // open actions menu - await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).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(); - - // signout - await manualSignout({ page }); - - for (const recipient of recipients) { - await page.waitForURL('/signin'); - - await manualLogin({ page, email: recipient.email, password: recipient.password }); - await page.waitForURL('/documents'); - - await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible(); - - await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`); - await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible(); - - await page.goto('/documents'); - await page.waitForURL('/documents'); - - await manualSignout({ page }); - } -}); - -test('[PR-711]: deleting a draft document should remove it without additional prompting', async ({ - page, -}) => { - const [sender] = TEST_USERS; - - await manualLogin({ page, email: sender.email, password: sender.password }); - await page.waitForURL('/documents'); - - // open actions menu - await page - .locator('tr', { hasText: 'Document 1 - Draft' }) - .getByRole('cell', { name: 'Edit' }) - .getByRole('button') - .click(); - - // delete document - await page.getByRole('menuitem', { name: 'Delete' }).click(); - await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible(); - await page.getByRole('button', { name: 'Delete' }).click(); - - await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible(); -}); diff --git a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts deleted file mode 100644 index 44cfe1e37..000000000 --- a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { expect, test } from '@playwright/test'; - -import { TEST_USERS } from '@documenso/prisma/seed/pr-713-add-document-search-to-command-menu'; - -test('[PR-713]: should see sent documents', async ({ page }) => { - const [user] = TEST_USERS; - - await page.goto('/signin'); - - await page.getByLabel('Email').fill(user.email); - await page.getByLabel('Password', { exact: true }).fill(user.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - - await page.waitForURL('/documents'); - - await page.keyboard.press('Meta+K'); - - await page.getByPlaceholder('Type a command or search...').first().fill('sent'); - await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible(); -}); - -test('[PR-713]: should see received documents', async ({ page }) => { - const [user] = TEST_USERS; - - await page.goto('/signin'); - - await page.getByLabel('Email').fill(user.email); - await page.getByLabel('Password', { exact: true }).fill(user.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - - await page.waitForURL('/documents'); - - await page.keyboard.press('Meta+K'); - - await page.getByPlaceholder('Type a command or search...').first().fill('received'); - await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible(); -}); - -test('[PR-713]: should be able to search by recipient', async ({ page }) => { - const [user, recipient] = TEST_USERS; - - await page.goto('/signin'); - - await page.getByLabel('Email').fill(user.email); - await page.getByLabel('Password', { exact: true }).fill(user.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - - await page.waitForURL('/documents'); - - await page.keyboard.press('Meta+K'); - - await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email); - await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible(); -}); diff --git a/packages/app-tests/e2e/teams/manage-team.spec.ts b/packages/app-tests/e2e/teams/manage-team.spec.ts index a1deb1995..7403ab9c9 100644 --- a/packages/app-tests/e2e/teams/manage-team.spec.ts +++ b/packages/app-tests/e2e/teams/manage-team.spec.ts @@ -11,6 +11,11 @@ test.describe.configure({ mode: 'parallel' }); test('[TEAMS]: create team', async ({ page }) => { const user = await seedUser(); + test.skip( + process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true', + 'Test skipped because billing is enabled.', + ); + await apiSignin({ page, email: user.email, @@ -26,9 +31,6 @@ test('[TEAMS]: create team', async ({ page }) => { await page.getByTestId('dialog-create-team-button').waitFor({ state: 'hidden' }); - const isCheckoutRequired = page.url().includes('pending'); - test.skip(isCheckoutRequired, 'Test skipped because billing is enabled.'); - // Goto new team settings page. await page.getByRole('row').filter({ hasText: teamId }).getByRole('link').nth(1).click(); diff --git a/packages/app-tests/e2e/templates/manage-templates.spec.ts b/packages/app-tests/e2e/templates/manage-templates.spec.ts index a89b308eb..a298d1e38 100644 --- a/packages/app-tests/e2e/templates/manage-templates.spec.ts +++ b/packages/app-tests/e2e/templates/manage-templates.spec.ts @@ -108,7 +108,7 @@ test('[TEMPLATES]: delete template', async ({ page }) => { await page.getByRole('button', { name: 'Delete' }).click(); await expect(page.getByText('Template deleted').first()).toBeVisible(); - await page.waitForTimeout(1000); + await page.reload(); } await unseedTeam(team.url); diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/user/auth-flow.spec.ts similarity index 88% rename from packages/app-tests/e2e/test-auth-flow.spec.ts rename to packages/app-tests/e2e/user/auth-flow.spec.ts index 3b07371f1..94338ec21 100644 --- a/packages/app-tests/e2e/test-auth-flow.spec.ts +++ b/packages/app-tests/e2e/user/auth-flow.spec.ts @@ -2,6 +2,7 @@ import { type Page, expect, test } from '@playwright/test'; import { extractUserVerificationToken, + seedTestEmail, seedUser, unseedUser, unseedUserByEmail, @@ -9,9 +10,9 @@ import { test.use({ storageState: { cookies: [], origins: [] } }); -test('user can sign up with email and password', async ({ page }: { page: Page }) => { +test('[USER] can sign up with email and password', async ({ page }: { page: Page }) => { const username = 'Test User'; - const email = `test-user-${Date.now()}@auth-flow.documenso.com`; + const email = seedTestEmail(); const password = 'Password123#'; await page.goto('/signup'); @@ -50,7 +51,7 @@ test('user can sign up with email and password', async ({ page }: { page: Page } await unseedUserByEmail(email); }); -test('user can login with user and password', async ({ page }: { page: Page }) => { +test('[USER] can sign in using email and password', async ({ page }: { page: Page }) => { const user = await seedUser(); await page.goto('/signin'); diff --git a/packages/app-tests/e2e/test-delete-user.spec.ts b/packages/app-tests/e2e/user/delete-account.spec.ts similarity index 80% rename from packages/app-tests/e2e/test-delete-user.spec.ts rename to packages/app-tests/e2e/user/delete-account.spec.ts index 6eb72bad9..e04283240 100644 --- a/packages/app-tests/e2e/test-delete-user.spec.ts +++ b/packages/app-tests/e2e/user/delete-account.spec.ts @@ -4,19 +4,16 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { seedUser } from '@documenso/prisma/seed/users'; -import { manualLogin } from './fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; -test('delete user', async ({ page }) => { +test('[USER] delete account', async ({ page }) => { const user = await seedUser(); - await manualLogin({ - page, - email: user.email, - redirectPath: '/settings', - }); + await apiSignin({ page, email: user.email, redirectPath: '/settings' }); await page.getByRole('button', { name: 'Delete Account' }).click(); await page.getByLabel('Confirm Email').fill(user.email); + await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled(); await page.getByRole('button', { name: 'Confirm Deletion' }).click(); diff --git a/packages/app-tests/e2e/test-update-user-name.spec.ts b/packages/app-tests/e2e/user/update-name.spec.ts similarity index 81% rename from packages/app-tests/e2e/test-update-user-name.spec.ts rename to packages/app-tests/e2e/user/update-name.spec.ts index 509db651b..ca26fbf3d 100644 --- a/packages/app-tests/e2e/test-update-user-name.spec.ts +++ b/packages/app-tests/e2e/user/update-name.spec.ts @@ -3,16 +3,12 @@ import { expect, test } from '@playwright/test'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { seedUser } from '@documenso/prisma/seed/users'; -import { manualLogin } from './fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; -test('update user name', async ({ page }) => { +test('[USER] update full name', async ({ page }) => { const user = await seedUser(); - await manualLogin({ - page, - email: user.email, - redirectPath: '/settings/profile', - }); + await apiSignin({ page, email: user.email, redirectPath: '/settings/profile' }); await page.getByLabel('Full Name').fill('John Doe'); diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts index 0796bb1e1..725f4bb04 100644 --- a/packages/app-tests/playwright.config.ts +++ b/packages/app-tests/playwright.config.ts @@ -17,12 +17,11 @@ export default defineConfig({ testDir: './e2e', /* Run tests in files in parallel */ fullyParallel: true, + workers: '50%', /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + retries: process.env.CI ? 2 : 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts index 1fceca900..6c1e698c5 100644 --- a/packages/prisma/seed/documents.ts +++ b/packages/prisma/seed/documents.ts @@ -213,7 +213,14 @@ export const seedPendingDocument = async ( }); } - return document; + return prisma.document.findFirstOrThrow({ + where: { + id: document.id, + }, + include: { + Recipient: true, + }, + }); }; export const seedPendingDocumentNoFields = async ({ diff --git a/packages/prisma/seed/pr-711-deletion-of-documents.ts b/packages/prisma/seed/pr-711-deletion-of-documents.ts deleted file mode 100644 index d2706b734..000000000 --- a/packages/prisma/seed/pr-711-deletion-of-documents.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { User } from '@prisma/client'; -import fs from 'node:fs'; -import path from 'node:path'; - -import { hashSync } from '@documenso/lib/server-only/auth/hash'; - -import { prisma } from '..'; -import { - DocumentDataType, - DocumentStatus, - FieldType, - Prisma, - ReadStatus, - SendStatus, - SigningStatus, -} from '../client'; - -const PULL_REQUEST_NUMBER = 711; -const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`; - -export const TEST_USERS = [ - { - name: 'Sender 1', - email: `sender1@${EMAIL_DOMAIN}`, - password: 'Password123', - }, - { - name: 'Sender 2', - email: `sender2@${EMAIL_DOMAIN}`, - password: 'Password123', - }, - { - name: 'Sender 3', - email: `sender3@${EMAIL_DOMAIN}`, - password: 'Password123', - }, -] as const; - -const examplePdf = fs - .readFileSync(path.join(__dirname, '../../../assets/example.pdf')) - .toString('base64'); - -export const seedDatabase = async () => { - const users = await Promise.all( - TEST_USERS.map(async (u) => - prisma.user.create({ - data: { - name: u.name, - email: u.email, - password: hashSync(u.password), - emailVerified: new Date(), - url: u.email, - }, - }), - ), - ); - - const [user1, user2, user3] = users; - - await createDraftDocument(user1, [user2, user3]); - await createPendingDocument(user1, [user2, user3]); - await createCompletedDocument(user1, [user2, user3]); -}; - -const createDraftDocument = async (sender: User, recipients: User[]) => { - const documentData = await prisma.documentData.create({ - data: { - type: DocumentDataType.BYTES_64, - data: examplePdf, - initialData: examplePdf, - }, - }); - - const document = await prisma.document.create({ - data: { - title: `[${PULL_REQUEST_NUMBER}] Document 1 - Draft`, - status: DocumentStatus.DRAFT, - documentDataId: documentData.id, - userId: sender.id, - }, - }); - - for (const recipient of recipients) { - const index = recipients.indexOf(recipient); - - await prisma.recipient.create({ - data: { - email: String(recipient.email), - name: String(recipient.name), - token: `draft-token-${index}`, - readStatus: ReadStatus.NOT_OPENED, - sendStatus: SendStatus.NOT_SENT, - signingStatus: SigningStatus.NOT_SIGNED, - signedAt: new Date(), - Document: { - connect: { - id: document.id, - }, - }, - Field: { - create: { - page: 1, - type: FieldType.NAME, - inserted: true, - customText: String(recipient.name), - positionX: new Prisma.Decimal(1), - positionY: new Prisma.Decimal(1), - width: new Prisma.Decimal(1), - height: new Prisma.Decimal(1), - documentId: document.id, - }, - }, - }, - }); - } -}; - -const createPendingDocument = async (sender: User, recipients: User[]) => { - const documentData = await prisma.documentData.create({ - data: { - type: DocumentDataType.BYTES_64, - data: examplePdf, - initialData: examplePdf, - }, - }); - - const document = await prisma.document.create({ - data: { - title: `[${PULL_REQUEST_NUMBER}] Document 1 - Pending`, - status: DocumentStatus.PENDING, - documentDataId: documentData.id, - userId: sender.id, - }, - }); - - for (const recipient of recipients) { - const index = recipients.indexOf(recipient); - - await prisma.recipient.create({ - data: { - email: String(recipient.email), - name: String(recipient.name), - token: `pending-token-${index}`, - readStatus: ReadStatus.OPENED, - sendStatus: SendStatus.SENT, - signingStatus: SigningStatus.SIGNED, - signedAt: new Date(), - Document: { - connect: { - id: document.id, - }, - }, - Field: { - create: { - page: 1, - type: FieldType.NAME, - inserted: true, - customText: String(recipient.name), - positionX: new Prisma.Decimal(1), - positionY: new Prisma.Decimal(1), - width: new Prisma.Decimal(1), - height: new Prisma.Decimal(1), - documentId: document.id, - }, - }, - }, - }); - } -}; - -const createCompletedDocument = async (sender: User, recipients: User[]) => { - const documentData = await prisma.documentData.create({ - data: { - type: DocumentDataType.BYTES_64, - data: examplePdf, - initialData: examplePdf, - }, - }); - - const document = await prisma.document.create({ - data: { - title: `[${PULL_REQUEST_NUMBER}] Document 1 - Completed`, - status: DocumentStatus.COMPLETED, - documentDataId: documentData.id, - completedAt: new Date(), - userId: sender.id, - }, - }); - - for (const recipient of recipients) { - const index = recipients.indexOf(recipient); - - await prisma.recipient.create({ - data: { - email: String(recipient.email), - name: String(recipient.name), - token: `completed-token-${index}`, - readStatus: ReadStatus.OPENED, - sendStatus: SendStatus.SENT, - signingStatus: SigningStatus.SIGNED, - signedAt: new Date(), - Document: { - connect: { - id: document.id, - }, - }, - Field: { - create: { - page: 1, - type: FieldType.NAME, - inserted: true, - customText: String(recipient.name), - positionX: new Prisma.Decimal(1), - positionY: new Prisma.Decimal(1), - width: new Prisma.Decimal(1), - height: new Prisma.Decimal(1), - documentId: document.id, - }, - }, - }, - }); - } -}; diff --git a/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts b/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts deleted file mode 100644 index 0fe27b703..000000000 --- a/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { User } from '@prisma/client'; -import fs from 'node:fs'; -import path from 'node:path'; - -import { hashSync } from '@documenso/lib/server-only/auth/hash'; - -import { prisma } from '..'; -import { - DocumentDataType, - DocumentStatus, - FieldType, - Prisma, - ReadStatus, - SendStatus, - SigningStatus, -} from '../client'; - -// -// https://github.com/documenso/documenso/pull/713 -// - -const PULL_REQUEST_NUMBER = 713; - -const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`; - -export const TEST_USERS = [ - { - name: 'User 1', - email: `user1@${EMAIL_DOMAIN}`, - password: 'Password123', - }, - { - name: 'User 2', - email: `user2@${EMAIL_DOMAIN}`, - password: 'Password123', - }, -] as const; - -const examplePdf = fs - .readFileSync(path.join(__dirname, '../../../assets/example.pdf')) - .toString('base64'); - -export const seedDatabase = async () => { - const users = await Promise.all( - TEST_USERS.map(async (u) => - prisma.user.create({ - data: { - name: u.name, - email: u.email, - password: hashSync(u.password), - emailVerified: new Date(), - url: u.email, - }, - }), - ), - ); - - const [user1, user2] = users; - - await createSentDocument(user1, [user2]); - await createReceivedDocument(user2, [user1]); -}; - -const createSentDocument = async (sender: User, recipients: User[]) => { - const documentData = await prisma.documentData.create({ - data: { - type: DocumentDataType.BYTES_64, - data: examplePdf, - initialData: examplePdf, - }, - }); - - const document = await prisma.document.create({ - data: { - title: `[${PULL_REQUEST_NUMBER}] Document - Sent`, - status: DocumentStatus.PENDING, - documentDataId: documentData.id, - userId: sender.id, - }, - }); - - for (const recipient of recipients) { - const index = recipients.indexOf(recipient); - - await prisma.recipient.create({ - data: { - email: String(recipient.email), - name: String(recipient.name), - token: `sent-token-${index}`, - readStatus: ReadStatus.NOT_OPENED, - sendStatus: SendStatus.SENT, - signingStatus: SigningStatus.NOT_SIGNED, - signedAt: new Date(), - Document: { - connect: { - id: document.id, - }, - }, - Field: { - create: { - page: 1, - type: FieldType.NAME, - inserted: true, - customText: String(recipient.name), - positionX: new Prisma.Decimal(1), - positionY: new Prisma.Decimal(1), - width: new Prisma.Decimal(1), - height: new Prisma.Decimal(1), - documentId: document.id, - }, - }, - }, - }); - } -}; - -const createReceivedDocument = async (sender: User, recipients: User[]) => { - const documentData = await prisma.documentData.create({ - data: { - type: DocumentDataType.BYTES_64, - data: examplePdf, - initialData: examplePdf, - }, - }); - - const document = await prisma.document.create({ - data: { - title: `[${PULL_REQUEST_NUMBER}] Document - Received`, - status: DocumentStatus.PENDING, - documentDataId: documentData.id, - userId: sender.id, - }, - }); - - for (const recipient of recipients) { - const index = recipients.indexOf(recipient); - - await prisma.recipient.create({ - data: { - email: String(recipient.email), - name: String(recipient.name), - token: `received-token-${index}`, - readStatus: ReadStatus.NOT_OPENED, - sendStatus: SendStatus.SENT, - signingStatus: SigningStatus.NOT_SIGNED, - signedAt: new Date(), - Document: { - connect: { - id: document.id, - }, - }, - Field: { - create: { - page: 1, - type: FieldType.NAME, - inserted: true, - customText: String(recipient.name), - positionX: new Prisma.Decimal(1), - positionY: new Prisma.Decimal(1), - width: new Prisma.Decimal(1), - height: new Prisma.Decimal(1), - documentId: document.id, - }, - }, - }, - }); - } -}; diff --git a/packages/prisma/seed/teams.ts b/packages/prisma/seed/teams.ts index 99b0df8d5..aaae866d0 100644 --- a/packages/prisma/seed/teams.ts +++ b/packages/prisma/seed/teams.ts @@ -1,8 +1,11 @@ +import { customAlphabet } from 'nanoid'; + import { prisma } from '..'; import { TeamMemberInviteStatus, TeamMemberRole } from '../client'; import { seedUser } from './users'; const EMAIL_DOMAIN = `test.documenso.com`; +const nanoid = customAlphabet('1234567890abcdef', 10); type SeedTeamOptions = { createTeamMembers?: number; @@ -13,7 +16,7 @@ export const seedTeam = async ({ createTeamMembers = 0, createTeamEmail, }: SeedTeamOptions = {}) => { - const teamUrl = `team-${Date.now()}`; + const teamUrl = `team-${nanoid()}`; const teamEmail = createTeamEmail === true ? `${teamUrl}@${EMAIL_DOMAIN}` : createTeamEmail; const teamOwner = await seedUser({ diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts index fd8706fea..9f7f80a71 100644 --- a/packages/prisma/seed/users.ts +++ b/packages/prisma/seed/users.ts @@ -1,3 +1,5 @@ +import { customAlphabet } from 'nanoid'; + import { hashSync } from '@documenso/lib/server-only/auth/hash'; import { prisma } from '..'; @@ -11,12 +13,22 @@ type SeedUserOptions = { verified?: boolean; }; +const nanoid = customAlphabet('1234567890abcdef', 10); + export const seedUser = async ({ - name = `user-${Date.now()}`, - email = `user-${Date.now()}@test.documenso.com`, + name, + email, password = 'password', verified = true, }: SeedUserOptions = {}) => { + if (!name) { + name = nanoid(); + } + + if (!email) { + email = `${nanoid()}@test.documenso.com`; + } + return await prisma.user.create({ data: { name, From 2ef619226e3ef710889af9cde16f2d5c5b42b783 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Thu, 4 Apr 2024 17:35:24 +0530 Subject: [PATCH 210/299] chore: remove duplicate env vars (#1075) **Description:** The `.env.example` had duplicate keys so removed them in this PR Signed-off-by: Adithya Krishna --- .env.example | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.env.example b/.env.example index 2fb7c3845..bc052aead 100644 --- a/.env.example +++ b/.env.example @@ -40,16 +40,6 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS= # OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport. NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS= -# [[SIGNING]] -# OPTIONAL: Defines the signing transport to use. Available options: local (default) -NEXT_PRIVATE_SIGNING_TRANSPORT="local" -# OPTIONAL: Defines the passphrase for the signing certificate. -NEXT_PRIVATE_SIGNING_PASSPHRASE= -# OPTIONAL: Defines the file contents for the signing certificate as a base64 encoded string. -NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS= -# OPTIONAL: Defines the file path for the signing certificate. defaults to ./example/cert.p12 -NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH= - # [[STORAGE]] # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 NEXT_PUBLIC_UPLOAD_TRANSPORT="database" From d4a7eb299e38cd5be16b64b940587be4d566e80d Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 4 Apr 2024 21:18:55 +0800 Subject: [PATCH 211/299] chore: add 2FA reauth docs (#1076) ## Description Update the tooltips to show documentation for 2FA --- packages/ui/primitives/document-flow/add-settings.tsx | 4 ++++ packages/ui/primitives/document-flow/add-signers.tsx | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index a5c0c5d9e..ea962dee5 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -223,6 +223,10 @@ export const AddSettingsFormPartial = ({ 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-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index f815ca4cd..7af4a06bc 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -291,6 +291,10 @@ export const AddSignersFormPartial = ({ 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
  • From b87154001a8143ad194a2a26ed1078fa57e40473 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Thu, 4 Apr 2024 22:00:39 +0530 Subject: [PATCH 212/299] feat: Ability to send team invitation in bulk (#930) fixes #923 https://github.com/documenso/documenso/assets/81948346/9f7cf419-91ec-4f43-b2c7-6fd3d0c13bfe --------- Co-authored-by: David Nguyen --- apps/web/package.json | 2 + .../dialogs/invite-team-member-dialog.tsx | 323 +++++++++++++----- package-lock.json | 16 + 3 files changed, 255 insertions(+), 86 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 484659740..71b480000 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,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", @@ -59,6 +60,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", diff --git a/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx index 482142c99..4adceda3d 100644 --- a/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx +++ b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx @@ -1,19 +1,22 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; -import { Mail, PlusCircle, Trash } from 'lucide-react'; +import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react'; +import Papa, { type ParseResult } from 'papaparse'; import { useFieldArray, useForm } from 'react-hook-form'; import { z } from 'zod'; +import { downloadFile } from '@documenso/lib/client-only/download-file'; import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; import { TeamMemberRole } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Dialog, DialogContent, @@ -39,6 +42,7 @@ import { SelectTrigger, SelectValue, } from '@documenso/ui/primitives/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type InviteTeamMembersDialogProps = { @@ -51,18 +55,45 @@ const ZInviteTeamMembersFormSchema = z .object({ invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations, }) - .refine( - (schema) => { - const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase()); + // Display exactly which rows are duplicates. + .superRefine((items, ctx) => { + const uniqueEmails = new Map(); - return new Set(emails).size === emails.length; - }, - // Dirty hack to handle errors when .root is populated for an array type - { message: 'Members must have unique emails', path: ['members__root'] }, - ); + for (const [index, invitation] of items.invitations.entries()) { + const email = invitation.email.toLowerCase(); + + const firstFoundIndex = uniqueEmails.get(email); + + if (firstFoundIndex === undefined) { + uniqueEmails.set(email, index); + continue; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Emails must be unique', + path: ['invitations', index, 'email'], + }); + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Emails must be unique', + path: ['invitations', firstFoundIndex, 'email'], + }); + } + }); type TInviteTeamMembersFormSchema = z.infer; +type TabTypes = 'INDIVIDUAL' | 'BULK'; + +const ZImportTeamMemberSchema = z.array( + z.object({ + email: z.string().email(), + role: z.nativeEnum(TeamMemberRole), + }), +); + export const InviteTeamMembersDialog = ({ currentUserTeamRole, teamId, @@ -70,6 +101,8 @@ export const InviteTeamMembersDialog = ({ ...props }: InviteTeamMembersDialogProps) => { const [open, setOpen] = useState(false); + const fileInputRef = useRef(null); + const [invitationType, setInvitationType] = useState('INDIVIDUAL'); const { toast } = useToast(); @@ -130,9 +163,75 @@ export const InviteTeamMembersDialog = ({ useEffect(() => { if (!open) { form.reset(); + setInvitationType('INDIVIDUAL'); } }, [open, form]); + const onFileInputChange = (e: React.ChangeEvent) => { + if (!e.target.files?.length) { + return; + } + + const csvFile = e.target.files[0]; + + Papa.parse(csvFile, { + skipEmptyLines: true, + comments: 'Work email,Job title', + complete: (results: ParseResult) => { + const members = results.data.map((row) => { + const [email, role] = row; + + return { + email: email.trim(), + role: role.trim().toUpperCase(), + }; + }); + + // Remove the first row if it contains the headers. + if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') { + members.shift(); + } + + try { + const importedInvitations = ZImportTeamMemberSchema.parse(members); + + form.setValue('invitations', importedInvitations); + form.clearErrors('invitations'); + + setInvitationType('INDIVIDUAL'); + } catch (err) { + console.error(err.message); + + toast({ + variant: 'destructive', + title: 'Something went wrong', + description: 'Please check the CSV file and make sure it is according to our format', + }); + } + }, + }); + }; + + const downloadTemplate = () => { + const data = [ + { email: 'admin@documenso.com', role: 'Admin' }, + { email: 'manager@documenso.com', role: 'Manager' }, + { email: 'member@documenso.com', role: 'Member' }, + ]; + + const csvContent = + 'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n'); + + const blob = new Blob([csvContent], { + type: 'text/csv', + }); + + downloadFile({ + filename: 'documenso-team-member-invites-template.csv', + data: blob, + }); + }; + return ( -
    - -
    - {teamMemberInvites.map((teamMemberInvite, index) => ( -
    - ( - - {index === 0 && Email address} - - - - - - )} - /> + setInvitationType(value as TabTypes)} + > + + + + Invite Members + - ( - - {index === 0 && Role} - - - - - - )} - /> + + + +
    +
    + {teamMemberInvites.map((teamMemberInvite, index) => ( +
    + ( + + {index === 0 && Email address} + + + + + + )} + /> - +
    + ))} +
    + + -
    - ))} + + Add more + - + + + + + +
    +
    + + + + +
    + + fileInputRef.current?.click()} + > + + +

    Click here to upload

    + + +
    +
    - - - -
    - - +
    + +
    ); 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 213/299] 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 214/299] 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 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 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 zcmZ^~1yr0%vo?x^Kp;RMSkMHA0S0#o!QCO~1R3021`7n20Kwhe-QC?axVtlhUiSXZ z`OjJRzjtQM>YA>uuI}o7`+cfbb+Dqm1Uf1qDjXafx|F1-G8`QIBpe)qDazZ|8lKbT z=GO{EGZ7I*DG`y+iuN|fW|m+$ID%NG-z+lU^l<{nWU#shGBdL}g^N1LM>^HevrIoQ z4%8jZvQHfCxKk{l6aLEkOnZ#Uy+}Aiz>b5CHF^>yLRIfjs~kGkzv0Q$`zu<0I~4qII~-D=d&V6Xl;%>7z=G^0jSUNOy-_)5fVts7GzAbX2 zqJYp4OWut*2puZut7zs5w$v0gvwF4a>(B%_SvdLsRr7yw z{%4^7kyLX4+l$y(y$U)C{?D@fm+=2){=WtPRr&pYRI+n&{BM>2i}SxE|Cs`>g1yS^1%^SWbm01)k`85QY#5vMu+TXzLG%oaC!;qz{%;e!k{h8-(+9d7^NAxQ)#e=^V4 zR7rfU4F$VQg)e>?fP!A@xP*q@00bSi*zRH9m{h|iO8K|~V+o|)6-|92N*UeCR0`?V z;sm0J&Oh^7UO$DOOGI1V#&UNe{IKNoiV_RpZ!5Ek`EsmhsQ%gdtCXe%a(;tlhke>m z14Cpji<_k;U0>{;iBgE2F^A@e3eg<50qn(9Xi|5c2W!sSr^jP7eYCfm=H8WIstGNb z-V;E5DP5@Am)mxKirEyPA8NbfhICe=&SQ*~E~5H2PdHjV{AskzfdjFD!WvXw0nuseORgp4bTzgJ8Q7AGxtXu) zt&DT6#-1Y3?R8QM+byG3;W@aIDioD2)JmpP+!2aV$5+lDpR1e_#-ekL&oBBVL$b{= zug-1QK>md|otnG{)e$0Q+zW*6HMf0`3xMyIAn8igSmuoGGB= ze06OlRkb;EoVPO+9q2o{Rl1qe-^>r;ORktQ6Z*9lRH8(wX>zFSviJKvv!iAPmQgND zjK9NpW-4IpnP2YMRD9YkU-5!yp%AjHA5Q+6wU(j27Ey2xT2`TaO{@?wL}`Kh&g^Dl z)IXH1@}xLzZa3~I5T!?1c|#l-A##pik73TfVPOrP#1khF{gMm=tIaBAWJgC7NH0cH zB<5A(z{72E(t)G#jcJd+u#{6Lq_glWZlmgnZFT8h(_@4mbn<;zDtGe|QmE0UMqJLT zz``_Mp46d%gG+ z9W?%h9&xyZL8h0<4MuG;5FYysH zH8FbhkcuVC7Sy)C+qhl3$ik%OPNJK1Yiq?wa$1Me1^z zJhlwot_hvEl`1Z76-#}F9e?M#MacMevM~@#CqSn@$oFr|4!u&=Sl+&6-wy0ku9#Zl zu67NFAL#M9;#jlFPv|?VZw=3PmNBdN9@2XdLHfIw2ak+C%j^$>zfGy5$VL2%wZbos ziMaqdA59IvPQ=Z%@M&23?K`TY(s8`sSkTNdZZZS7vs zcjq^-mh*$G8W_khl(p4E>|iZb$Gr_D@wJHQ>l326zRJ6zoh;0u^@@4*buo&2G6Mh` z6*-o&5x#Sd_xMm8eAl13_NDeMux!sl-h>`!^bgu}?y-j#`_niRYq1MUgs zIk!n#xD~jv(z>&m(r+==ekAdCILCh*JUv*A>(`KCHym4M8x3>FTl{R&i$G2S=(`k{ zgs^BtJsOx`?S5KTsu!v*W@j7|H+b_{OQNl8xrAye_Ex7NC+Ii4c_seg97ZB?_`St4 zq#9c=GXa;d8>vwJ$nI+|#|b-ae$G#Q+i4JP;#mp$E z$gfd-+tS!@jgYQUfedA|*?>obe#0qtGjqlNd>t>ausaIZ<2@#1>mhDv4|i4&yC4Tm z=|T_=kq-N0yW+^b#x;*eT1KRB9MJRxwv$@^YR9j^rIG@a#f}Wu`?kj%%nXitv&ti( zTx4wE^^Or6Z@KHYx&~n_efsRy3E=r#-}8F65VyVWA0ex%UhdgWw}+r5^{{@vw3?Ii zpgb0pq?k(^xU0is8$ktgMWYmzn!82-Jh znWnL>BpNbYrNuXle^;*mR8wHKa(NNSHsEds8li~ORA?o((pv1PY<5y`mga=k9np-( zRe_D>!cHQ39>b?o&F)?EB5&6#NLrNW4;m5}i8`+FzO0fO7gHjp_c=VtTo(IdE&}xK zv_pQB0N?0g^T_%_%>n2(U=N#6(Fw8i}M0I=@rVU9YgNO6GU$y97g;dl zNtnyabXBOF#T>*8V{wR`(P%5~_6Jp;t&n+Z`36HaTdmL#WFlD`xPQcw7W4R(zw4Gq zD2|Br$O)#%nq;r zy8*B`nK9O!qXm{_bu$CfE@X7=uf>TM%i*tB0<9v*@vPbw2I{L?v<8EDgZ$Q!bMJ|92{*qu%PEK~k!CfV zC*{GFJT~(GLgTdvHL782Fi!HbE$|aZGFgc@KMsvPms*B(`@E2M8t@}HFxp}L(rQdy z`N$~fsTlB6gF;E=WF1H2D?`!0Z-}**Vu0c>+qJ{+y$KP=Ie`OX6~D1lo4uvdc85lg z9}g!^4S25}w{+3scL-qU9q$L6c@56|>*=BNyVvO0-;3vo?bM!G^Z1$hR;G-( zrjZL(u$It)um<#H2oh8)`kWb57!>W1Wjl(F0I)r;G*lPEV+rsXx?B5OJ$S~fUtPdx ziI?uJAN{3#rUbU=5;o@rWIx6+Tj6eJkFGWFzADR2@8ve+(9{-kk|D~AmVMB?zNHoE zhrLa=^x{O29}*b(Ky_!#(l8PHBc+zQ%ljo5nY{MbBZWY`J4z>28sy@^zxJG!79e^Cz{ogLBremf;5_TW*RX#JH(j;9pij-=58f5|}T; zXA>XwL--2nqq+q1oNt&Fe0zXme9D&PT9F?aY$HbF+6Tx*;M-JpZfzD78Y8{_#Q^BP zo8~cO?ZBOvklKwZi0hTjs}HbqMQ;G?!*kn(nfLcWUhWo# zI8?SreTuA24eJKWuDnsQD+~kl5v;}O$EsBBxW$T<{V#;)+tKqH7YI@^qVJ3=xO&gQ zm#2t%6+5F>tA?b}yRv>Ef2nVOzWhL4sb=Bd^IlJC*Co`a9ItR8tVW1{9EYuFU)6wZ1x-Q)fG3y-8IxoOd_t!dkvUs4_s@@>MjyPn zd*En?FE&HtWqoWT2lmKRBKY-r$zriLXn6Hl zXAsO-yJ6rzS6`Ffdr@>(O%OjW!{J*_O}t_#VLdEpexDUf``G$-!=WK#j}RYjNG9c5 zqlV=>2zo!g!t2|??oXC`$gn%k1-B^pM;xvBJc>V2mxl`|xPefau`|BlYUH17<7@Rr zJ)!7FaY8n{>{U@Ml}(0Ls$qLkFl-c%ocPzup?D^=nZ@wr=I6qz@7VUQ@A!%W%BReK zl8`7i=U7(_RR&;v$yjftCK;1PE+sH=aleH1LCods?5J6IxAT`e{I22yRuDPnxUHS* z3v1KCi+K>3mllRw;+e^QIygmQhvVxnhMxjq$;&D~2WS6*#Rz&>EMtQ4$Ajpnxy2P< zt*V=m&8x0S3;Kz!UYvy({Ak`z9&$PLE%#9tH; z(ybv~LoP$yo|R45`sxUXKo5%Fwzyn~>QTggw<4myv1JtK`mq8pOc!JBR37V`(^FZ3 zMe}{P`+kA(K*F-XXFQShq5im>IN-pDZKeuaY+v!W+6h%J-o_BD$S@RE_a5K*4VZBp zKvY}j>ejYoyJ#9`k_J3WG!NNQ+oS8tUCG>mekRzjEIV1d;;1<~w^cmG|EXF6O9;jf zqK`Mw`9%G%BcQCajz-%1`fj}`?AiJq`>x7=#rpH-y)+e^9$FRLA84w*`>)Hy{ewW9 znChii83|<0#4(c)-fhEzPL2NeFxJSceqqz64D<<-r`dXi{+4#fyD|0VKqrEcjb6nV znK8YAije=NMU>1|i4me$oV&jmmO7aC31=(3Rgyky0Q-`zYL}uDO-ut}VBER5p?y<% zhpGq7g#7@T5Qv@27+S=N4o;&+x3TLo&1-1D?OP+?d5FL4H=P+Jq@3SASjeE$rmH(< z_DQNg?0XKct|=qw`%B&n1D8@!25VIrWTZ${A)T0BX%PTV!lrqPtX6>|>$w-qhKoe> z$rU!*--beCHG}$>W0p}==*HL2y0i&@zaQ<=r0VTsTYLM{E~09RRb{=r0{KOc&wgyH9Tjj>m25^IZtKTSxh1>P= z&)Ts_=454w*8_lM;co+l%-8OtmL^=d=u4gI$0B@1h{&iEu5 zbPjQ6C}SQS@_jv(iGc%Ci&}Y%X1Eky19;Oo3CSm{$QLf%711uSz}<&@s8rO!i$@Hl zr18h)rDmnejo(jq0KEiRM3yD1-c1#rQJYb%e)V#q-5kYuW|CjzYAS|+IoVQ!9Yv{T zbwCok**8Y*x}2Tic$GEyE3+L^z(K8dWVV7}iiTv=t|lJGw#ieNo$)C0z!jZ*yBxnKl>m8L1ywg}?##hgPpC#HRWAqOj74A%CEKYJ3@M2Xni(V5y8p)iat6ZtQytp;!|Vqj$8+Zmsm$f&ZvJc z^gCH!DYtcFtJ%@1YWt&#&?(F6eY800k`5y!Awr0Xz-!>p!!p8!pl9?%3IoKVFVc92 ze6>)qM>)-5y;`sw8^K94#XV3~7rj}P@Ly5Hi`}&Wm z28rqA_8@EG?a6XHCaqJA`;EEzY~82ypOzV74(f4l6do2^hpEk{T-j#J(Uc|yA97}E z%Gx4!8R;L@nt~o)EQOr7g7?vizDy%zYczU&R)qK`uryvsJqGEx?3Tf@Tj{#7AOcNuNhwbcqaei3~B)a@@UrFxCLkp)TW;J_Fl zcet^9s??3eoN)t587sfvm%&=IYU&zOruO;;R9CSAoiBh&m+N5cmSmnt{h%sn}~wz$$> zsU7PF^vp@*Vbi5KH14~xao3l8a*6d=H*3Zeqlxvtf(e_%3#0dFpF--I zToxtqJsmh&dCT5CZT*U~EL&|qd9cc0YpV)KBUld8^Q>>oUvcV3zn&GDpXyeq&v2pU z%p4xm>jj*SI`))3mD$r9GN?T@Ek?hiZl-^m!- zVMjLmZQ5tqE+Ef9rlwQm>y2fyi7Rh!y^hUa_1vejg_88^}_FnaUqE|B7ti!je(! zer4~zLFd7EGOk#|5-x6uIB#1?~NmR7a`m%SUWGPbB`QGv`UiJ*>IP-vb z1x#GZAH7Sfo$rG!=POAVK~*WR#mFm?%6X>iO%+44#b|KjBl1}dNvi9Zl;bE&W3msO z=wn(1(q==l*|uOdv|%QJ+MBs?o3!ARuEP-1Y(7(f5j0epoBA%wY{*t(i}Hc(E$UXs z{qgzoE(@VmAfy|TiRXrHyu(&;e)L?pRrbK;uAj@D=7#JYd<1nQrt(Y|a@`&XM-3VL zKtC7I)TX~~gA#LIsBZ9QbIP?NA7VCKqG=VTk}QT=#a}P4yF9V)C$6L-=+h3}3}v@o z;a;+7e=^5^dRK6{e1KKoX0U}ek0*?{v*?3F<+JoFLG$M_*0?K>xNpvdJ=QB zI_-AB?RDz2L96b?bhVb1iQ?$m5)vov0eFm5_eSa}k->mDwgG0&BHh-!2;1mp(|77+ zc!N1rrhp$rS0VUF4>kIV)4bvmC*h9<-^G^_*N(ZLdr7a+*cIKG;^MMLeE&`2lLWE|NJ|T6_NK<-8Gl z-Ngn(DbjtYW4FIkV)vM{OuC#jnv{chlRF)+`0_d*_I#|g{)h&iiY%nl!LTOSMn}M1 z-6ubpeQfOn81x$v%&hD6dRC|KZ>V*psU#~c0C8MY8< z@OdV<3+Q#S8A_%fu31mT!OMIA4m*L4&iSkD%|i0^a7}RE-+);@wc2Dq2_wMnkEvtp z;iM*2UOy-1C5`aHEcTRmr^gjhe<8B= z?uD>=Gs0-?=b@)tY>SU4g0mQ{lW3-@eb_ao8OI>y${>uew_#@wqV7TFlF<7g+Ie?MT(E z-XcYXL&M!B(vG$-PikFic80r`{N`6}#PQRbqP1!V_LS!+Zd8v#)SMh|$X2AIDD*{+ zKH)(v=qkCmC)IU>KoV>G*624&<{v$Ii&+Gqf2h$socU<`2tEiM31813z6@B1F#GHu zqm9S0wBJ@)6sjGmJ8taYD^DBqy9r$|gs&J<{$otC)a}DKN^oMVFN5=E^!++hK$$(q!YKXP=19^&Be8j zB@4upLZ3h-2S&s~tcgpirZh-rCMO9}gMsA<`%&bM(;3vi@*v34S(M>_eB>rOs3TT< z$`h-VA%=kr4GmVUxCUb&^)Yg%hR`+4B=L_vrIb&<8Ah|FAae9b_c-iyG3ak0hhLsBm$a9lG6#>P322`N8{`JdsE6VMYOCBpo=G(J2ZVv5@tKO_45gX%yUu1XghNV!H2myFzBEkvs?*2DNO}EcY%?~?@ zrmal%v@Tn6aCt~wumxJ9(AGzH-o4AS3IBSU3BY(;7Tj`w=Saif+r`wRiy!=uvu^tX z;$nGOR_>q_mjgG}##N^yT?m~S5Bj1Rvis%+&9*0HB?F&K#BzR$13mgq>`AT(h+YHF zy6H-T-D-%Pklm=F1R-A`RsO8)0)xb}i-lE5A(1=Lx=|X41@f96H$B+QyL8ugVvlAZJlc3LU2*={nG|IK)@Y63m9lNRuKBGaHjdjlN??cNcCuvB= zG0SdZxtOo>Q$2XYR~$6-;Mq4);wG%P9wP+PGw;xvt!7i1pa)_zl`=G%o{+~4iR*_W zukQG+NfW0-C>)Fr}{NOi%{c* z3BT;kI($RYyM=OWM1hirh%1NZVmZUJ~ zzfX7FXVHvZMrqW)0K`VX*KPweb^H}+s}VXT&g=t6aM5;U50)%`I|!feXpBab}ENzjtKF_!@A;q&oL*=Hno6{KB4V)EQWz>x3qnAG@YxE zNCVt``&tHFl{aMkvbxmO7|tyl3$Y<<Z*$&GngarO8U!;k^JA9Pn!T7HmfP_*ZW^MXY3R^!Hv8w^aicpgpjX1CI1w%*$vJyWDT`iPJ2?c z!t_;Mk>p9sU-u=KSA&knC#me+CKqbwkJcFfHEu&*`m@VqDhpZ3z~hJ2S!Ku5M+#=7 z#g&_i8iH)3vn5LNMeekQV@#8E-qW_VoblFO$sS2l+)c`ETTrZJeNr&zy9n228vQ%F zYk51hi{DM+vSjBta1qF@j?3L4rQdEX&@`A#wZ8E4E$Z0<#odBfkxkn@b*jsti<>Oh zB-Ngdv<0-|Ht?k}d~Q=1^)`JFc~5v56P0GO?+gF8FF@TUmt4-1r4Z0oUU;F{Fdih` z(Sz`6#(^PFv0eCS8~dUiR(0;viD-5{abmh3q|d(5iV4#L)9|3Yqq=fASrwy3@$V9@ zbqn2P^=-QuBC2tG2a3U$@Y*10iMU(y@_TE#<`|g&%VBStbw#3!=mRCE2ucL*O5J$s z=#v4Hf~_Cb*_MKUuk*5tLedqCI0<&(NLafUzdr@i5sy!S3Gg)N-43T;Y%e9OdY)LB z&*tHxe-aFn=RYF4h9Y{$Ki8ctz+>h7_<7V5!zjIHtqUth^X!c8=zrYOTsj*@p5kiv zz#C0xi|@OHW<15Jv{T2JhN?3jY!-XbE^jUNzepjv5XgEQ*VD8@%UP~rCF9_##U>4f z#EiZ1PZAHrEx>dXVwv{l+j+-(n8~0(O+TsY9|cIjMB2ldoWj$I)Kc7;UAk-EycxPe zT0=_f@F~SX4qJOCW(1=VXjwyX_m*dZ*H68k(2bK z0Dzy%QkvJ*bn?!3$4(CXDT{H;37mCn za++Rn@^G=4Ce{9V@G_;lL1QhqTb@6q?K!o48rb%N)-JRK^>NFx3aqO++E;Si3wOC3 zTxUI8?AM3o?Ecm0pWL8vS&D&T|TosXR(YV?`q` zx3pjUkqXp!)6T0$A-Od2C5CqR=caffqgQ~l-l8)+w=&XM!(5%A%#!mD6Z5}y>rKY~ z&uhg2&tJqT1V46nY!$9hL~#FHb=^Ww}`*uCoI zQx*8D0}ie~TW_6v6L)E&4;wv%d|*b_Bc`E2TaBY0i|u7cF7 z;`)F?rqSZP#A?`AAwXUR>GHMY(!v86=iI=9jopK0 zNjpce`d~8N`Yz<9B2B?D$43ICR#Vj_S&UeZAj-ZP?t)$?Jtem}~CG%pUK z`xNJ4ldIG+l)`)}BJsBKDGWw2{N%DC@oH}K3GQ~ir~C692eS!>lUNquB%*gqy2bII z9S3;F7k#$$Kbh-t?MHf`=$OD_V3UqxwTYK_!grS?r#ELRL`Jpe>osS+m)fHeGcPkCzT z664DIzc1&02W`uzc+hbJ-Vq7O)?CN#xTez(1yskdx*IyonJ@yV| z%Kji5rQI(J?FaB#8$7k?W8>CAd*3p}u3L|^oHRSGKb*!Kgs^$jW;~0$#g>OVZMns) z2_chwIG#@QN49YmG6i-~hM-s4o+7wIZ%v*0mP$@f@Yiz6AxENwx4EgNk4Iak*GkCe zsE!C$I%iDP_Ph<=R(BO9@`;@vXNfN_%A0j>|BgdjGcWFP?D&JTOc`1kzl7|A-<+d0&u#_@xI{^@J8O-eefos&#rfmVpU$zj-U~YsCI{{| z)v^XAH2*GpJ{>Q_N`G`dolB%{6$%z7}+D@$t}__xL#tNX^4*P5Nik zpJ$arG@3f%t%#zoDsT(nTNXFyS3dS?YxZmBj_p_W*=>u4sHBy|$Ym6(m*fKTfVtpM zbqVCC4^*MU^(;RKmBzRm@=eu%ooKa<)q5)KzOCSB{%7dc!Oc7<|6n>_Wjsyb;OTBH zp2o@JrmLTkb7)6b)Av#|WIUp5SJ21iKfX( z#Cb0AJf%3=C+EcPyV*eyDa6&-u?2f!kMSrsNv}wnyYhcpDQ4X7tq-A|=5;?f$-5rn z8`Wj-8jwmG+Zh~^==V(Jy`TMC3a%oVu%J2-G!SU*ytCW81e=aZg0R+xh=6TkXXn$6 zcH^mBx}T5>wD`PuBwT3Sc<-{ zOrbx1%OtU%D0_+Y4Nd+sx8fbZR405O&PC5FFyN$#BK2z8!AZw8g|zjmJPAbH?=$kt zgj@c8jLQ_7Mqw(jRuui@r=Vz=u8)84j&g{zCCRe8cxBkSQ5N?xWcaqBgx~Cu^6$k+ zqE)fRkB!VSQje?1k0pIoZ(8Y8eYmQnD*FP~^$O!U8ckfsH=Z)i&@j3&2Ie0_ayM-q zU68EJSmb_ZzE$9&rVG+O1nCUS$kVBkasp~rI(SQsrSL)RPk{mek+cqU!bzOYYkh^RGX^DNXYe}{kQ zPv%u|Iel^|&yR;0Qq`Tg999Ikc^}f>KR7U&PSxaDmvr@^W`kkE<^o{_;y;~_iALR~ zwq?SW%kwGjKr(@+-50CYeV}Y(*WsZh7MY2}d0wwbCQuY*a>Olt&a+X z%Kvz|-b8N0J+13PZb|Lv-FnUpg5g?W;_j-yC=A&^&Gf!Xzt7G%yd`ng4N7S36qmnN zyUxbMAxP}~04V+rU$DY&+`d2PPe_IQY=s9_?79;fn$cMx_6LO^d-pcwFiW0wUhe;g6MEQ*8HAF+yXfQ%OD*Umt#~Z)= zHtF2+cd9dyJWZMlsyF-)PSQA_HVzU@q_DKL%FB8vP=U#)Ta!?%$+Q)g3p-~+R|t!e zseORBv6pT>c(K8}+_3j{LdImau6l-IhF_y!o1ZgDy9&qb{cGIM%e1<4_nKXM z>9;O(Qx8PjM(zjw3)!E;??~FMDcC95+abYc8WAfdlusLnYgYo}g*7DQE8{KO+LnRH z)Mz)j-etgqY9AYin%Lp`6$Yl=k+}u{8kFAW2kg?yF4$*t-I-s-B=;6UNtxF-_P8dv ze)IUrRK)rW({NPEMXp6DDC7s8BA6Skvd+CH4W7(=7ro`lPwj~E?(1Y9D*H=f9y?kn zIHe<(bg0l}P$DbNnepek?vA*#|HtluH7jROhAF!Ixx#uWm}_?vcmPiH`Ph)A+T|v(9Nu%5y~S%JL0iQ~SO7TZ1d3E#|UU zGFKz70B2{dc8l8W^TE02{AH`gGsDrPID{v1oF4o<*Jg$zQV@0}%V>5~4I~_Vn)7;G z|G1`py{vE^9lg_Am4V7BG|3y^!1!4ma(;@84TY*iWJ|zEm!K|v(-F2wo&eneb;k`LBE(scwIvp1>fs9RebDZl@^4O>OO)C{H%`0g6j zomKUhIX=?Soco%xwchn{6W-k~D+@)Fg)LdF3G9D;HV6sz@}hG{p(y4pF?QLMDfWE( z3XquuKxJj7r*Rq7__;A$)w=?24W?|Vj&pQ1Y(kQ?-SQ#_yB^j%z)tRvQeVIf!YD_3 za~f0YZhwvG^S67WDnajwCM%-GFpTmu-DkwrKEB`{RJc255f<^-r@;4Fh>=2nMop8$ zH3mxfwj>@^xzaP+UY43`5*1AO9IeMcLUBlknk_u~%b<`9|Asll53OG?TM1wnCA)FZxy-o0^Y8mhkDw-L=@ zXC%(M4;KUVQILU^6hn#Pe9o0MfvxE3?W(JX^7j0#F3fXi*Xh@F${eJ%v9Dq35Ezlh z8(VK&8jgOiD_6UE!`{o@;X*XfE)qt%qH843#^qr0m-uEUw z$zlubZ<8M7t$P*Szc3SqZJy}nFEATm%1?J_sHs%;=MyyAoPeB?pLaU z`0={Ltx2Wmbf2j1M6J@05|a}hrn)-vTXOan6c}BDP>|wnLqos@{7Y)Cd3YB2CJT9t z1`#~#CRnx#Kj>OvL&%RlQr?SF71`?<9N;0GMo8!|Ep&vIdTd{^_4Qi+ zW$q`JNXnSCkA}%tAKQ~n<4HItd|SxHI)g6^$LlkK)7fJmk_W!x2wamhp7d>^GTLCcWP4EefZ4L4{!I$8gvRoJ>?ip;*2?MxVXC)cAv}iS`2@6I3Pe+ zH*TcR%d@qcG(JHI#yE7fe#el-6u1jCYq)c9$sD>9e-ZH@;npG*?b^}&0S;q*m-}S$ z!&o-%GG2G(VVUn>=4vd%=87`%Cn6)Y2B2X(@rarTL~_MSa#5QNtuBA8Ktb->^XhMZ zO6$14%Bfm5eN7t~bTjA{(JGJdT%^?`%pPimt`e&!8NNKH90fLc4FHOtA_ae2yF=@% zctHc0(3bo1cq4@7t0tidO`&^?6Ssj?``=Idf8Sj{W{;zM&@f~6?V*}&gj)No-rpV7 ztSKQ+4J))4{AL$WFie4pXY!S|r=I&Crv)DGY$|L+$3uYMLT%n*xVX*o+pk&`nxkv(>;rlO3Ei}%vSKN<7&N>^j6Qg1!uYQi;QTfntF0c> zxEeabt`qyAz_tuLMy&~nt^6sfwMOcX%sbtAqAk{swcbarq`F<0D7cXd0exJ>cQNVp znQ4DHr@|H!paP1U-#MFPdIchh5k<`_HTqt+JI}z9t#8)ToX3N!<_TUfg??YfKI!3q9NvN!0`j_tWbcJkucJdT09 zrqaE8dUP5linyw7Idh?ezT%==2@$JlJ%Ty}FuFQTq|)qtDw&@q4GmzkIHn0UGO)q- zUOrQ8w_01h6g~L~LnCkBY#nm7O)musl|I7WKNs<~Ia;_6h(n^6;?GJq+ko=Yj;gbU zFyazuNucO*I6>DMiX-0BJ`uk8oguk%cZlf;p%-&Fu)dF*d&}%E6&l{~cKLeN{QJ!J z?;m(>xS-h-4ZLuRc1@{?EY0{N6Za=lo?QZ1Mg`X#MuEgo)-rsARAx1YEGxFmYu>Q0f zee?m62H6JQmaF%R*lORchNQnu1KznfJloZGopxB~1`vx?emetiHduG3#ZW1+qX+Ks z=lm_Z_%M)!stg3~uxsi#V>9l~4CXJWYbJ;1ukOHlEjzqUYPz9U2D;nL4i}kNcaQO4bY`_lQh?;)0gQj7FABGtb)P|@AmZGtr$5@HocXe%HH-*`el`+4MF1@ zOE$I04(GKd^L>Y8WNrkF4=7#(#pw`LM9e_Q#UlPS)GzV?QDBiBZ7HL;Rn(28Kz|{N zmJSppp|kG68I-sRIU_t0sCXQC`)h`VSvWC&ds{@lJo_7si5=QV%A-bh+|4~==UU>5Dm=X zamYW{-OL1nuh^UonL(R@n(h}SXZO>Wv!VM>nEjKDducKxOHP}}puBVB{MI_pCU}rS_6MVo60d0rS+^+^=zgie^9qz2uja($N zS^mu$`}0WgwJ+sbWF>@{oL?ixF#&W7n%34fT4-ko=Wjj*3ENl={M0w@cXZW(^lW+8 zc^5NuXs7AXkMUcHnQxK1Nd$ivB4Fy>bb~MglpO2ag=cF1Y*fXVhD^;&xk$}rx1>fU zju)e(_T#z<$6wAy3|}2E#ms9=-Z;D;{qz`B%KwOhzU*@0kDPIyKFdZ z8SK|go!Sgki1ay?Ifdy@MkF$#9z`}em~9`o02#B^0D(pN$ciJb?W7wx_rgIguw~IgF@D4Tmq$JtG7Z$Nwy( zP%0Y@`~Yy0=)DahA(F*Mq<^cN?Xnkw{)cRl@O-uRqg%MDL2Y=){*Vb(CA5c*Si-XM$`U{-BtNGz;H8DsqJ zLKg9pf!1h_+plLZ}tP4+<}M@_LOXe}1NVWUv9RH?HL8 zQl#+1*EHj{NW6~7Lh%Z2>QTmCaH;;V|4D%$-PnSxacicAzZi-FGJW3>+iM^T!$
    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 215/299] 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 216/299] 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 217/299] 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 218/299] 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 224/299] 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 225/299] 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 226/299] 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 227/299] 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 228/299] 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 229/299] 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 230/299] 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 231/299] 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 232/299] 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 233/299] 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 234/299] 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 235/299] 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 236/299] 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 237/299] 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 238/299] 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 239/299] 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 240/299] 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 241/299] 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 242/299] 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 246/299] 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 247/299] 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 248/299] 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 249/299] 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 250/299] 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 251/299] 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 252/299] 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 253/299] 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 254/299] 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 255/299] 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 256/299] 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 257/299] 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 258/299] 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 259/299] 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 260/299] 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 261/299] 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 262/299] 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 263/299] 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 264/299] 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 265/299] 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 266/299] 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 267/299] 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 268/299] 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 269/299] 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 270/299] 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 80c03fcf3f096c645ebd7b5d85191a9816e54068 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 29 Apr 2024 04:28:13 +0530 Subject: [PATCH 271/299] 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 272/299] fix: include all document meta when using the public api --- packages/api/v1/implementation.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 8ee0350bd..253803fc8 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -229,6 +229,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { requestMetadata: extractNextApiRequestMetadata(args.req), }); + await upsertDocumentMeta({ + documentId: document.id, + userId: user.id, + ...body.meta, + requestMetadata: extractNextApiRequestMetadata(args.req), + }); + const recipients = await setRecipientsForDocument({ userId: user.id, teamId: team?.id, @@ -324,10 +331,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { await upsertDocumentMeta({ documentId: document.id, userId: user.id, - subject: body.meta.subject, - message: body.meta.message, - dateFormat: body.meta.dateFormat, - timezone: body.meta.timezone, + ...body.meta, requestMetadata: extractNextApiRequestMetadata(args.req), }); } From 97d334a1da63b8e0284d2697f914631fa64671eb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 29 Apr 2024 20:15:40 +0700 Subject: [PATCH 273/299] fix: force users to have a Stripe customer on sign in --- apps/web/src/pages/api/auth/[...nextauth].ts | 32 ++++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts index 365b6ec40..5217f4f8b 100644 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ b/apps/web/src/pages/api/auth/[...nextauth].ts @@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import NextAuth from 'next-auth'; +import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { prisma } from '@documenso/prisma'; @@ -18,15 +20,27 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) { error: '/signin', }, events: { - signIn: async ({ user }) => { - await prisma.userSecurityAuditLog.create({ - data: { - userId: user.id, - ipAddress, - userAgent, - type: UserSecurityAuditLogType.SIGN_IN, - }, - }); + signIn: async ({ user: { id: userId } }) => { + const [user] = await Promise.all([ + await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }), + await prisma.userSecurityAuditLog.create({ + data: { + userId: userId, + ipAddress, + userAgent, + type: UserSecurityAuditLogType.SIGN_IN, + }, + }), + ]); + + // Create the Stripe customer and attach it to the user if it doesn't exist. + if (user.customerId === null && IS_BILLING_ENABLED()) { + await getStripeCustomerByUser(user); + } }, signOut: async ({ token }) => { const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id; From 0e16a86e74702986fe4e3146a775db9293617795 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Tue, 30 Apr 2024 11:55:01 +0530 Subject: [PATCH 274/299] chore: updated dark mode text --- .../(unauthenticated)/articles/signature-disclosure/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(unauthenticated)/articles/signature-disclosure/page.tsx b/apps/web/src/app/(unauthenticated)/articles/signature-disclosure/page.tsx index 878332f35..c56f53702 100644 --- a/apps/web/src/app/(unauthenticated)/articles/signature-disclosure/page.tsx +++ b/apps/web/src/app/(unauthenticated)/articles/signature-disclosure/page.tsx @@ -5,7 +5,7 @@ import { Button } from '@documenso/ui/primitives/button'; export default function SignatureDisclosure() { return (
      -
      +

      Electronic Signature Disclosure

      Welcome

      From 8622e688534ffc374aee85715625a12262a38c20 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 30 Apr 2024 15:50:22 +0700 Subject: [PATCH 275/299] 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 276/299] 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 277/299] 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 286/299] 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 287/299] 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 288/299] fix: unauthorized access error api tokens page team (#1134) --- .../t/[teamUrl]/settings/tokens/page.tsx | 22 ++++++++++++++++++- .../public-api/get-all-team-tokens.ts | 8 ++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/tokens/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/tokens/page.tsx index eedae29d1..7602ac70f 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/settings/tokens/page.tsx +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/tokens/page.tsx @@ -1,7 +1,10 @@ import { DateTime } from 'luxon'; +import { match } from 'ts-pattern'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import type { GetTeamTokensResponse } from '@documenso/lib/server-only/public-api/get-all-team-tokens'; import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens'; import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { Button } from '@documenso/ui/primitives/button'; @@ -23,7 +26,24 @@ export default async function ApiTokensPage({ params }: ApiTokensPageProps) { const team = await getTeamByUrl({ userId: user.id, teamUrl }); - const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }); + let tokens: GetTeamTokensResponse | null = null; + + try { + tokens = await getTeamTokens({ userId: user.id, teamId: team.id }); + } catch (err) { + const error = AppError.parseError(err); + + return ( +
    +

    API Tokens

    +

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

    +
    + ); + } return (
    diff --git a/packages/lib/server-only/public-api/get-all-team-tokens.ts b/packages/lib/server-only/public-api/get-all-team-tokens.ts index 86c13ed1d..35285336b 100644 --- a/packages/lib/server-only/public-api/get-all-team-tokens.ts +++ b/packages/lib/server-only/public-api/get-all-team-tokens.ts @@ -1,3 +1,4 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; import { TeamMemberRole } from '@documenso/prisma/client'; @@ -6,6 +7,8 @@ export type GetUserTokensOptions = { teamId: number; }; +export type GetTeamTokensResponse = Awaited>; + export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => { const teamMember = await prisma.teamMember.findFirst({ where: { @@ -15,7 +18,10 @@ export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => }); if (teamMember?.role !== TeamMemberRole.ADMIN) { - throw new Error('You do not have permission to view tokens for this team'); + throw new AppError( + AppErrorCode.UNAUTHORIZED, + 'You do not have the required permissions to view this page.', + ); } return await prisma.apiToken.findMany({ From cc4efddabf8f20dffcc89d3cf8de33a8a1fdd38d Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Wed, 8 May 2024 17:03:57 +0530 Subject: [PATCH 289/299] 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 290/299] 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 291/299] 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) + + + +