mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
22 Commits
ElTimuro-p
...
v1.3.1-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ff44f10a6 | |||
| 16d97783f2 | |||
| 91dd10ec9b | |||
| a94b829ee0 | |||
| 1bc885478d | |||
| 560352492d | |||
| 84b0c2756b | |||
| 58b3a127ea | |||
| 7e71e06e04 | |||
| 68953d1253 | |||
| d73ef57794 | |||
| eeb6a072aa | |||
| b09071ebc7 | |||
| 66bb56047a | |||
| 3054d84ba7 | |||
| a71078cbd5 | |||
| 4fd6a0d5b6 | |||
| fface15a22 | |||
| d8cbe1d5ba | |||
| 53c570151f | |||
| 72a7dc6c05 | |||
| 2ae9e29903 |
@ -13,9 +13,9 @@
|
||||
·
|
||||
<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>
|
||||
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
---
|
||||
title: Jan 10th Email Provider Security Incident
|
||||
description: On January 10th, 2022, we were notified by our email provider that they had experienced a security incident.
|
||||
authorName: 'Lucas Smith'
|
||||
authorImage: '/blog/blog-author-lucas.png'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-01-17
|
||||
tags:
|
||||
- Security
|
||||
---
|
||||
|
||||
On January 10th, 2024 we were notified by our email provider that a security incident had occurred. This security incident which had started on January 7th led to a bad actor obtaining access to their database which contains ours and other customer’s data.
|
||||
|
||||
We understand that during this security incident the following has been accessed:
|
||||
|
||||
- Email addresses.
|
||||
- Metadata on emails sent excluding the email body.
|
||||
|
||||
While the incident is unfortunate we are pleased with the remediation and the processes that our email provider has put in place to help avoid this kind of situation in the future. Since the incident, our provider has rectified the issue and has engaged a security company to conduct an exhaustive investigation and to help improve their security posture moving forward.
|
||||
|
||||
We remain steadfast in our commitment to our current email provider, and will not be taking any further action with relation to changing providers.
|
||||
|
||||
We are now working with our legal counsel to ensure that we provide the appropriate notice to all our customers in each jurisdiction. If you have any further questions on this incident please feel free to contact our support team at [support@documenso.com](mailto:support@documenso.com).
|
||||
|
||||
We appreciate your ongoing support in this matter.
|
||||
|
||||
You can read more on the incident on our providers blog post below:
|
||||
[https://resend.com/blog/incident-report-for-january-10-2024](https://resend.com/blog/incident-report-for-january-10-2024)
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Announcing Pre-Seed and Open Metrics
|
||||
description: We are exicited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
|
||||
description: We are excited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
|
||||
@ -30,7 +30,7 @@ We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania)
|
||||
|
||||
## Documenso Merch Shop
|
||||
|
||||
The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contrinuting to Documenso.
|
||||
The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contributing to Documenso.
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
|
||||
@ -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) => {
|
||||
@ -56,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<EditDocumentStep, DocumentFlowStep> = {
|
||||
title: {
|
||||
@ -176,6 +180,13 @@ export const EditDocumentForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onPasswordSubmit = async (password: string) => {
|
||||
await setPasswordForDocument({
|
||||
documentId: document.id,
|
||||
password,
|
||||
});
|
||||
};
|
||||
|
||||
const currentDocumentFlow = documentFlow[step];
|
||||
|
||||
return (
|
||||
@ -185,7 +196,13 @@ export const EditDocumentForm = ({
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
||||
<LazyPDFViewer
|
||||
key={documentData.id}
|
||||
documentData={documentData}
|
||||
document={document}
|
||||
password={documentMeta?.password}
|
||||
onPasswordSubmit={onPasswordSubmit}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -40,7 +42,24 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
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([
|
||||
getRecipientsForDocument({
|
||||
@ -83,6 +102,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 +111,12 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
|
||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||
<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>
|
||||
|
||||
@ -88,7 +88,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{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)}
|
||||
{stats[value] > 99 && '+'}
|
||||
</span>
|
||||
|
||||
@ -49,6 +49,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||
}, [fields]);
|
||||
|
||||
const fieldsValidated = () => {
|
||||
setValidateUninsertedFields(true);
|
||||
validateFieldsInserted(fields);
|
||||
};
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
setValidateUninsertedFields(true);
|
||||
|
||||
@ -154,6 +159,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
document={document}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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) {
|
||||
@ -101,7 +120,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
||||
<LazyPDFViewer
|
||||
key={documentData.id}
|
||||
documentData={documentData}
|
||||
document={document}
|
||||
password={documentMeta?.password}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ export type SignDialogProps = {
|
||||
isSubmitting: boolean;
|
||||
document: Document;
|
||||
fields: Field[];
|
||||
fieldsValidated: () => void | Promise<void>;
|
||||
onSignatureComplete: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
@ -22,6 +23,7 @@ export const SignDialog = ({
|
||||
isSubmitting,
|
||||
document,
|
||||
fields,
|
||||
fieldsValidated,
|
||||
onSignatureComplete,
|
||||
}: SignDialogProps) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
@ -29,16 +31,16 @@ export const SignDialog = ({
|
||||
const isComplete = fields.every((field) => field.inserted);
|
||||
|
||||
return (
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<Dialog open={showDialog && isComplete} onOpenChange={setShowDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
size="lg"
|
||||
disabled={!isComplete}
|
||||
onClick={fieldsValidated}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Complete
|
||||
{isComplete ? 'Complete' : 'Next field'}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
|
||||
@ -95,8 +95,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
const toggleOpen = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
const toggleOpen = () => {
|
||||
setIsOpen((isOpen) => !isOpen);
|
||||
onOpenChange?.(!isOpen);
|
||||
|
||||
@ -136,7 +135,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
|
||||
const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]);
|
||||
|
||||
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen);
|
||||
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true });
|
||||
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
|
||||
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
|
||||
useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates);
|
||||
|
||||
3
package-lock.json
generated
3
package-lock.json
generated
@ -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": "*",
|
||||
|
||||
@ -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;
|
||||
password?: string;
|
||||
dateFormat?: string;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
@ -18,6 +19,7 @@ export const upsertDocumentMeta = async ({
|
||||
dateFormat,
|
||||
documentId,
|
||||
userId,
|
||||
password,
|
||||
}: CreateDocumentMetaOptions) => {
|
||||
await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
@ -35,12 +37,14 @@ export const upsertDocumentMeta = async ({
|
||||
message,
|
||||
dateFormat,
|
||||
timezone,
|
||||
password,
|
||||
documentId,
|
||||
},
|
||||
update: {
|
||||
subject,
|
||||
message,
|
||||
dateFormat,
|
||||
password,
|
||||
timezone,
|
||||
},
|
||||
});
|
||||
|
||||
@ -26,6 +26,7 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI
|
||||
message: true,
|
||||
subject: true,
|
||||
dateFormat: true,
|
||||
password: true,
|
||||
timezone: true,
|
||||
},
|
||||
},
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentMeta" ADD COLUMN "password" TEXT;
|
||||
@ -162,6 +162,7 @@ model DocumentMeta {
|
||||
subject String?
|
||||
message String?
|
||||
timezone String? @db.Text @default("Etc/UTC")
|
||||
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)
|
||||
|
||||
@ -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 {
|
||||
@ -24,6 +26,7 @@ import {
|
||||
ZSearchDocumentsMutationSchema,
|
||||
ZSendDocumentMutationSchema,
|
||||
ZSetFieldsForDocumentMutationSchema,
|
||||
ZSetPasswordForDocumentMutationSchema,
|
||||
ZSetRecipientsForDocumentMutationSchema,
|
||||
ZSetTitleForDocumentMutationSchema,
|
||||
} 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
|
||||
.input(ZSendDocumentMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@ -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({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(z.number()).min(1),
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,6 +121,7 @@ export const FieldItem = ({
|
||||
<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"
|
||||
onClick={() => onRemove?.()}
|
||||
onTouchEnd={() => onRemove?.()}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
96
packages/ui/primitives/document-password-dialog.tsx
Normal file
96
packages/ui/primitives/document-password-dialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -3,16 +3,19 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from '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 '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 } 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';
|
||||
|
||||
export type LoadedPDFDocument = PDFDocumentProxy;
|
||||
@ -43,6 +46,9 @@ const PDFLoader = () => (
|
||||
export type PDFViewerProps = {
|
||||
className?: string;
|
||||
documentData: DocumentData;
|
||||
document?: DocumentWithData;
|
||||
password?: string | null;
|
||||
onPasswordSubmit?: (password: string) => void | Promise<void>;
|
||||
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
|
||||
onPageClick?: OnPDFViewerPageClick;
|
||||
[key: string]: unknown;
|
||||
@ -51,6 +57,8 @@ export type PDFViewerProps = {
|
||||
export const PDFViewer = ({
|
||||
className,
|
||||
documentData,
|
||||
password: defaultPassword,
|
||||
onPasswordSubmit,
|
||||
onDocumentLoad,
|
||||
onPageClick,
|
||||
...props
|
||||
@ -59,7 +67,11 @@ export const PDFViewer = ({
|
||||
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
const passwordCallbackRef = useRef<((password: string | null) => void) | null>(null);
|
||||
|
||||
const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false);
|
||||
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
|
||||
const [isPasswordError, setIsPasswordError] = useState(false);
|
||||
const [documentBytes, setDocumentBytes] = useState<Uint8Array | null>(null);
|
||||
|
||||
const [width, setWidth] = useState(0);
|
||||
@ -169,57 +181,87 @@ export const PDFViewer = ({
|
||||
<PDFLoader />
|
||||
</div>
|
||||
) : (
|
||||
<PDFDocument
|
||||
file={documentBytes.buffer}
|
||||
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.
|
||||
// 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 ? (
|
||||
<>
|
||||
<PDFDocument
|
||||
file={documentBytes.buffer}
|
||||
className={cn('w-full overflow-hidden rounded', {
|
||||
'h-[80vh] max-h-[60rem]': numPages === 0,
|
||||
})}
|
||||
onPassword={(callback, reason) => {
|
||||
// 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;
|
||||
|
||||
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">
|
||||
<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">
|
||||
<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>
|
||||
}
|
||||
>
|
||||
{Array(numPages)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-border my-8 overflow-hidden rounded border will-change-transform first:mt-0 last:mb-0"
|
||||
>
|
||||
<PDFPage
|
||||
pageNumber={i + 1}
|
||||
width={width}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
loading={() => ''}
|
||||
onClick={(e) => onDocumentPageClick(e, i + 1)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</PDFDocument>
|
||||
}
|
||||
>
|
||||
{Array(numPages)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-border my-8 overflow-hidden rounded border will-change-transform first:mt-0 last:mb-0"
|
||||
>
|
||||
<PDFPage
|
||||
pageNumber={i + 1}
|
||||
width={width}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
loading={() => ''}
|
||||
onClick={(e) => onDocumentPageClick(e, i + 1)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</PDFDocument>
|
||||
|
||||
<PasswordDialog
|
||||
open={isPasswordModalOpen}
|
||||
onOpenChange={setIsPasswordModalOpen}
|
||||
onPasswordSubmit={(password) => {
|
||||
passwordCallbackRef.current?.(password);
|
||||
|
||||
setIsPasswordModalOpen(false);
|
||||
|
||||
void onPasswordSubmit?.(password);
|
||||
}}
|
||||
isError={isPasswordError}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -18,7 +18,7 @@ export const ThemeSwitcher = () => {
|
||||
>
|
||||
{isMounted && theme === THEMES_TYPE.LIGHT && (
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user