From 2ae9e2990320b6b7ed008923d59978826a3ce25c Mon Sep 17 00:00:00 2001 From: harkiratsm Date: Fri, 22 Dec 2023 17:24:05 +0530 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 68953d1253b8c688988bbef0d6e256b3d01dee47 Mon Sep 17 00:00:00 2001 From: harkiratsm Date: Fri, 12 Jan 2024 20:54:59 +0530 Subject: [PATCH 4/6] 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 a94b829ee06e2759051376e7e4d2230af4968925 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 17 Jan 2024 17:17:08 +1100 Subject: [PATCH 5/6] 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 6/6] 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) {