Compare commits

...

22 Commits

Author SHA1 Message Date
9ff44f10a6 chore: add incident blog post 2024-01-17 21:41:00 +11:00
16d97783f2 feat: improve the UX for password protected documents (#780) 2024-01-17 19:32:42 +11:00
91dd10ec9b fix: add symmetric encryption to document passwords 2024-01-17 17:28:28 +11:00
a94b829ee0 fix: tidy code 2024-01-17 17:17:08 +11:00
1bc885478d fix: display the number of documents in mobile view (#837)
This PR fixes #782.
It now displays the document count on mobile view.
2024-01-17 11:10:28 +11:00
560352492d fix: keyboard shortcut ctrl+k fixed (#830) 2024-01-16 13:07:42 +11:00
84b0c2756b fix: fixed the deleting signature block issue on touchscreens (#809)
Fixed the deleting signature block issue on touchscreens, for some
reason the `onClick` event isn't working on the touchscreens that's why
I've added `onTouchEnd` event to delete the signature block when the
user clicks on it.
2024-01-15 19:33:40 +11:00
58b3a127ea chore: fix color for light mode icon (#806) 2024-01-15 10:48:55 +11:00
7e71e06e04 fix: keyboard shortcut ctrl+k default behaviour fixed 2024-01-13 14:19:37 +05:30
68953d1253 feat add documentPassword to documenet meta and improve the ux
Signed-off-by: harkiratsm <multaniharry714@gmail.com>
2024-01-12 20:54:59 +05:30
d73ef57794 Merge branch 'main' into fix/bug-798-signatures-block 2024-01-11 16:50:43 +08:00
eeb6a072aa Merge branch 'main' into harkirat/Protect 2024-01-10 10:45:19 +05:30
b09071ebc7 feat: jump to next field (#805)
When the fields are not filled, the button will say "Next field". Clicking on the button takes you to
the unfilled field.
2024-01-10 15:27:18 +11:00
66bb56047a chore: update roadmap links 2024-01-09 14:32:49 +01:00
3054d84ba7 chore: implemented feedback 2024-01-08 09:58:34 +02:00
a71078cbd5 fix: fixed the deleting signature block issue on touchscreens 2024-01-08 13:17:30 +08:00
4fd6a0d5b6 chore: update onOpenChange 2024-01-05 13:06:16 +02:00
fface15a22 feat: jump to next field 2024-01-05 12:56:07 +02:00
d8cbe1d5ba Merge branch 'main' into harkirat/Protect 2024-01-03 11:34:42 +05:30
53c570151f fix lint, description of dialog
Signed-off-by: harkiratsm <multaniharry714@gmail.com>
2023-12-29 22:11:44 +05:30
72a7dc6c05 fix the console error
Signed-off-by: harkiratsm <multaniharry714@gmail.com>
2023-12-29 17:26:33 +05:30
2ae9e29903 feat: improve the ux for password protected documents
Signed-off-by: harkiratsm <multaniharry714@gmail.com>
2023-12-22 17:24:05 +05:30
23 changed files with 364 additions and 70 deletions

View File

@ -13,9 +13,9 @@
· ·
<a href="https://github.com/documenso/documenso/issues">Issues</a> <a href="https://github.com/documenso/documenso/issues">Issues</a>
· ·
<a href="https://github.com/documenso/documenso/milestones">Roadmap</a> <a href="https://documen.so/live">Upcoming Releases</a>
· ·
<a href="https://documen.so/launches">Upcoming Launches</a> <a href="https://documen.so/roadmap">Roadmap</a>
</p> </p>
</p> </p>

View File

@ -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 customers 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)

View File

@ -1,6 +1,6 @@
--- ---
title: Announcing Pre-Seed and Open Metrics 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' authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg' authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder' authorRole: 'Co-Founder'

View File

@ -30,7 +30,7 @@ We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania)
## Documenso Merch Shop ## 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.
<figure> <figure>
<MdxNextImage <MdxNextImage

View File

@ -4,7 +4,7 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation'; 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 { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -29,6 +29,7 @@ export type EditDocumentFormProps = {
user: User; user: User;
document: DocumentWithData; document: DocumentWithData;
recipients: Recipient[]; recipients: Recipient[];
documentMeta: DocumentMeta | null;
fields: Field[]; fields: Field[];
documentData: DocumentData; documentData: DocumentData;
}; };
@ -41,6 +42,7 @@ export const EditDocumentForm = ({
document, document,
recipients, recipients,
fields, fields,
documentMeta,
user: _user, user: _user,
documentData, documentData,
}: EditDocumentFormProps) => { }: EditDocumentFormProps) => {
@ -56,6 +58,8 @@ export const EditDocumentForm = ({
const { mutateAsync: addFields } = trpc.field.addFields.useMutation(); const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation(); const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation(); const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
const { mutateAsync: setPasswordForDocument } =
trpc.document.setPasswordForDocument.useMutation();
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = { const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
title: { title: {
@ -176,6 +180,13 @@ export const EditDocumentForm = ({
} }
}; };
const onPasswordSubmit = async (password: string) => {
await setPasswordForDocument({
documentId: document.id,
password,
});
};
const currentDocumentFlow = documentFlow[step]; const currentDocumentFlow = documentFlow[step];
return ( return (
@ -185,7 +196,13 @@ export const EditDocumentForm = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer key={documentData.id} documentData={documentData} /> <LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
onPasswordSubmit={onPasswordSubmit}
/>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -3,10 +3,12 @@ import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react'; 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 { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; 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 { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-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 { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@ -40,7 +42,24 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
redirect('/documents'); redirect('/documents');
} }
const { documentData } = document; 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([ const [recipients, fields] = await Promise.all([
getRecipientsForDocument({ getRecipientsForDocument({
@ -83,6 +102,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
className="mt-8" className="mt-8"
document={document} document={document}
user={user} user={user}
documentMeta={documentMeta}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
documentData={documentData} documentData={documentData}
@ -91,7 +111,12 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
{document.status === InternalDocumentStatus.COMPLETED && ( {document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl"> <div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer key={documentData.id} documentData={documentData} /> <LazyPDFViewer
document={document}
key={documentData.id}
documentMeta={documentMeta}
documentData={documentData}
/>
</div> </div>
)} )}
</div> </div>

View File

@ -88,7 +88,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
<DocumentStatus status={value} /> <DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && ( {value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 hidden opacity-50 md:inline-block"> <span className="ml-1 inline-block opacity-50">
{Math.min(stats[value], 99)} {Math.min(stats[value], 99)}
{stats[value] > 99 && '+'} {stats[value] > 99 && '+'}
</span> </span>

View File

@ -49,6 +49,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
return sortFieldsByPosition(fields.filter((field) => !field.inserted)); return sortFieldsByPosition(fields.filter((field) => !field.inserted));
}, [fields]); }, [fields]);
const fieldsValidated = () => {
setValidateUninsertedFields(true);
validateFieldsInserted(fields);
};
const onFormSubmit = async () => { const onFormSubmit = async () => {
setValidateUninsertedFields(true); setValidateUninsertedFields(true);
@ -154,6 +159,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
onSignatureComplete={handleSubmit(onFormSubmit)} onSignatureComplete={handleSubmit(onFormSubmit)}
document={document} document={document}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated}
/> />
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern'; 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 { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; 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 { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-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 { 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 { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@ -66,6 +68,23 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
redirect(`/sign/${token}/complete`); 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 }); const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
if (document.deletedAt) { if (document.deletedAt) {
@ -101,7 +120,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer key={documentData.id} documentData={documentData} /> <LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
/>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -15,6 +15,7 @@ export type SignDialogProps = {
isSubmitting: boolean; isSubmitting: boolean;
document: Document; document: Document;
fields: Field[]; fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>; onSignatureComplete: () => void | Promise<void>;
}; };
@ -22,6 +23,7 @@ export const SignDialog = ({
isSubmitting, isSubmitting,
document, document,
fields, fields,
fieldsValidated,
onSignatureComplete, onSignatureComplete,
}: SignDialogProps) => { }: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
@ -29,16 +31,16 @@ export const SignDialog = ({
const isComplete = fields.every((field) => field.inserted); const isComplete = fields.every((field) => field.inserted);
return ( return (
<Dialog open={showDialog} onOpenChange={setShowDialog}> <Dialog open={showDialog && isComplete} onOpenChange={setShowDialog}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
className="w-full" className="w-full"
type="button" type="button"
size="lg" size="lg"
disabled={!isComplete} onClick={fieldsValidated}
loading={isSubmitting} loading={isSubmitting}
> >
Complete {isComplete ? 'Complete' : 'Next field'}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>

View File

@ -95,8 +95,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const currentPage = pages[pages.length - 1]; const currentPage = pages[pages.length - 1];
const toggleOpen = (e: KeyboardEvent) => { const toggleOpen = () => {
e.preventDefault();
setIsOpen((isOpen) => !isOpen); setIsOpen((isOpen) => !isOpen);
onOpenChange?.(!isOpen); onOpenChange?.(!isOpen);
@ -136,7 +135,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]); const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
const goToTemplates = useCallback(() => push(TEMPLATES_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(SETTINGS_PAGE_SHORTCUT, goToSettings);
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments); useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates); useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates);

3
package-lock.json generated
View File

@ -19869,7 +19869,8 @@
"react-rnd": "^10.4.1", "react-rnd": "^10.4.1",
"tailwind-merge": "^1.12.0", "tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5", "tailwindcss-animate": "^1.0.5",
"ts-pattern": "^5.0.5" "ts-pattern": "^5.0.5",
"zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@documenso/tailwind-config": "*", "@documenso/tailwind-config": "*",

View File

@ -4,10 +4,11 @@ import { prisma } from '@documenso/prisma';
export type CreateDocumentMetaOptions = { export type CreateDocumentMetaOptions = {
documentId: number; documentId: number;
subject: string; subject?: string;
message: string; message?: string;
timezone: string; timezone?: string;
dateFormat: string; password?: string;
dateFormat?: string;
userId: number; userId: number;
}; };
@ -18,6 +19,7 @@ export const upsertDocumentMeta = async ({
dateFormat, dateFormat,
documentId, documentId,
userId, userId,
password,
}: CreateDocumentMetaOptions) => { }: CreateDocumentMetaOptions) => {
await prisma.document.findFirstOrThrow({ await prisma.document.findFirstOrThrow({
where: { where: {
@ -35,12 +37,14 @@ export const upsertDocumentMeta = async ({
message, message,
dateFormat, dateFormat,
timezone, timezone,
password,
documentId, documentId,
}, },
update: { update: {
subject, subject,
message, message,
dateFormat, dateFormat,
password,
timezone, timezone,
}, },
}); });

View File

@ -26,6 +26,7 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI
message: true, message: true,
subject: true, subject: true,
dateFormat: true, dateFormat: true,
password: true,
timezone: true, timezone: true,
}, },
}, },

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "password" TEXT;

View File

@ -162,6 +162,7 @@ model DocumentMeta {
subject String? subject String?
message String? message String?
timezone String? @db.Text @default("Etc/UTC") timezone String? @db.Text @default("Etc/UTC")
password String?
dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a") dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a")
documentId Int @unique documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)

View File

@ -1,6 +1,7 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/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 { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { deleteDocument } from '@documenso/lib/server-only/document/delete-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 { updateTitle } from '@documenso/lib/server-only/document/update-title';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; 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 { 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 { authenticatedProcedure, procedure, router } from '../trpc';
import { import {
@ -24,6 +26,7 @@ import {
ZSearchDocumentsMutationSchema, ZSearchDocumentsMutationSchema,
ZSendDocumentMutationSchema, ZSendDocumentMutationSchema,
ZSetFieldsForDocumentMutationSchema, ZSetFieldsForDocumentMutationSchema,
ZSetPasswordForDocumentMutationSchema,
ZSetRecipientsForDocumentMutationSchema, ZSetRecipientsForDocumentMutationSchema,
ZSetTitleForDocumentMutationSchema, ZSetTitleForDocumentMutationSchema,
} from './schema'; } from './schema';
@ -175,6 +178,38 @@ export const documentRouter = router({
} }
}), }),
setPasswordForDocument: authenticatedProcedure
.input(ZSetPasswordForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
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: securePassword,
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 sendDocument: authenticatedProcedure
.input(ZSendDocumentMutationSchema) .input(ZSendDocumentMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {

View File

@ -73,6 +73,15 @@ export const ZSendDocumentMutationSchema = z.object({
}), }),
}); });
export const ZSetPasswordForDocumentMutationSchema = z.object({
documentId: z.number(),
password: z.string(),
});
export type TSetPasswordForDocumentMutationSchema = z.infer<
typeof ZSetPasswordForDocumentMutationSchema
>;
export const ZResendDocumentMutationSchema = z.object({ export const ZResendDocumentMutationSchema = z.object({
documentId: z.number(), documentId: z.number(),
recipients: z.array(z.number()).min(1), recipients: z.array(z.number()).min(1),

View File

@ -70,6 +70,7 @@
"react-rnd": "^10.4.1", "react-rnd": "^10.4.1",
"tailwind-merge": "^1.12.0", "tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5", "tailwindcss-animate": "^1.0.5",
"ts-pattern": "^5.0.5" "ts-pattern": "^5.0.5",
"zod": "^3.22.4"
} }
} }

View File

@ -121,6 +121,7 @@ export const FieldItem = ({
<button <button
className="text-muted-foreground/50 hover:text-muted-foreground/80 bg-background absolute -right-2 -top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full border" className="text-muted-foreground/50 hover:text-muted-foreground/80 bg-background absolute -right-2 -top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full border"
onClick={() => onRemove?.()} onClick={() => onRemove?.()}
onTouchEnd={() => onRemove?.()}
> >
<Trash className="h-4 w-4" /> <Trash className="h-4 w-4" />
</button> </button>

View File

@ -0,0 +1,96 @@
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, 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<typeof ZPasswordDialogFormSchema>;
type PasswordDialogProps = {
open: boolean;
onOpenChange: (_open: boolean) => void;
defaultPassword?: string;
onPasswordSubmit?: (password: string) => void;
isError?: boolean;
};
export const PasswordDialog = ({
open,
onOpenChange,
defaultPassword,
onPasswordSubmit,
isError,
}: PasswordDialogProps) => {
const form = useForm<TPasswordDialogFormSchema>({
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 (
<Dialog open={open}>
<DialogContent className="w-full max-w-md">
<DialogHeader>
<DialogTitle>Password Required</DialogTitle>
<DialogDescription className="text-muted-foreground">
This document is password protected. Please enter the password to view the document.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex flex-wrap items-start justify-between gap-4">
<FormField
name="password"
control={form.control}
render={({ field }) => (
<FormItem className="relative flex-1">
<FormControl>
<Input
type="password"
className="bg-background"
placeholder="Enter password"
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button>Submit</Button>
</div>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -3,16 +3,19 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import type { PDFDocumentProxy } from 'pdfjs-dist'; import { type PDFDocumentProxy, PasswordResponses } from 'pdfjs-dist';
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; 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/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.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 { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import type { DocumentData } 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 { cn } from '../lib/utils';
import { PasswordDialog } from './document-password-dialog';
import { useToast } from './use-toast'; import { useToast } from './use-toast';
export type LoadedPDFDocument = PDFDocumentProxy; export type LoadedPDFDocument = PDFDocumentProxy;
@ -43,6 +46,9 @@ const PDFLoader = () => (
export type PDFViewerProps = { export type PDFViewerProps = {
className?: string; className?: string;
documentData: DocumentData; documentData: DocumentData;
document?: DocumentWithData;
password?: string | null;
onPasswordSubmit?: (password: string) => void | Promise<void>;
onDocumentLoad?: (_doc: LoadedPDFDocument) => void; onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
onPageClick?: OnPDFViewerPageClick; onPageClick?: OnPDFViewerPageClick;
[key: string]: unknown; [key: string]: unknown;
@ -51,6 +57,8 @@ export type PDFViewerProps = {
export const PDFViewer = ({ export const PDFViewer = ({
className, className,
documentData, documentData,
password: defaultPassword,
onPasswordSubmit,
onDocumentLoad, onDocumentLoad,
onPageClick, onPageClick,
...props ...props
@ -59,7 +67,11 @@ export const PDFViewer = ({
const $el = useRef<HTMLDivElement>(null); const $el = useRef<HTMLDivElement>(null);
const passwordCallbackRef = useRef<((password: string | null) => void) | null>(null);
const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false); const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false);
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
const [isPasswordError, setIsPasswordError] = useState(false);
const [documentBytes, setDocumentBytes] = useState<Uint8Array | null>(null); const [documentBytes, setDocumentBytes] = useState<Uint8Array | null>(null);
const [width, setWidth] = useState(0); const [width, setWidth] = useState(0);
@ -169,57 +181,87 @@ export const PDFViewer = ({
<PDFLoader /> <PDFLoader />
</div> </div>
) : ( ) : (
<PDFDocument <>
file={documentBytes.buffer} <PDFDocument
className={cn('w-full overflow-hidden rounded', { file={documentBytes.buffer}
'h-[80vh] max-h-[60rem]': numPages === 0, className={cn('w-full overflow-hidden rounded', {
})} 'h-[80vh] max-h-[60rem]': numPages === 0,
onLoadSuccess={(d) => onDocumentLoaded(d)} })}
// Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. onPassword={(callback, reason) => {
// Therefore we add some additional custom error handling. // If the document already has a password, we don't need to ask for it again.
onSourceError={() => { if (defaultPassword && reason !== PasswordResponses.INCORRECT_PASSWORD) {
setPdfError(true); callback(defaultPassword);
}} return;
externalLinkTarget="_blank" }
loading={
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50"> setIsPasswordModalOpen(true);
{pdfError ? (
passwordCallbackRef.current = callback;
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.
// Therefore we add some additional custom error handling.
onSourceError={() => {
setPdfError(true);
}}
externalLinkTarget="_blank"
loading={
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
{pdfError ? (
<div className="text-muted-foreground text-center">
<p>Something went wrong while loading the document.</p>
<p className="mt-1 text-sm">Please try again or contact our support.</p>
</div>
) : (
<PDFLoader />
)}
</div>
}
error={
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
<div className="text-muted-foreground text-center"> <div className="text-muted-foreground text-center">
<p>Something went wrong while loading the document.</p> <p>Something went wrong while loading the document.</p>
<p className="mt-1 text-sm">Please try again or contact our support.</p> <p className="mt-1 text-sm">Please try again or contact our support.</p>
</div> </div>
) : (
<PDFLoader />
)}
</div>
}
error={
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
<div className="text-muted-foreground text-center">
<p>Something went wrong while loading the document.</p>
<p className="mt-1 text-sm">Please try again or contact our support.</p>
</div> </div>
</div> }
} >
> {Array(numPages)
{Array(numPages) .fill(null)
.fill(null) .map((_, i) => (
.map((_, i) => ( <div
<div key={i}
key={i} className="border-border my-8 overflow-hidden rounded border will-change-transform first:mt-0 last:mb-0"
className="border-border my-8 overflow-hidden rounded border will-change-transform first:mt-0 last:mb-0" >
> <PDFPage
<PDFPage pageNumber={i + 1}
pageNumber={i + 1} width={width}
width={width} renderAnnotationLayer={false}
renderAnnotationLayer={false} renderTextLayer={false}
renderTextLayer={false} loading={() => ''}
loading={() => ''} onClick={(e) => onDocumentPageClick(e, i + 1)}
onClick={(e) => onDocumentPageClick(e, i + 1)} />
/> </div>
</div> ))}
))} </PDFDocument>
</PDFDocument>
<PasswordDialog
open={isPasswordModalOpen}
onOpenChange={setIsPasswordModalOpen}
onPasswordSubmit={(password) => {
passwordCallbackRef.current?.(password);
setIsPasswordModalOpen(false);
void onPasswordSubmit?.(password);
}}
isError={isPasswordError}
/>
</>
)} )}
</div> </div>
); );

View File

@ -18,7 +18,7 @@ export const ThemeSwitcher = () => {
> >
{isMounted && theme === THEMES_TYPE.LIGHT && ( {isMounted && theme === THEMES_TYPE.LIGHT && (
<motion.div <motion.div
className="bg-background absolute inset-0 rounded-full mix-blend-exclusion" className="bg-background absolute inset-0 rounded-full mix-blend-color-burn"
layoutId="selected-theme" layoutId="selected-theme"
/> />
)} )}