Merge branch 'main' into feature/linear-gh

This commit is contained in:
Timur Ercan
2024-01-18 17:17:01 +01:00
committed by GitHub
26 changed files with 469 additions and 165 deletions

View File

@ -1,35 +1,39 @@
name: 'General Improvement' 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: body:
- type: markdown
attributes:
value: Please provide a clear and concise title for your improvement suggestion
- type: textarea - type: textarea
attributes: attributes:
label: Improvement Description label: 'Describe the improvement you are suggesting in detail'
description: Describe the improvement you are suggesting in detail. Explain what specific aspect of the project it addresses or enhances. description: 'Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change.'
validations:
required: true
- type: textarea - type: textarea
id: description
attributes: attributes:
label: Rationale label: 'Additional Information & Alternatives (optional)'
description: Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change. description: 'Are there any additional context or information that might be relevant to the improvement suggestion.'
- type: textarea validations:
required: false
- type: dropdown
id: assignee
attributes: attributes:
label: Proposed Solution label: 'Do you want to work on this improvement?'
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. multiple: false
- type: textarea options:
attributes: - 'No'
label: Alternatives (optional) - 'Yes'
description: Are there any alternative approaches to achieve the same improvement? Describe other ways to address the issue or enhance the project. default: 0
- type: textarea validations:
attributes: required: true
label: Additional Context
description: Add any additional context or information that might be relevant to the improvement suggestion.
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Please check the boxes that apply to this improvement suggestion. label: 'Please check the boxes that apply to this improvement suggestion.'
options: options:
- label: I have searched the existing issues and improvement suggestions to avoid duplication. - 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 provided a clear description of the improvement being suggested.'
- label: I have explained the rationale behind this improvement. - label: 'I have explained the rationale behind this improvement.'
- label: I have included any relevant technical details or design suggestions. - 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 understand that this is a suggestion and that there is no guarantee of implementation.'
validations:
required: true

View File

@ -1,3 +1,5 @@
>We are nominated for a Product Hunt Gold Kitty 😺✨ and appreciate any support: https://documen.so/kitty
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo"> <img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
<p align="center" style="margin-top: 20px"> <p align="center" style="margin-top: 20px">

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

@ -6,7 +6,7 @@ import { Download, Edit, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import type { Document, Recipient, User } from '@documenso/prisma/client'; import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
@ -55,28 +55,14 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const documentData = document?.documentData; const documentData = document?.documentData;
if (!documentData) { if (!documentData) {
return; throw Error('No document available');
} }
const documentBytes = await getFile(documentData); await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
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);
} catch (error) {
toast({ toast({
title: 'Something went wrong', title: 'Something went wrong',
description: 'An error occurred while trying to download file.', description: 'An error occurred while downloading your document.',
variant: 'destructive', variant: 'destructive',
}); });
} }

View File

@ -17,7 +17,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import type { Document, Recipient, User } from '@documenso/prisma/client'; import type { Document, 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';
@ -30,6 +30,7 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu'; } from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ResendDocumentActionItem } from './_action-items/resend-document'; import { ResendDocumentActionItem } from './_action-items/resend-document';
import { DeleteDocumentDialog } from './delete-document-dialog'; import { DeleteDocumentDialog } from './delete-document-dialog';
@ -44,6 +45,7 @@ export type DataTableActionDropdownProps = {
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
const { toast } = useToast();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
@ -63,39 +65,33 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
const isDocumentDeletable = isOwner; const isDocumentDeletable = isOwner;
const onDownloadClick = async () => { const onDownloadClick = async () => {
let document: DocumentWithData | null = null; try {
let document: DocumentWithData | null = null;
if (!recipient) { if (!recipient) {
document = await trpcClient.document.getDocumentById.query({ document = await trpcClient.document.getDocumentById.query({
id: row.id, id: row.id,
}); });
} else { } else {
document = await trpcClient.document.getDocumentByToken.query({ document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token, 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;
}
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);
}; };
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');

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

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

@ -5,6 +5,7 @@ import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Loader, Monitor, Moon, Sun } from 'lucide-react'; import { Loader, Monitor, Moon, Sun } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
@ -13,6 +14,7 @@ import {
SETTINGS_PAGE_SHORTCUT, SETTINGS_PAGE_SHORTCUT,
TEMPLATES_PAGE_SHORTCUT, TEMPLATES_PAGE_SHORTCUT,
} from '@documenso/lib/constants/keyboard-shortcuts'; } from '@documenso/lib/constants/keyboard-shortcuts';
import type { Document, Recipient } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { import {
CommandDialog, CommandDialog,
@ -65,6 +67,8 @@ export type CommandMenuProps = {
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const { setTheme } = useTheme(); const { setTheme } = useTheme();
const { data: session } = useSession();
const router = useRouter(); const router = useRouter();
const [isOpen, setIsOpen] = useState(() => open ?? false); 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(() => { const searchResults = useMemo(() => {
if (!searchDocumentsData) { if (!searchDocumentsData) {
return []; return [];
@ -88,15 +103,14 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
return searchDocumentsData.map((document) => ({ return searchDocumentsData.map((document) => ({
label: document.title, 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(' '), value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
})); }));
}, [searchDocumentsData]); }, [searchDocumentsData, isOwner, getSigningLink]);
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 +150,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

@ -0,0 +1,29 @@
import type { DocumentData } from '@documenso/prisma/client';
import { getFile } from '../universal/upload/get-file';
type DownloadPDFProps = {
documentData: DocumentData;
fileName?: string;
};
export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps) => {
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')
: [fileName ?? 'document'];
link.href = window.URL.createObjectURL(blob);
link.download = `${baseTitle}_signed.pdf`;
link.click();
window.URL.revokeObjectURL(link.href);
};

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

@ -5,11 +5,11 @@ import { useState } from 'react';
import { Download } from 'lucide-react'; import { Download } from 'lucide-react';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import type { DocumentData } from '@documenso/prisma/client'; import type { DocumentData } from '@documenso/prisma/client';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Button } from '../../primitives/button'; import { Button } from '../../primitives/button';
import { useToast } from '../../primitives/use-toast';
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & { export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
disabled?: boolean; disabled?: boolean;
@ -24,43 +24,29 @@ export const DocumentDownloadButton = ({
disabled, disabled,
...props ...props
}: DownloadButtonProps) => { }: DownloadButtonProps) => {
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const onDownloadClick = async () => { const onDownloadClick = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
if (!documentData) { if (!documentData) {
setIsLoading(false);
return; return;
} }
const bytes = await getFile(documentData); await downloadPDF({ documentData, fileName }).then(() => {
setIsLoading(false);
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) { } catch (err) {
console.error(err); setIsLoading(false);
toast({ toast({
title: 'Error', title: 'Something went wrong',
description: 'An error occurred while downloading your document.', description: 'An error occurred while downloading your document.',
variant: 'destructive', variant: 'destructive',
}); });
} finally {
setIsLoading(false);
} }
}; };

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"
/> />
)} )}