From 38ad3a19229797de45591b7a4033e3cc530448f3 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 7 Dec 2023 14:52:12 +0000 Subject: [PATCH 01/19] refactor: download function to be reusable --- .../documents/data-table-action-button.tsx | 56 ++++++------------- .../documents/data-table-action-dropdown.tsx | 18 +----- packages/lib/client-only/download-pdf.ts | 37 ++++++++++++ packages/prisma/types/document-with-data.ts | 2 +- .../document/document-download-button.tsx | 43 +++----------- packages/ui/primitives/use-toast.ts | 3 +- 6 files changed, 66 insertions(+), 93 deletions(-) create mode 100644 packages/lib/client-only/download-pdf.ts diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 54a8f6184..51f3f7c58 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -6,13 +6,12 @@ import { Download, Edit, Pencil } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { match } from 'ts-pattern'; -import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { downloadFile } from '@documenso/lib/client-only/download-pdf'; import type { Document, Recipient, User } from '@documenso/prisma/client'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { Button } from '@documenso/ui/primitives/button'; -import { useToast } from '@documenso/ui/primitives/use-toast'; export type DataTableActionButtonProps = { row: Document & { @@ -23,7 +22,6 @@ export type DataTableActionButtonProps = { export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const { data: session } = useSession(); - const { toast } = useToast(); if (!session) { return null; @@ -39,47 +37,25 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const onDownloadClick = async () => { - try { - let document: DocumentWithData | null = null; + let document: DocumentWithData | null = null; - if (!recipient) { - document = await trpcClient.document.getDocumentById.query({ - id: row.id, - }); - } else { - document = await trpcClient.document.getDocumentByToken.query({ - token: recipient.token, - }); - } - - const documentData = document?.documentData; - - if (!documentData) { - return; - } - - const documentBytes = await getFile(documentData); - - const blob = new Blob([documentBytes], { - type: 'application/pdf', + if (!recipient) { + document = await trpcClient.document.getDocumentById.query({ + id: row.id, }); - - const link = window.document.createElement('a'); - const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title; - - link.href = window.URL.createObjectURL(blob); - link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; - - link.click(); - - window.URL.revokeObjectURL(link.href); - } catch (error) { - toast({ - title: 'Something went wrong', - description: 'An error occurred while trying to download file.', - variant: 'destructive', + } else { + document = await trpcClient.document.getDocumentByToken.query({ + token: recipient.token, }); } + + const documentData = document?.documentData; + + if (!documentData) { + return; + } + + await downloadFile({ documentData, fileName: row.title }); }; return match({ 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 9c3532f88..e9a713d62 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 @@ -17,7 +17,7 @@ import { } from 'lucide-react'; import { useSession } from 'next-auth/react'; -import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { downloadFile } from '@documenso/lib/client-only/download-pdf'; import type { Document, Recipient, User } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; @@ -81,21 +81,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = return; } - const documentBytes = await getFile(documentData); - - const blob = new Blob([documentBytes], { - type: 'application/pdf', - }); - - const link = window.document.createElement('a'); - const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title; - - link.href = window.URL.createObjectURL(blob); - link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; - - link.click(); - - window.URL.revokeObjectURL(link.href); + await downloadFile({ documentData, fileName: row.title }); }; const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); diff --git a/packages/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts new file mode 100644 index 000000000..af304f983 --- /dev/null +++ b/packages/lib/client-only/download-pdf.ts @@ -0,0 +1,37 @@ +import type { DocumentData } from '@documenso/prisma/client'; +import { toast } from '@documenso/ui/primitives/use-toast'; + +import { getFile } from '../universal/upload/get-file'; + +type DownloadPDFProps = { + documentData: DocumentData; + fileName?: string; +}; + +export const downloadFile = async ({ documentData, fileName }: DownloadPDFProps) => { + try { + const bytes = await getFile(documentData); + + const blob = new Blob([bytes], { + type: 'application/pdf', + }); + + const link = window.document.createElement('a'); + const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName; + + link.href = window.URL.createObjectURL(blob); + link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; + + link.click(); + + window.URL.revokeObjectURL(link.href); + } catch (err) { + console.error(err); + + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', + }); + } +}; diff --git a/packages/prisma/types/document-with-data.ts b/packages/prisma/types/document-with-data.ts index d8dd8a888..461d13e6c 100644 --- a/packages/prisma/types/document-with-data.ts +++ b/packages/prisma/types/document-with-data.ts @@ -1,4 +1,4 @@ -import { Document, DocumentData, DocumentMeta } from '@documenso/prisma/client'; +import type { Document, DocumentData, DocumentMeta } from '@documenso/prisma/client'; export type DocumentWithData = Document & { documentData?: DocumentData | null; diff --git a/packages/ui/components/document/document-download-button.tsx b/packages/ui/components/document/document-download-button.tsx index a2a35e490..9471611ff 100644 --- a/packages/ui/components/document/document-download-button.tsx +++ b/packages/ui/components/document/document-download-button.tsx @@ -5,11 +5,10 @@ import { useState } from 'react'; import { Download } from 'lucide-react'; -import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { downloadFile } from '@documenso/lib/client-only/download-pdf'; import type { DocumentData } from '@documenso/prisma/client'; import { Button } from '../../primitives/button'; -import { useToast } from '../../primitives/use-toast'; export type DownloadButtonProps = HTMLAttributes & { disabled?: boolean; @@ -24,44 +23,18 @@ export const DocumentDownloadButton = ({ disabled, ...props }: DownloadButtonProps) => { - const { toast } = useToast(); - const [isLoading, setIsLoading] = useState(false); const onDownloadClick = async () => { - try { - setIsLoading(true); + setIsLoading(true); - if (!documentData) { - return; - } - - const bytes = await getFile(documentData); - - const blob = new Blob([bytes], { - type: 'application/pdf', - }); - - const link = window.document.createElement('a'); - const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName; - - link.href = window.URL.createObjectURL(blob); - link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; - - link.click(); - - window.URL.revokeObjectURL(link.href); - } catch (err) { - console.error(err); - - toast({ - title: 'Error', - description: 'An error occurred while downloading your document.', - variant: 'destructive', - }); - } finally { - setIsLoading(false); + if (!documentData) { + return; } + + await downloadFile({ documentData, fileName }).then(() => { + setIsLoading(false); + }); }; return ( diff --git a/packages/ui/primitives/use-toast.ts b/packages/ui/primitives/use-toast.ts index 6524baf30..27f96aa29 100644 --- a/packages/ui/primitives/use-toast.ts +++ b/packages/ui/primitives/use-toast.ts @@ -1,7 +1,8 @@ // Inspired by react-hot-toast library import * as React from 'react'; -import { ToastActionElement, type ToastProps } from './toast'; +import type { ToastActionElement } from './toast'; +import { type ToastProps } from './toast'; const TOAST_LIMIT = 1; const TOAST_REMOVE_DELAY = 1000000; From 2ae9e2990320b6b7ed008923d59978826a3ce25c Mon Sep 17 00:00:00 2001 From: harkiratsm Date: Fri, 22 Dec 2023 17:24:05 +0530 Subject: [PATCH 02/19] feat: improve the ux for password protected documents Signed-off-by: harkiratsm --- .../primitives/document-password-dialog.tsx | 51 +++++++++++++++++++ packages/ui/primitives/pdf-viewer.tsx | 40 ++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 packages/ui/primitives/document-password-dialog.tsx diff --git a/packages/ui/primitives/document-password-dialog.tsx b/packages/ui/primitives/document-password-dialog.tsx new file mode 100644 index 000000000..da482bae3 --- /dev/null +++ b/packages/ui/primitives/document-password-dialog.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from './dialog'; + +import { Input } from './input'; +import { Button } from './button'; + +type PasswordDialogProps = { + open: boolean; + onOpenChange: (_open: boolean) => void; + setPassword: (_password: string) => void; + handleSubmit: () => void; + isError?: boolean; +} + +export const PasswordDialog = ({ open, onOpenChange, handleSubmit, isError, setPassword }: PasswordDialogProps) => { + return ( + + + + Password Required + + {isError ? ( +

Incorrect password. Please try again.

+ ) : ( +

+ This document is password protected. Please enter the password to view the document. +

+ )} +
+
+ + setPassword(e.target.value)} + /> + + +
+
+ ); +}; diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx index 07cdaf1e2..c4184b17f 100644 --- a/packages/ui/primitives/pdf-viewer.tsx +++ b/packages/ui/primitives/pdf-viewer.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Loader } from 'lucide-react'; -import type { PDFDocumentProxy } from 'pdfjs-dist'; +import { PasswordResponses, type PDFDocumentProxy } from 'pdfjs-dist'; import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; import 'react-pdf/dist/esm/Page/TextLayer.css'; @@ -14,6 +14,7 @@ import type { DocumentData } from '@documenso/prisma/client'; import { cn } from '../lib/utils'; import { useToast } from './use-toast'; +import { PasswordDialog } from './document-password-dialog'; export type LoadedPDFDocument = PDFDocumentProxy; @@ -60,6 +61,10 @@ export const PDFViewer = ({ const $el = useRef(null); const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false); + const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); + const [password, setPassword] = useState(null); + const passwordCallbackRef = useRef<((password: string | null) => void) | null>(null); + const [isPasswordError, setIsPasswordError] = useState(false); const [documentBytes, setDocumentBytes] = useState(null); const [width, setWidth] = useState(0); @@ -77,6 +82,14 @@ export const PDFViewer = ({ setNumPages(doc.numPages); onDocumentLoad?.(doc); }; + + const handlePasswordSubmit = () => { + setIsPasswordModalOpen(false); + if (passwordCallbackRef.current) { + passwordCallbackRef.current(password); + passwordCallbackRef.current = null; + } + } const onDocumentPageClick = ( event: React.MouseEvent, @@ -169,11 +182,26 @@ export const PDFViewer = ({ ) : ( + <> { + setIsPasswordModalOpen(true); + passwordCallbackRef.current = callback; + switch (reason) { + case PasswordResponses.NEED_PASSWORD: + setIsPasswordError(false); + break; + case PasswordResponses.INCORRECT_PASSWORD: + setIsPasswordError(true); + break; + default: + break; + } + }} onLoadSuccess={(d) => onDocumentLoaded(d)} // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. // Therefore we add some additional custom error handling. @@ -220,7 +248,15 @@ export const PDFViewer = ({ ))} - )} + + + )} ); }; From 72a7dc6c051f06323484accff15707eab13abe0d Mon Sep 17 00:00:00 2001 From: harkiratsm Date: Fri, 29 Dec 2023 17:26:33 +0530 Subject: [PATCH 03/19] fix the console error Signed-off-by: harkiratsm --- packages/ui/primitives/document-password-dialog.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/primitives/document-password-dialog.tsx b/packages/ui/primitives/document-password-dialog.tsx index da482bae3..61436aa71 100644 --- a/packages/ui/primitives/document-password-dialog.tsx +++ b/packages/ui/primitives/document-password-dialog.tsx @@ -28,19 +28,19 @@ export const PasswordDialog = ({ open, onOpenChange, handleSubmit, isError, setP Password Required {isError ? ( -

Incorrect password. Please try again.

+ Incorrect password. Please try again. ) : ( -

+ This document is password protected. Please enter the password to view the document. -

+ )}
setPassword(e.target.value)} /> From 53c570151f43918452c7923a92caef114bde288c Mon Sep 17 00:00:00 2001 From: harkiratsm Date: Fri, 29 Dec 2023 22:11:44 +0530 Subject: [PATCH 04/19] fix lint, description of dialog Signed-off-by: harkiratsm --- .../primitives/document-password-dialog.tsx | 43 +++--- packages/ui/primitives/pdf-viewer.tsx | 144 +++++++++--------- 2 files changed, 96 insertions(+), 91 deletions(-) diff --git a/packages/ui/primitives/document-password-dialog.tsx b/packages/ui/primitives/document-password-dialog.tsx index 61436aa71..08a5de8f3 100644 --- a/packages/ui/primitives/document-password-dialog.tsx +++ b/packages/ui/primitives/document-password-dialog.tsx @@ -1,50 +1,55 @@ import React from 'react'; +import { Button } from './button'; import { Dialog, DialogContent, - DialogHeader, - DialogTitle, DialogDescription, DialogFooter, -} from './dialog'; - -import { Input } from './input'; -import { Button } from './button'; + DialogHeader, + DialogTitle, +} from './dialog'; +import { Input } from './input'; type PasswordDialogProps = { open: boolean; onOpenChange: (_open: boolean) => void; setPassword: (_password: string) => void; - handleSubmit: () => void; + onPasswordSubmit: () => void; isError?: boolean; -} +}; -export const PasswordDialog = ({ open, onOpenChange, handleSubmit, isError, setPassword }: PasswordDialogProps) => { +export const PasswordDialog = ({ + open, + onOpenChange, + onPasswordSubmit, + isError, + setPassword, +}: PasswordDialogProps) => { return ( Password Required - - {isError ? ( - Incorrect password. Please try again. - ) : ( - - This document is password protected. Please enter the password to view the document. - - )} + + This document is password protected. Please enter the password to view the document. setPassword(e.target.value)} + autoComplete="off" /> - + + {isError && ( + + The password you entered is incorrect. Please try again. + + )} ); diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx index c4184b17f..be2d0cc4a 100644 --- a/packages/ui/primitives/pdf-viewer.tsx +++ b/packages/ui/primitives/pdf-viewer.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Loader } from 'lucide-react'; -import { PasswordResponses, type PDFDocumentProxy } from 'pdfjs-dist'; +import { type PDFDocumentProxy, PasswordResponses } from 'pdfjs-dist'; import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; import 'react-pdf/dist/esm/Page/TextLayer.css'; @@ -13,8 +13,8 @@ import { getFile } from '@documenso/lib/universal/upload/get-file'; import type { DocumentData } from '@documenso/prisma/client'; import { cn } from '../lib/utils'; -import { useToast } from './use-toast'; import { PasswordDialog } from './document-password-dialog'; +import { useToast } from './use-toast'; export type LoadedPDFDocument = PDFDocumentProxy; @@ -82,14 +82,14 @@ export const PDFViewer = ({ setNumPages(doc.numPages); onDocumentLoad?.(doc); }; - - const handlePasswordSubmit = () => { + + const onPasswordSubmit = () => { setIsPasswordModalOpen(false); if (passwordCallbackRef.current) { passwordCallbackRef.current(password); passwordCallbackRef.current = null; } - } + }; const onDocumentPageClick = ( event: React.MouseEvent, @@ -183,80 +183,80 @@ export const PDFViewer = ({ ) : ( <> - { - setIsPasswordModalOpen(true); - passwordCallbackRef.current = callback; - switch (reason) { - case PasswordResponses.NEED_PASSWORD: - setIsPasswordError(false); - break; - case PasswordResponses.INCORRECT_PASSWORD: - setIsPasswordError(true); - break; - default: - break; + { + setIsPasswordModalOpen(true); + passwordCallbackRef.current = callback; + switch (reason) { + case PasswordResponses.NEED_PASSWORD: + setIsPasswordError(false); + break; + case PasswordResponses.INCORRECT_PASSWORD: + setIsPasswordError(true); + break; + default: + break; + } + }} + onLoadSuccess={(d) => onDocumentLoaded(d)} + // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. + // Therefore we add some additional custom error handling. + onSourceError={() => { + setPdfError(true); + }} + externalLinkTarget="_blank" + loading={ +
+ {pdfError ? ( +
+

Something went wrong while loading the document.

+

Please try again or contact our support.

+
+ ) : ( + + )} +
} - }} - onLoadSuccess={(d) => onDocumentLoaded(d)} - // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. - // Therefore we add some additional custom error handling. - onSourceError={() => { - setPdfError(true); - }} - externalLinkTarget="_blank" - loading={ -
- {pdfError ? ( + error={ +

Something went wrong while loading the document.

Please try again or contact our support.

- ) : ( - - )} -
- } - error={ -
-
-

Something went wrong while loading the document.

-

Please try again or contact our support.

-
- } - > - {Array(numPages) - .fill(null) - .map((_, i) => ( -
- ''} - onClick={(e) => onDocumentPageClick(e, i + 1)} - /> -
- ))} - - + } + > + {Array(numPages) + .fill(null) + .map((_, i) => ( +
+ ''} + onClick={(e) => onDocumentPageClick(e, i + 1)} + /> +
+ ))} + + - )} + )}
); }; From b76d2cea3be939805b1bf051d601708435a416eb Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Tue, 2 Jan 2024 04:38:35 +0000 Subject: [PATCH 05/19] fix: changes from code review --- .vscode/settings.json | 2 +- .../documents/data-table-action-button.tsx | 44 ++++++++++++------- .../documents/data-table-action-dropdown.tsx | 42 +++++++++++------- packages/lib/client-only/download-pdf.ts | 33 +++++--------- .../document/document-download-button.tsx | 27 +++++++++--- packages/ui/primitives/use-toast.ts | 3 +- 6 files changed, 85 insertions(+), 66 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 97d5d1948..82aa3c1a3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "typescript.tsdk": "node_modules/typescript/lib", "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"], "javascript.preferences.importModuleSpecifier": "non-relative", diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 51f3f7c58..9910ef111 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -6,12 +6,13 @@ import { Download, Edit, Pencil } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { match } from 'ts-pattern'; -import { downloadFile } from '@documenso/lib/client-only/download-pdf'; +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { Document, Recipient, User } from '@documenso/prisma/client'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; export type DataTableActionButtonProps = { row: Document & { @@ -22,6 +23,7 @@ export type DataTableActionButtonProps = { export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const { data: session } = useSession(); + const { toast } = useToast(); if (!session) { return null; @@ -37,25 +39,33 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const onDownloadClick = async () => { - let document: DocumentWithData | null = null; + try { + let document: DocumentWithData | null = null; - if (!recipient) { - document = await trpcClient.document.getDocumentById.query({ - id: row.id, - }); - } else { - document = await trpcClient.document.getDocumentByToken.query({ - token: recipient.token, + if (!recipient) { + document = await trpcClient.document.getDocumentById.query({ + id: row.id, + }); + } else { + document = await trpcClient.document.getDocumentByToken.query({ + token: recipient.token, + }); + } + + const documentData = document?.documentData; + + if (!documentData) { + throw Error('No document available'); + } + + await downloadPDF({ documentData, fileName: row.title }); + } catch (err) { + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', }); } - - const documentData = document?.documentData; - - if (!documentData) { - return; - } - - await downloadFile({ documentData, fileName: row.title }); }; return match({ 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 e9a713d62..c95a49ff6 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 @@ -17,7 +17,7 @@ import { } from 'lucide-react'; import { useSession } from 'next-auth/react'; -import { downloadFile } from '@documenso/lib/client-only/download-pdf'; +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { Document, Recipient, User } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; @@ -63,25 +63,33 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = const isDocumentDeletable = isOwner; const onDownloadClick = async () => { - let document: DocumentWithData | null = null; + try { + let document: DocumentWithData | null = null; - if (!recipient) { - document = await trpcClient.document.getDocumentById.query({ - id: row.id, - }); - } else { - document = await trpcClient.document.getDocumentByToken.query({ - token: recipient.token, + if (!recipient) { + document = await trpcClient.document.getDocumentById.query({ + id: row.id, + }); + } else { + document = await trpcClient.document.getDocumentByToken.query({ + token: recipient.token, + }); + } + + const documentData = document?.documentData; + + if (!documentData) { + return; + } + + await downloadPDF({ documentData, fileName: row.title }); + } catch (err) { + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', }); } - - const documentData = document?.documentData; - - if (!documentData) { - return; - } - - await downloadFile({ documentData, fileName: row.title }); }; const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); diff --git a/packages/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts index af304f983..e095002ee 100644 --- a/packages/lib/client-only/download-pdf.ts +++ b/packages/lib/client-only/download-pdf.ts @@ -1,5 +1,4 @@ import type { DocumentData } from '@documenso/prisma/client'; -import { toast } from '@documenso/ui/primitives/use-toast'; import { getFile } from '../universal/upload/get-file'; @@ -8,30 +7,20 @@ type DownloadPDFProps = { fileName?: string; }; -export const downloadFile = async ({ documentData, fileName }: DownloadPDFProps) => { - try { - const bytes = await getFile(documentData); +export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps) => { + const bytes = await getFile(documentData); - const blob = new Blob([bytes], { - type: 'application/pdf', - }); + const blob = new Blob([bytes], { + type: 'application/pdf', + }); - const link = window.document.createElement('a'); - const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName; + const link = window.document.createElement('a'); + const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName; - link.href = window.URL.createObjectURL(blob); - link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; + link.href = window.URL.createObjectURL(blob); + link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; - link.click(); + link.click(); - window.URL.revokeObjectURL(link.href); - } catch (err) { - console.error(err); - - toast({ - title: 'Something went wrong', - description: 'An error occurred while downloading your document.', - variant: 'destructive', - }); - } + window.URL.revokeObjectURL(link.href); }; diff --git a/packages/ui/components/document/document-download-button.tsx b/packages/ui/components/document/document-download-button.tsx index 9471611ff..ee41cc691 100644 --- a/packages/ui/components/document/document-download-button.tsx +++ b/packages/ui/components/document/document-download-button.tsx @@ -3,9 +3,10 @@ import type { HTMLAttributes } from 'react'; import { useState } from 'react'; +import { useToast } from '@/primitives/use-toast'; import { Download } from 'lucide-react'; -import { downloadFile } from '@documenso/lib/client-only/download-pdf'; +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { DocumentData } from '@documenso/prisma/client'; import { Button } from '../../primitives/button'; @@ -24,17 +25,29 @@ export const DocumentDownloadButton = ({ ...props }: DownloadButtonProps) => { const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); const onDownloadClick = async () => { - setIsLoading(true); + try { + setIsLoading(true); - if (!documentData) { - return; - } + if (!documentData) { + setIsLoading(false); + return; + } - await downloadFile({ documentData, fileName }).then(() => { + await downloadPDF({ documentData, fileName }).then(() => { + setIsLoading(false); + }); + } catch (err) { setIsLoading(false); - }); + + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', + }); + } }; return ( diff --git a/packages/ui/primitives/use-toast.ts b/packages/ui/primitives/use-toast.ts index 27f96aa29..093ec8bb5 100644 --- a/packages/ui/primitives/use-toast.ts +++ b/packages/ui/primitives/use-toast.ts @@ -1,8 +1,7 @@ // Inspired by react-hot-toast library import * as React from 'react'; -import type { ToastActionElement } from './toast'; -import { type ToastProps } from './toast'; +import type { ToastActionElement, ToastProps } from './toast'; const TOAST_LIMIT = 1; const TOAST_REMOVE_DELAY = 1000000; From 6a26ab4b2b8bcb23348e421d7dc6de7e6c08ff10 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Tue, 2 Jan 2024 04:52:15 +0000 Subject: [PATCH 06/19] fix: toast import errors --- .../app/(dashboard)/documents/data-table-action-dropdown.tsx | 2 ++ packages/ui/components/document/document-download-button.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) 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 c95a49ff6..a0695509d 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 @@ -30,6 +30,7 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from '@documenso/ui/primitives/dropdown-menu'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { ResendDocumentActionItem } from './_action-items/resend-document'; import { DeleteDocumentDialog } from './delete-document-dialog'; @@ -44,6 +45,7 @@ export type DataTableActionDropdownProps = { export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { const { data: session } = useSession(); + const { toast } = useToast(); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); diff --git a/packages/ui/components/document/document-download-button.tsx b/packages/ui/components/document/document-download-button.tsx index ee41cc691..1c4490f4a 100644 --- a/packages/ui/components/document/document-download-button.tsx +++ b/packages/ui/components/document/document-download-button.tsx @@ -3,11 +3,11 @@ import type { HTMLAttributes } from 'react'; import { useState } from 'react'; -import { useToast } from '@/primitives/use-toast'; import { Download } from 'lucide-react'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { DocumentData } from '@documenso/prisma/client'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { Button } from '../../primitives/button'; From a71078cbd57428aa01025b6841dfba4af39452ed Mon Sep 17 00:00:00 2001 From: Ashraf Chowdury <87828904+ashrafchowdury@users.noreply.github.com> Date: Mon, 8 Jan 2024 13:17:30 +0800 Subject: [PATCH 07/19] fix: fixed the deleting signature block issue on touchscreens --- packages/ui/primitives/document-flow/field-item.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/primitives/document-flow/field-item.tsx b/packages/ui/primitives/document-flow/field-item.tsx index 7583bd4b9..716768c18 100644 --- a/packages/ui/primitives/document-flow/field-item.tsx +++ b/packages/ui/primitives/document-flow/field-item.tsx @@ -121,6 +121,7 @@ export const FieldItem = ({ From 68953d1253b8c688988bbef0d6e256b3d01dee47 Mon Sep 17 00:00:00 2001 From: harkiratsm Date: Fri, 12 Jan 2024 20:54:59 +0530 Subject: [PATCH 08/19] feat add documentPassword to documenet meta and improve the ux Signed-off-by: harkiratsm --- .../documents/[id]/edit-document.tsx | 6 ++-- .../app/(dashboard)/documents/[id]/page.tsx | 5 ++- .../src/app/(signing)/sign/[token]/page.tsx | 2 +- .../document-meta/upsert-document-meta.ts | 12 ++++--- .../document/duplicate-document-by-id.ts | 1 + packages/prisma/schema.prisma | 1 + .../trpc/server/document-router/router.ts | 24 ++++++++++++++ .../trpc/server/document-router/schema.ts | 5 +++ packages/ui/primitives/pdf-viewer.tsx | 32 ++++++++++++++++--- 9 files changed, 75 insertions(+), 13 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 a5dc9e23e..613146b99 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client'; +import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc } from '@documenso/trpc/react'; @@ -29,6 +29,7 @@ export type EditDocumentFormProps = { user: User; document: DocumentWithData; recipients: Recipient[]; + documentMeta: DocumentMeta | null; fields: Field[]; documentData: DocumentData; }; @@ -41,6 +42,7 @@ export const EditDocumentForm = ({ document, recipients, fields, + documentMeta, user: _user, documentData, }: EditDocumentFormProps) => { @@ -185,7 +187,7 @@ export const EditDocumentForm = ({ gradient > - + diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index b26b6308c..b19e1cf4b 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -13,6 +13,7 @@ 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 { DocumentStatus } from '~/components/formatter/document-status'; +import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id'; export type DocumentPageProps = { params: { @@ -41,6 +42,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) { } const { documentData } = document; + const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null); const [recipients, fields] = await Promise.all([ getRecipientsForDocument({ @@ -83,6 +85,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) { className="mt-8" document={document} user={user} + documentMeta={documentMeta} recipients={recipients} fields={fields} documentData={documentData} @@ -91,7 +94,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) { {document.status === InternalDocumentStatus.COMPLETED && (
- +
)} diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index efd0b266c..80d88ce40 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -101,7 +101,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp gradient > - + diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index 34c33e7cd..3c12bcb35 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -4,10 +4,11 @@ import { prisma } from '@documenso/prisma'; export type CreateDocumentMetaOptions = { documentId: number; - subject: string; - message: string; - timezone: string; - dateFormat: string; + subject?: string; + message?: string; + timezone?: string; + documentPassword?: string; + dateFormat?: string; userId: number; }; @@ -18,6 +19,7 @@ export const upsertDocumentMeta = async ({ dateFormat, documentId, userId, + documentPassword, }: CreateDocumentMetaOptions) => { await prisma.document.findFirstOrThrow({ where: { @@ -35,12 +37,14 @@ export const upsertDocumentMeta = async ({ message, dateFormat, timezone, + documentPassword, documentId, }, update: { subject, message, dateFormat, + documentPassword, timezone, }, }); diff --git a/packages/lib/server-only/document/duplicate-document-by-id.ts b/packages/lib/server-only/document/duplicate-document-by-id.ts index 5986b4cfe..6cdc5bc49 100644 --- a/packages/lib/server-only/document/duplicate-document-by-id.ts +++ b/packages/lib/server-only/document/duplicate-document-by-id.ts @@ -26,6 +26,7 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI message: true, subject: true, dateFormat: true, + documentPassword: true, timezone: true, }, }, diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f0bfc6fda..59a92f296 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -162,6 +162,7 @@ model DocumentMeta { subject String? message String? timezone String? @db.Text @default("Etc/UTC") + documentPassword String? dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a") documentId Int @unique document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index b4a1b60e3..717f8bed2 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -24,6 +24,7 @@ import { ZSearchDocumentsMutationSchema, ZSendDocumentMutationSchema, ZSetFieldsForDocumentMutationSchema, + ZSetPasswordForDocumentMutationSchema, ZSetRecipientsForDocumentMutationSchema, ZSetTitleForDocumentMutationSchema, } from './schema'; @@ -174,6 +175,29 @@ export const documentRouter = router({ }); } }), + + setDocumentPassword: authenticatedProcedure + .input(ZSetPasswordForDocumentMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, documentPassword } = input; + await upsertDocumentMeta({ + documentId, + documentPassword, + userId: ctx.user.id, + }); + + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to send this document. Please try again later.', + }); + } + }), + + sendDocument: authenticatedProcedure .input(ZSendDocumentMutationSchema) diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 4559f65f3..baccc6b85 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -73,6 +73,11 @@ export const ZSendDocumentMutationSchema = z.object({ }), }); +export const ZSetPasswordForDocumentMutationSchema = z.object({ + documentId: z.number(), + documentPassword: z.string(), +}); + export const ZResendDocumentMutationSchema = z.object({ documentId: z.number(), recipients: z.array(z.number()).min(1), diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx index be2d0cc4a..b109dca24 100644 --- a/packages/ui/primitives/pdf-viewer.tsx +++ b/packages/ui/primitives/pdf-viewer.tsx @@ -10,11 +10,13 @@ import 'react-pdf/dist/esm/Page/TextLayer.css'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { getFile } from '@documenso/lib/universal/upload/get-file'; -import type { DocumentData } from '@documenso/prisma/client'; +import type { DocumentData, DocumentMeta } from '@documenso/prisma/client'; import { cn } from '../lib/utils'; import { PasswordDialog } from './document-password-dialog'; import { useToast } from './use-toast'; +import { trpc } from '@documenso/trpc/react'; +import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; export type LoadedPDFDocument = PDFDocumentProxy; @@ -44,6 +46,8 @@ const PDFLoader = () => ( export type PDFViewerProps = { className?: string; documentData: DocumentData; + document?: DocumentWithData; + documentMeta?: DocumentMeta | null; onDocumentLoad?: (_doc: LoadedPDFDocument) => void; onPageClick?: OnPDFViewerPageClick; [key: string]: unknown; @@ -52,6 +56,8 @@ export type PDFViewerProps = { export const PDFViewer = ({ className, documentData, + document, + documentMeta, onDocumentLoad, onPageClick, ...props @@ -62,7 +68,7 @@ export const PDFViewer = ({ const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false); const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); - const [password, setPassword] = useState(null); + const [password, setPassword] = useState(documentMeta?.documentPassword || null); const passwordCallbackRef = useRef<((password: string | null) => void) | null>(null); const [isPasswordError, setIsPasswordError] = useState(false); const [documentBytes, setDocumentBytes] = useState(null); @@ -76,6 +82,9 @@ export const PDFViewer = ({ [documentData.data, documentData.type], ); + const {mutateAsync: addDocumentPassword } = trpc.document.setDocumentPassword.useMutation(); + + const isLoading = isDocumentBytesLoading || !documentBytes; const onDocumentLoaded = (doc: LoadedPDFDocument) => { @@ -83,12 +92,20 @@ export const PDFViewer = ({ onDocumentLoad?.(doc); }; - const onPasswordSubmit = () => { + const onPasswordSubmit = async() => { setIsPasswordModalOpen(false); - if (passwordCallbackRef.current) { - passwordCallbackRef.current(password); + try{ + await addDocumentPassword({ + documentId: document?.id ?? 0, + documentPassword: password!, + }); + passwordCallbackRef.current?.(password); + } catch (error) { + console.error('Error adding document password:', error); + } finally { passwordCallbackRef.current = null; } + }; const onDocumentPageClick = ( @@ -189,6 +206,11 @@ export const PDFViewer = ({ 'h-[80vh] max-h-[60rem]': numPages === 0, })} onPassword={(callback, reason) => { + // If the documentMeta already has a password, we don't need to ask for it again. + if(password && reason !== PasswordResponses.INCORRECT_PASSWORD){ + callback(password); + return; + } setIsPasswordModalOpen(true); passwordCallbackRef.current = callback; switch (reason) { From 7e71e06e04bc991856beccec3b11648a853ee40a Mon Sep 17 00:00:00 2001 From: hiteshwadhwani Date: Sat, 13 Jan 2024 14:19:37 +0530 Subject: [PATCH 09/19] fix: keyboard shortcut ctrl+k default behaviour fixed --- apps/web/src/components/(dashboard)/common/command-menu.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index 39cd9df0d..ffbd213a4 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -95,8 +95,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const currentPage = pages[pages.length - 1]; - const toggleOpen = (e: KeyboardEvent) => { - e.preventDefault(); + const toggleOpen = () => { setIsOpen((isOpen) => !isOpen); onOpenChange?.(!isOpen); @@ -136,7 +135,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]); const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]); - useHotkeys(['ctrl+k', 'meta+k'], toggleOpen); + useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true }); useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings); useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments); useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates); From 58b3a127eaf7416fb92f135c54f36959a45138c9 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 15 Jan 2024 05:18:55 +0530 Subject: [PATCH 10/19] chore: fix color for light mode icon (#806) --- apps/marketing/content/blog/pre-seed.mdx | 2 +- apps/marketing/content/blog/shop.mdx | 2 +- packages/ui/primitives/theme-switcher.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/marketing/content/blog/pre-seed.mdx b/apps/marketing/content/blog/pre-seed.mdx index fae0a6c4a..215700355 100644 --- a/apps/marketing/content/blog/pre-seed.mdx +++ b/apps/marketing/content/blog/pre-seed.mdx @@ -1,6 +1,6 @@ --- title: Announcing Pre-Seed and Open Metrics -description: We are exicited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso. +description: We are excited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso. authorName: 'Timur Ercan' authorImage: '/blog/blog-author-timur.jpeg' authorRole: 'Co-Founder' diff --git a/apps/marketing/content/blog/shop.mdx b/apps/marketing/content/blog/shop.mdx index fafd98a40..cb5b65554 100644 --- a/apps/marketing/content/blog/shop.mdx +++ b/apps/marketing/content/blog/shop.mdx @@ -30,7 +30,7 @@ We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania) ## Documenso Merch Shop -The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contrinuting to Documenso. +The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contributing to Documenso.
{ > {isMounted && theme === THEMES_TYPE.LIGHT && ( )} From a593e045b50b15851ed1c23943bb5ec9f24c3624 Mon Sep 17 00:00:00 2001 From: Gautam Hegde <85569489+Gautam-Hegde@users.noreply.github.com> Date: Tue, 16 Jan 2024 00:08:04 +0530 Subject: [PATCH 11/19] Update improvement.yml --- .github/ISSUE_TEMPLATE/improvement.yml | 54 ++++++++++++++------------ 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/improvement.yml b/.github/ISSUE_TEMPLATE/improvement.yml index 058a025e7..d73d46561 100644 --- a/.github/ISSUE_TEMPLATE/improvement.yml +++ b/.github/ISSUE_TEMPLATE/improvement.yml @@ -1,35 +1,39 @@ -name: 'General Improvement' +name: 'General Improvement Request' description: Suggest a minor enhancement or improvement for this project +title: '[Title for your improvement suggestion]' body: - - type: markdown - attributes: - value: Please provide a clear and concise title for your improvement suggestion - type: textarea attributes: - label: Improvement Description - description: Describe the improvement you are suggesting in detail. Explain what specific aspect of the project it addresses or enhances. + label: "Describe the improvement you are suggesting in detail" + description: "Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change." + validations: + required: true - type: textarea + id: description attributes: - label: Rationale - description: Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change. - - type: textarea + label: "Additional Information & Alternatives (optional)" + description: "Are there any additional context or information that might be relevant to the improvement suggestion." + validations: + required: false + - type: dropdown + id: assignee attributes: - label: Proposed Solution - description: If you have a suggestion for how this improvement could be implemented, describe it here. Include any technical details, design suggestions, or other relevant information. - - type: textarea - attributes: - label: Alternatives (optional) - description: Are there any alternative approaches to achieve the same improvement? Describe other ways to address the issue or enhance the project. - - type: textarea - attributes: - label: Additional Context - description: Add any additional context or information that might be relevant to the improvement suggestion. + label: "Do you want to work on this improvement?" + multiple: false + options: + - "No" + - "Yes" + default: 0 + validations: + required: true - type: checkboxes attributes: - label: Please check the boxes that apply to this improvement suggestion. + label: "Please check the boxes that apply to this improvement suggestion." options: - - label: I have searched the existing issues and improvement suggestions to avoid duplication. - - label: I have provided a clear description of the improvement being suggested. - - label: I have explained the rationale behind this improvement. - - label: I have included any relevant technical details or design suggestions. - - label: I understand that this is a suggestion and that there is no guarantee of implementation. + - label: "I have searched the existing issues and improvement suggestions to avoid duplication." + - label: "I have provided a clear description of the improvement being suggested." + - label: "I have explained the rationale behind this improvement." + - label: "I have included any relevant technical details or design suggestions." + - label: "I understand that this is a suggestion and that there is no guarantee of implementation." + validations: + required: true From 67aebaac1aa41e70695f805ac34654bf3d9013fb Mon Sep 17 00:00:00 2001 From: Gautam Hegde <85569489+Gautam-Hegde@users.noreply.github.com> Date: Tue, 16 Jan 2024 01:14:48 +0530 Subject: [PATCH 12/19] Update improvement.yml code quality --- .github/ISSUE_TEMPLATE/improvement.yml | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/improvement.yml b/.github/ISSUE_TEMPLATE/improvement.yml index d73d46561..de2983b67 100644 --- a/.github/ISSUE_TEMPLATE/improvement.yml +++ b/.github/ISSUE_TEMPLATE/improvement.yml @@ -1,39 +1,39 @@ name: 'General Improvement Request' -description: Suggest a minor enhancement or improvement for this project +description: 'Suggest a minor enhancement or improvement for this project' title: '[Title for your improvement suggestion]' body: - type: textarea attributes: - label: "Describe the improvement you are suggesting in detail" - description: "Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change." + label: 'Describe the improvement you are suggesting in detail' + description: 'Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change.' validations: required: true - type: textarea id: description attributes: - label: "Additional Information & Alternatives (optional)" - description: "Are there any additional context or information that might be relevant to the improvement suggestion." + label: 'Additional Information & Alternatives (optional)' + description: 'Are there any additional context or information that might be relevant to the improvement suggestion.' validations: required: false - type: dropdown id: assignee attributes: - label: "Do you want to work on this improvement?" + label: 'Do you want to work on this improvement?' multiple: false options: - - "No" - - "Yes" + - 'No' + - 'Yes' default: 0 validations: required: true - type: checkboxes attributes: - label: "Please check the boxes that apply to this improvement suggestion." + label: 'Please check the boxes that apply to this improvement suggestion.' options: - - label: "I have searched the existing issues and improvement suggestions to avoid duplication." - - label: "I have provided a clear description of the improvement being suggested." - - label: "I have explained the rationale behind this improvement." - - label: "I have included any relevant technical details or design suggestions." - - label: "I understand that this is a suggestion and that there is no guarantee of implementation." + - label: 'I have searched the existing issues and improvement suggestions to avoid duplication.' + - label: 'I have provided a clear description of the improvement being suggested.' + - label: 'I have explained the rationale behind this improvement.' + - label: 'I have included any relevant technical details or design suggestions.' + - label: 'I understand that this is a suggestion and that there is no guarantee of implementation.' validations: required: true From 1bc885478d05f39b58c34730bd39aa6df6fe7136 Mon Sep 17 00:00:00 2001 From: Fatuma Abdullahi <67555014+FatumaA@users.noreply.github.com> Date: Wed, 17 Jan 2024 03:10:28 +0300 Subject: [PATCH 13/19] fix: display the number of documents in mobile view (#837) This PR fixes #782. It now displays the document count on mobile view. --- apps/web/src/app/(dashboard)/documents/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index f38668fd9..8bb321377 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -88,7 +88,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage {value !== ExtendedDocumentStatus.ALL && ( - + {Math.min(stats[value], 99)} {stats[value] > 99 && '+'} From a94b829ee06e2759051376e7e4d2230af4968925 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 17 Jan 2024 17:17:08 +1100 Subject: [PATCH 14/19] fix: tidy code --- .../documents/[id]/edit-document.tsx | 17 +++- .../app/(dashboard)/documents/[id]/page.tsx | 11 ++- .../src/app/(signing)/sign/[token]/page.tsx | 7 +- package-lock.json | 3 +- .../document-meta/upsert-document-meta.ts | 8 +- .../document/duplicate-document-by-id.ts | 2 +- .../migration.sql | 2 + packages/prisma/schema.prisma | 2 +- .../trpc/server/document-router/router.ts | 42 ++++---- .../trpc/server/document-router/schema.ts | 6 +- packages/ui/package.json | 3 +- .../primitives/document-password-dialog.tsx | 98 +++++++++++++------ packages/ui/primitives/pdf-viewer.tsx | 68 +++++-------- 13 files changed, 161 insertions(+), 108 deletions(-) create mode 100644 packages/prisma/migrations/20240115031508_add_password_to_document_meta/migration.sql 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 613146b99..2159b87f2 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -58,6 +58,8 @@ export const EditDocumentForm = ({ const { mutateAsync: addFields } = trpc.field.addFields.useMutation(); const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation(); const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation(); + const { mutateAsync: setPasswordForDocument } = + trpc.document.setPasswordForDocument.useMutation(); const documentFlow: Record = { title: { @@ -178,6 +180,13 @@ export const EditDocumentForm = ({ } }; + const onPasswordSubmit = async (password: string) => { + await setPasswordForDocument({ + documentId: document.id, + password, + }); + }; + const currentDocumentFlow = documentFlow[step]; return ( @@ -187,7 +196,13 @@ export const EditDocumentForm = ({ gradient > - + diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index b19e1cf4b..4df8453da 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -13,7 +13,6 @@ 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 { DocumentStatus } from '~/components/formatter/document-status'; -import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id'; export type DocumentPageProps = { params: { @@ -41,8 +40,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) { redirect('/documents'); } - const { documentData } = document; - const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null); + const { documentData, documentMeta } = document; const [recipients, fields] = await Promise.all([ getRecipientsForDocument({ @@ -94,7 +92,12 @@ export default async function DocumentPage({ params }: DocumentPageProps) { {document.status === InternalDocumentStatus.COMPLETED && (
- +
)} diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 80d88ce40..f8b68d652 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -101,7 +101,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp gradient > - + diff --git a/package-lock.json b/package-lock.json index e3c1139f6..69825e8d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19869,7 +19869,8 @@ "react-rnd": "^10.4.1", "tailwind-merge": "^1.12.0", "tailwindcss-animate": "^1.0.5", - "ts-pattern": "^5.0.5" + "ts-pattern": "^5.0.5", + "zod": "^3.22.4" }, "devDependencies": { "@documenso/tailwind-config": "*", diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index 3c12bcb35..b67c6848b 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -7,7 +7,7 @@ export type CreateDocumentMetaOptions = { subject?: string; message?: string; timezone?: string; - documentPassword?: string; + password?: string; dateFormat?: string; userId: number; }; @@ -19,7 +19,7 @@ export const upsertDocumentMeta = async ({ dateFormat, documentId, userId, - documentPassword, + password, }: CreateDocumentMetaOptions) => { await prisma.document.findFirstOrThrow({ where: { @@ -37,14 +37,14 @@ export const upsertDocumentMeta = async ({ message, dateFormat, timezone, - documentPassword, + password, documentId, }, update: { subject, message, dateFormat, - documentPassword, + password, timezone, }, }); diff --git a/packages/lib/server-only/document/duplicate-document-by-id.ts b/packages/lib/server-only/document/duplicate-document-by-id.ts index 6cdc5bc49..ddb70b1cb 100644 --- a/packages/lib/server-only/document/duplicate-document-by-id.ts +++ b/packages/lib/server-only/document/duplicate-document-by-id.ts @@ -26,7 +26,7 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI message: true, subject: true, dateFormat: true, - documentPassword: true, + password: true, timezone: true, }, }, diff --git a/packages/prisma/migrations/20240115031508_add_password_to_document_meta/migration.sql b/packages/prisma/migrations/20240115031508_add_password_to_document_meta/migration.sql new file mode 100644 index 000000000..c2f5150bc --- /dev/null +++ b/packages/prisma/migrations/20240115031508_add_password_to_document_meta/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "DocumentMeta" ADD COLUMN "password" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 59a92f296..e1549e072 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -162,7 +162,7 @@ model DocumentMeta { subject String? message String? timezone String? @db.Text @default("Etc/UTC") - documentPassword String? + password String? dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a") documentId Int @unique document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 717f8bed2..bdc10a604 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -175,29 +175,27 @@ export const documentRouter = router({ }); } }), - - setDocumentPassword: authenticatedProcedure - .input(ZSetPasswordForDocumentMutationSchema) - .mutation(async ({ input, ctx }) => { - try { - const { documentId, documentPassword } = input; - await upsertDocumentMeta({ - documentId, - documentPassword, - userId: ctx.user.id, - }); - - } catch (err) { - console.error(err); - - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'We were unable to send this document. Please try again later.', - }); - } - }), - + setPasswordForDocument: authenticatedProcedure + .input(ZSetPasswordForDocumentMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, password } = input; + + await upsertDocumentMeta({ + documentId, + password, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to set the password for this document. Please try again later.', + }); + } + }), sendDocument: authenticatedProcedure .input(ZSendDocumentMutationSchema) diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index baccc6b85..c4389bdfb 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -75,9 +75,13 @@ export const ZSendDocumentMutationSchema = z.object({ export const ZSetPasswordForDocumentMutationSchema = z.object({ documentId: z.number(), - documentPassword: z.string(), + password: z.string(), }); +export type TSetPasswordForDocumentMutationSchema = z.infer< + typeof ZSetPasswordForDocumentMutationSchema +>; + export const ZResendDocumentMutationSchema = z.object({ documentId: z.number(), recipients: z.array(z.number()).min(1), diff --git a/packages/ui/package.json b/packages/ui/package.json index ce452091e..34675ba89 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -70,6 +70,7 @@ "react-rnd": "^10.4.1", "tailwind-merge": "^1.12.0", "tailwindcss-animate": "^1.0.5", - "ts-pattern": "^5.0.5" + "ts-pattern": "^5.0.5", + "zod": "^3.22.4" } } diff --git a/packages/ui/primitives/document-password-dialog.tsx b/packages/ui/primitives/document-password-dialog.tsx index 08a5de8f3..571c81716 100644 --- a/packages/ui/primitives/document-password-dialog.tsx +++ b/packages/ui/primitives/document-password-dialog.tsx @@ -1,55 +1,95 @@ -import React from 'react'; +import { useEffect } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; import { Button } from './button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from './dialog'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './dialog'; +import { Form, FormControl, FormField, FormItem, FormMessage } from './form/form'; import { Input } from './input'; +const ZPasswordDialogFormSchema = z.object({ + password: z.string(), +}); + +type TPasswordDialogFormSchema = z.infer; + type PasswordDialogProps = { open: boolean; onOpenChange: (_open: boolean) => void; - setPassword: (_password: string) => void; - onPasswordSubmit: () => void; + defaultPassword?: string; + onPasswordSubmit?: (password: string) => void; isError?: boolean; }; export const PasswordDialog = ({ open, onOpenChange, + defaultPassword, onPasswordSubmit, isError, - setPassword, }: PasswordDialogProps) => { + const form = useForm({ + defaultValues: { + password: defaultPassword ?? '', + }, + resolver: zodResolver(ZPasswordDialogFormSchema), + }); + + const onFormSubmit = ({ password }: TPasswordDialogFormSchema) => { + onPasswordSubmit?.(password); + }; + + useEffect(() => { + if (isError) { + form.setError('password', { + type: 'manual', + message: 'The password you have entered is incorrect. Please try again.', + }); + } + }, [form, isError]); + return ( - - + + Password Required + This document is password protected. Please enter the password to view the document. - - setPassword(e.target.value)} - autoComplete="off" - /> - - - {isError && ( - - The password you entered is incorrect. Please try again. - - )} + +
+ +
+ ( + + + + + + + + )} + /> + +
+ +
+
+
+
); diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx index b109dca24..b4e5c10ba 100644 --- a/packages/ui/primitives/pdf-viewer.tsx +++ b/packages/ui/primitives/pdf-viewer.tsx @@ -7,16 +7,16 @@ import { type PDFDocumentProxy, PasswordResponses } from 'pdfjs-dist'; import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; import 'react-pdf/dist/esm/Page/TextLayer.css'; +import { match } from 'ts-pattern'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { getFile } from '@documenso/lib/universal/upload/get-file'; -import type { DocumentData, DocumentMeta } from '@documenso/prisma/client'; +import type { DocumentData } from '@documenso/prisma/client'; +import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { cn } from '../lib/utils'; import { PasswordDialog } from './document-password-dialog'; import { useToast } from './use-toast'; -import { trpc } from '@documenso/trpc/react'; -import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; export type LoadedPDFDocument = PDFDocumentProxy; @@ -47,7 +47,8 @@ export type PDFViewerProps = { className?: string; documentData: DocumentData; document?: DocumentWithData; - documentMeta?: DocumentMeta | null; + password?: string | null; + onPasswordSubmit?: (password: string) => void | Promise; onDocumentLoad?: (_doc: LoadedPDFDocument) => void; onPageClick?: OnPDFViewerPageClick; [key: string]: unknown; @@ -56,8 +57,8 @@ export type PDFViewerProps = { export const PDFViewer = ({ className, documentData, - document, - documentMeta, + password: defaultPassword, + onPasswordSubmit, onDocumentLoad, onPageClick, ...props @@ -66,10 +67,10 @@ export const PDFViewer = ({ const $el = useRef(null); + const passwordCallbackRef = useRef<((password: string | null) => void) | null>(null); + const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false); const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); - const [password, setPassword] = useState(documentMeta?.documentPassword || null); - const passwordCallbackRef = useRef<((password: string | null) => void) | null>(null); const [isPasswordError, setIsPasswordError] = useState(false); const [documentBytes, setDocumentBytes] = useState(null); @@ -82,9 +83,6 @@ export const PDFViewer = ({ [documentData.data, documentData.type], ); - const {mutateAsync: addDocumentPassword } = trpc.document.setDocumentPassword.useMutation(); - - const isLoading = isDocumentBytesLoading || !documentBytes; const onDocumentLoaded = (doc: LoadedPDFDocument) => { @@ -92,22 +90,6 @@ export const PDFViewer = ({ onDocumentLoad?.(doc); }; - const onPasswordSubmit = async() => { - setIsPasswordModalOpen(false); - try{ - await addDocumentPassword({ - documentId: document?.id ?? 0, - documentPassword: password!, - }); - passwordCallbackRef.current?.(password); - } catch (error) { - console.error('Error adding document password:', error); - } finally { - passwordCallbackRef.current = null; - } - - }; - const onDocumentPageClick = ( event: React.MouseEvent, pageNumber: number, @@ -206,23 +188,19 @@ export const PDFViewer = ({ 'h-[80vh] max-h-[60rem]': numPages === 0, })} onPassword={(callback, reason) => { - // If the documentMeta already has a password, we don't need to ask for it again. - if(password && reason !== PasswordResponses.INCORRECT_PASSWORD){ - callback(password); + // If the document already has a password, we don't need to ask for it again. + if (defaultPassword && reason !== PasswordResponses.INCORRECT_PASSWORD) { + callback(defaultPassword); return; } + setIsPasswordModalOpen(true); + passwordCallbackRef.current = callback; - switch (reason) { - case PasswordResponses.NEED_PASSWORD: - setIsPasswordError(false); - break; - case PasswordResponses.INCORRECT_PASSWORD: - setIsPasswordError(true); - break; - default: - break; - } + + match(reason) + .with(PasswordResponses.NEED_PASSWORD, () => setIsPasswordError(false)) + .with(PasswordResponses.INCORRECT_PASSWORD, () => setIsPasswordError(true)); }} onLoadSuccess={(d) => onDocumentLoaded(d)} // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. @@ -270,12 +248,18 @@ export const PDFViewer = ({ ))} + { + passwordCallbackRef.current?.(password); + + setIsPasswordModalOpen(false); + + void onPasswordSubmit?.(password); + }} isError={isPasswordError} - setPassword={setPassword} /> )} From 91dd10ec9b5f47b11c47da1d1a81258a9529c8b3 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 17 Jan 2024 17:28:28 +1100 Subject: [PATCH 15/19] fix: add symmetric encryption to document passwords --- .../app/(dashboard)/documents/[id]/page.tsx | 19 +++++++++++++++++++ .../src/app/(signing)/sign/[token]/page.tsx | 19 +++++++++++++++++++ .../trpc/server/document-router/router.ts | 15 ++++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index 4df8453da..44f3991d8 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -3,10 +3,12 @@ import { redirect } from 'next/navigation'; import { ChevronLeft, Users2 } from 'lucide-react'; +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 { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; +import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; @@ -42,6 +44,23 @@ export default async function DocumentPage({ params }: DocumentPageProps) { const { documentData, documentMeta } = document; + if (documentMeta?.password) { + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY'); + } + + const securePassword = Buffer.from( + symmetricDecrypt({ + key, + data: documentMeta.password, + }), + ).toString('utf-8'); + + documentMeta.password = securePassword; + } + const [recipients, fields] = await Promise.all([ getRecipientsForDocument({ documentId, diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index f8b68d652..004c59329 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -2,6 +2,7 @@ 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'; @@ -12,6 +13,7 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum 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 { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; @@ -66,6 +68,23 @@ export default async function SigningPage({ params: { token } }: SigningPageProp redirect(`/sign/${token}/complete`); } + if (documentMeta?.password) { + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY'); + } + + const securePassword = Buffer.from( + symmetricDecrypt({ + key, + data: documentMeta.password, + }), + ).toString('utf-8'); + + documentMeta.password = securePassword; + } + const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id }); if (document.deletedAt) { diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index bdc10a604..9dba63797 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; 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'; @@ -13,6 +14,7 @@ import { sendDocument } from '@documenso/lib/server-only/document/send-document' import { updateTitle } from '@documenso/lib/server-only/document/update-title'; import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; +import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { @@ -182,9 +184,20 @@ export const documentRouter = router({ try { const { documentId, password } = input; + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('Missing encryption key'); + } + + const securePassword = symmetricEncrypt({ + data: password, + key, + }); + await upsertDocumentMeta({ documentId, - password, + password: securePassword, userId: ctx.user.id, }); } catch (err) { From 9ff44f10a6b26e68758b131ecd67a5468c1b346f Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 17 Jan 2024 21:41:00 +1100 Subject: [PATCH 16/19] chore: add incident blog post --- .../email-provider-incident-2024-01-10.mdx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 apps/marketing/content/blog/email-provider-incident-2024-01-10.mdx diff --git a/apps/marketing/content/blog/email-provider-incident-2024-01-10.mdx b/apps/marketing/content/blog/email-provider-incident-2024-01-10.mdx new file mode 100644 index 000000000..0f5279d6e --- /dev/null +++ b/apps/marketing/content/blog/email-provider-incident-2024-01-10.mdx @@ -0,0 +1,28 @@ +--- +title: Jan 10th Email Provider Security Incident +description: On January 10th, 2022, we were notified by our email provider that they had experienced a security incident. +authorName: 'Lucas Smith' +authorImage: '/blog/blog-author-lucas.png' +authorRole: 'Co-Founder' +date: 2024-01-17 +tags: + - Security +--- + +On January 10th, 2024 we were notified by our email provider that a security incident had occurred. This security incident which had started on January 7th led to a bad actor obtaining access to their database which contains ours and other customer’s data. + +We understand that during this security incident the following has been accessed: + +- Email addresses. +- Metadata on emails sent excluding the email body. + +While the incident is unfortunate we are pleased with the remediation and the processes that our email provider has put in place to help avoid this kind of situation in the future. Since the incident, our provider has rectified the issue and has engaged a security company to conduct an exhaustive investigation and to help improve their security posture moving forward. + +We remain steadfast in our commitment to our current email provider, and will not be taking any further action with relation to changing providers. + +We are now working with our legal counsel to ensure that we provide the appropriate notice to all our customers in each jurisdiction. If you have any further questions on this incident please feel free to contact our support team at [support@documenso.com](mailto:support@documenso.com). + +We appreciate your ongoing support in this matter. + +You can read more on the incident on our providers blog post below: +[https://resend.com/blog/incident-report-for-january-10-2024](https://resend.com/blog/incident-report-for-january-10-2024) From 6f726565e8ad5e8800052114d145b1a36aff0860 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 17 Jan 2024 14:36:28 +0100 Subject: [PATCH 17/19] Update README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We are nominated for a Product Hunt Gold Kitty 😺✨ and appreciate any support: https://documen.so/kitty --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 39cbb4332..6d2fab334 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +>We are nominated for a Product Hunt Gold Kitty 😺✨ and appreciate any support: https://documen.so/kitty + Documenso Logo

From 0d15b80c2d6f001b01ec500c5433c5bd03b7ed17 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 18 Jan 2024 04:23:22 +0000 Subject: [PATCH 18/19] fix: simplify code --- packages/lib/client-only/download-pdf.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts index e095002ee..ec7d0c252 100644 --- a/packages/lib/client-only/download-pdf.ts +++ b/packages/lib/client-only/download-pdf.ts @@ -15,10 +15,13 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps) }); const link = window.document.createElement('a'); - const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName; + + const [baseTitle] = fileName?.includes('.pdf') + ? fileName.split('.pdf') + : [fileName ?? 'document']; link.href = window.URL.createObjectURL(blob); - link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; + link.download = `${baseTitle}_signed.pdf`; link.click(); From 204388888d30da894bd00f4089c7eddc1263f2ae Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Thu, 18 Jan 2024 09:38:42 +0200 Subject: [PATCH 19/19] fix: fix bug for completed document shortcut (#839) When you're in the `/documents` page in the dashboard, if you hover over a draft and a completed document, you'll see different URLs. At the moment, the shortcut tries to go to the following URL for a completed document `/documents/{doc-id}`. However, that's the wrong URL, since the URL for a completed doc is `/sign/{token}` when the user is the recipient, not the one that sent the document for signing. If it's the document owner & the document is completed, the URL is fine as `/documents/{doc-id}`. --------- Co-authored-by: Lucas Smith --- .../(dashboard)/common/command-menu.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index ffbd213a4..93f7fa729 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -5,6 +5,7 @@ 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'; @@ -13,6 +14,7 @@ import { SETTINGS_PAGE_SHORTCUT, TEMPLATES_PAGE_SHORTCUT, } from '@documenso/lib/constants/keyboard-shortcuts'; +import type { Document, Recipient } from '@documenso/prisma/client'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { CommandDialog, @@ -65,6 +67,8 @@ export type CommandMenuProps = { export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const { setTheme } = useTheme(); + const { data: session } = useSession(); + const router = useRouter(); const [isOpen, setIsOpen] = useState(() => open ?? false); @@ -81,6 +85,17 @@ 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 []; @@ -88,10 +103,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { return searchDocumentsData.map((document) => ({ label: document.title, - path: `/documents/${document.id}`, + path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient), value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '), })); - }, [searchDocumentsData]); + }, [searchDocumentsData, isOwner, getSigningLink]); const currentPage = pages[pages.length - 1];