mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 18:21:32 +10:00
chore: merged main
This commit is contained in:
@ -10,7 +10,7 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||
{isDocument && (
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
This document was sent using{' '}
|
||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
||||
<Link className="text-[#7AC455]" href="https://documen.so/mail-footer">
|
||||
Documenso.
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
29
packages/lib/client-only/download-pdf.ts
Normal file
29
packages/lib/client-only/download-pdf.ts
Normal 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);
|
||||
};
|
||||
@ -1 +1,12 @@
|
||||
import { IdentityProvider } from '@documenso/prisma/client';
|
||||
|
||||
export const SALT_ROUNDS = 12;
|
||||
|
||||
export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = {
|
||||
[IdentityProvider.DOCUMENSO]: 'Documenso',
|
||||
[IdentityProvider.GOOGLE]: 'Google',
|
||||
};
|
||||
|
||||
export const IS_GOOGLE_SSO_ENABLED = Boolean(
|
||||
process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
|
||||
);
|
||||
|
||||
@ -1 +1,23 @@
|
||||
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
|
||||
|
||||
export const DOCUMENSO_ENCRYPTION_SECONDARY_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY;
|
||||
|
||||
if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
|
||||
}
|
||||
|
||||
if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||
throw new Error(
|
||||
'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal',
|
||||
);
|
||||
}
|
||||
|
||||
if (DOCUMENSO_ENCRYPTION_KEY === 'CAFEBABE') {
|
||||
console.warn('*********************************************************************');
|
||||
console.warn('*');
|
||||
console.warn('*');
|
||||
console.warn('Please change the encryption key from the default value of "CAFEBABE"');
|
||||
console.warn('*');
|
||||
console.warn('*');
|
||||
console.warn('*********************************************************************');
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import type { GoogleProfile } from 'next-auth/providers/google';
|
||||
import GoogleProvider from 'next-auth/providers/google';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { IdentityProvider } from '@documenso/prisma/client';
|
||||
|
||||
import { ONE_DAY } from '../constants/time';
|
||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||
@ -105,7 +106,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
async jwt({ token, user, trigger, account }) {
|
||||
const merged = {
|
||||
...token,
|
||||
...user,
|
||||
@ -150,6 +151,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
merged.emailVerified = user.emailVerified?.toISOString() ?? null;
|
||||
}
|
||||
|
||||
if ((trigger === 'signIn' || trigger === 'signUp') && account?.provider === 'google') {
|
||||
merged.emailVerified = user?.emailVerified
|
||||
? new Date(user.emailVerified).toISOString()
|
||||
: new Date().toISOString();
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: Number(merged.id),
|
||||
},
|
||||
data: {
|
||||
emailVerified: merged.emailVerified,
|
||||
identityProvider: IdentityProvider.GOOGLE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: merged.id,
|
||||
name: merged.name,
|
||||
|
||||
33
packages/lib/server-only/crypto/decrypt.ts
Normal file
33
packages/lib/server-only/crypto/decrypt.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { ZEncryptedDataSchema } from '@documenso/lib/server-only/crypto/encrypt';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
|
||||
/**
|
||||
* Decrypt the passed in data. This uses the secondary encrypt key for miscellaneous data.
|
||||
*
|
||||
* @param encryptedData The data encrypted with the `encryptSecondaryData` function.
|
||||
* @returns The decrypted value, or `null` if the data is invalid or expired.
|
||||
*/
|
||||
export const decryptSecondaryData = (encryptedData: string): string | null => {
|
||||
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||
throw new Error('Missing encryption key');
|
||||
}
|
||||
|
||||
const decryptedBufferValue = symmetricDecrypt({
|
||||
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
|
||||
data: encryptedData,
|
||||
});
|
||||
|
||||
const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8');
|
||||
const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue));
|
||||
|
||||
if (!result.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data.data;
|
||||
};
|
||||
42
packages/lib/server-only/crypto/encrypt.ts
Normal file
42
packages/lib/server-only/crypto/encrypt.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||
import type { TEncryptSecondaryDataMutationSchema } from '@documenso/trpc/server/crypto/schema';
|
||||
|
||||
export const ZEncryptedDataSchema = z.object({
|
||||
data: z.string(),
|
||||
expiresAt: z.number().optional(),
|
||||
});
|
||||
|
||||
export type EncryptDataOptions = {
|
||||
data: string;
|
||||
|
||||
/**
|
||||
* When the data should no longer be allowed to be decrypted.
|
||||
*
|
||||
* Leave this empty to never expire the data.
|
||||
*/
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encrypt the passed in data. This uses the secondary encrypt key for miscellaneous data.
|
||||
*
|
||||
* @returns The encrypted data.
|
||||
*/
|
||||
export const encryptSecondaryData = ({ data, expiresAt }: TEncryptSecondaryDataMutationSchema) => {
|
||||
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||
throw new Error('Missing encryption key');
|
||||
}
|
||||
|
||||
const dataToEncrypt: z.infer<typeof ZEncryptedDataSchema> = {
|
||||
data,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
return symmetricEncrypt({
|
||||
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
|
||||
data: JSON.stringify(dataToEncrypt),
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@ -7,6 +7,7 @@ import { SigningStatus } from '@documenso/prisma/client';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
import type { FindResultSet } from '../../types/find-result-set';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
|
||||
export type FindDocumentsOptions = {
|
||||
userId: number;
|
||||
@ -173,8 +174,15 @@ export const findDocuments = async ({
|
||||
}),
|
||||
]);
|
||||
|
||||
const maskedData = data.map((document) =>
|
||||
maskRecipientTokensForDocument({
|
||||
document,
|
||||
user,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
data: maskedData,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
|
||||
export interface GetDocumentAndSenderByTokenOptions {
|
||||
token: string;
|
||||
@ -58,7 +58,11 @@ export const getDocumentAndRecipientByToken = async ({
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
Recipient: {
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -42,6 +42,11 @@ export const getStats = async ({ user }: GetStatsInput) => {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
User: {
|
||||
email: {
|
||||
not: user.email,
|
||||
},
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
|
||||
export type SearchDocumentsWithKeywordOptions = {
|
||||
query: string;
|
||||
userId: number;
|
||||
@ -77,5 +79,12 @@ export const searchDocumentsWithKeyword = async ({
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return documents;
|
||||
const maskedDocuments = documents.map((document) =>
|
||||
maskRecipientTokensForDocument({
|
||||
document,
|
||||
user,
|
||||
}),
|
||||
);
|
||||
|
||||
return maskedDocuments;
|
||||
};
|
||||
|
||||
@ -5,14 +5,16 @@ import type { Prisma } from '@prisma/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type UpdateDocumentOptions = {
|
||||
documentId: number;
|
||||
data: Prisma.DocumentUpdateInput;
|
||||
userId: number;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const updateDocument = async ({ documentId, data }: UpdateDocumentOptions) => {
|
||||
export const updateDocument = async ({ documentId, userId, data }: UpdateDocumentOptions) => {
|
||||
return await prisma.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
|
||||
38
packages/lib/utils/mask-recipient-tokens-for-document.ts
Normal file
38
packages/lib/utils/mask-recipient-tokens-for-document.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import type { DocumentWithRecipients } from '@documenso/prisma/types/document-with-recipient';
|
||||
|
||||
export type MaskRecipientTokensForDocumentOptions<T extends DocumentWithRecipients> = {
|
||||
document: T;
|
||||
user?: User;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export const maskRecipientTokensForDocument = <T extends DocumentWithRecipients>({
|
||||
document,
|
||||
user,
|
||||
token,
|
||||
}: MaskRecipientTokensForDocumentOptions<T>) => {
|
||||
const maskedRecipients = document.Recipient.map((recipient) => {
|
||||
if (document.userId === user?.id) {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
if (recipient.email === user?.email) {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
if (recipient.token === token) {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
return {
|
||||
...recipient,
|
||||
token: '',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...document,
|
||||
Recipient: maskedRecipients,
|
||||
};
|
||||
};
|
||||
@ -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)
|
||||
|
||||
@ -18,6 +18,7 @@ export const seedDatabase = async () => {
|
||||
create: {
|
||||
name: 'Example User',
|
||||
email: 'example@documenso.com',
|
||||
emailVerified: new Date(),
|
||||
password: hashSync('password'),
|
||||
roles: [Role.USER],
|
||||
},
|
||||
@ -31,6 +32,7 @@ export const seedDatabase = async () => {
|
||||
create: {
|
||||
name: 'Admin User',
|
||||
email: 'admin@documenso.com',
|
||||
emailVerified: new Date(),
|
||||
password: hashSync('password'),
|
||||
roles: [Role.USER, Role.ADMIN],
|
||||
},
|
||||
|
||||
17
packages/trpc/server/crypto/router.ts
Normal file
17
packages/trpc/server/crypto/router.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
||||
|
||||
import { procedure, router } from '../trpc';
|
||||
import { ZEncryptSecondaryDataMutationSchema } from './schema';
|
||||
|
||||
export const cryptoRouter = router({
|
||||
encryptSecondaryData: procedure
|
||||
.input(ZEncryptSecondaryDataMutationSchema)
|
||||
.mutation(({ input }) => {
|
||||
try {
|
||||
return encryptSecondaryData(input);
|
||||
} catch {
|
||||
// Never leak errors for crypto.
|
||||
throw new Error('Failed to encrypt data');
|
||||
}
|
||||
}),
|
||||
});
|
||||
15
packages/trpc/server/crypto/schema.ts
Normal file
15
packages/trpc/server/crypto/schema.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZEncryptSecondaryDataMutationSchema = z.object({
|
||||
data: z.string(),
|
||||
expiresAt: z.number().optional(),
|
||||
});
|
||||
|
||||
export const ZDecryptDataMutationSchema = z.object({
|
||||
data: z.string(),
|
||||
});
|
||||
|
||||
export type TEncryptSecondaryDataMutationSchema = z.infer<
|
||||
typeof ZEncryptSecondaryDataMutationSchema
|
||||
>;
|
||||
export type TDecryptDataMutationSchema = z.infer<typeof ZDecryptDataMutationSchema>;
|
||||
@ -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),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { adminRouter } from './admin-router/router';
|
||||
import { authRouter } from './auth-router/router';
|
||||
import { cryptoRouter } from './crypto/router';
|
||||
import { documentRouter } from './document-router/router';
|
||||
import { fieldRouter } from './field-router/router';
|
||||
import { profileRouter } from './profile-router/router';
|
||||
@ -12,6 +13,7 @@ import { twoFactorAuthenticationRouter } from './two-factor-authentication-route
|
||||
|
||||
export const appRouter = router({
|
||||
auth: authRouter,
|
||||
crypto: cryptoRouter,
|
||||
profile: profileRouter,
|
||||
document: documentRouter,
|
||||
field: fieldRouter,
|
||||
|
||||
1
packages/tsconfig/process-env.d.ts
vendored
1
packages/tsconfig/process-env.d.ts
vendored
@ -8,6 +8,7 @@ declare namespace NodeJS {
|
||||
|
||||
NEXT_PRIVATE_DATABASE_URL: string;
|
||||
NEXT_PRIVATE_ENCRYPTION_KEY: string;
|
||||
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY: string;
|
||||
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import { useState } from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { DocumentData } from '@documenso/prisma/client';
|
||||
import type { DocumentData } from '@documenso/prisma/client';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '../../primitives/dialog';
|
||||
|
||||
@ -5,11 +5,11 @@ import { useState } from '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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { Button } from '../../primitives/button';
|
||||
import { useToast } from '../../primitives/use-toast';
|
||||
|
||||
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
disabled?: boolean;
|
||||
@ -24,43 +24,29 @@ export const DocumentDownloadButton = ({
|
||||
disabled,
|
||||
...props
|
||||
}: DownloadButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (!documentData) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = await getFile(documentData);
|
||||
|
||||
const blob = new Blob([bytes], {
|
||||
type: 'application/pdf',
|
||||
await downloadPDF({ documentData, fileName }).then(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName;
|
||||
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setIsLoading(false);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
title: 'Something went wrong',
|
||||
description: 'An error occurred while downloading your document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps)
|
||||
<DialogContent className="overflow-hidden p-0 shadow-2xl">
|
||||
<Command
|
||||
{...commandProps}
|
||||
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4"
|
||||
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-0 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4"
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
@ -92,7 +92,7 @@ const CommandGroup = React.forwardRef<
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
|
||||
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground mx-2 overflow-hidden border-b pb-2 last:border-0 [&_[cmdk-group-heading]]:mt-2 [&_[cmdk-group-heading]]:px-0 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-normal [&_[cmdk-group-heading]]:opacity-50 ',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@ -121,7 +121,7 @@ const CommandItem = React.forwardRef<
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground relative -mx-2 -my-1 flex cursor-default select-none items-center rounded-lg px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -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