From 81ee582f1c4591737a2e30998e9a9a03c0b45c8f Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sat, 30 Mar 2024 13:43:28 +0800 Subject: [PATCH 01/11] fix: linting warnings (#1069) ## Description Cleaned up code that was being highlighted in the dev tools --- apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx | 1 + apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx | 2 +- apps/web/src/components/branding/logo.tsx | 2 +- apps/web/src/components/formatter/template-type.tsx | 4 ++-- .../src/components/forms/2fa/view-recovery-codes-dialog.tsx | 3 --- apps/web/src/components/forms/profile.tsx | 3 --- apps/web/src/components/ui/background.tsx | 2 +- apps/web/src/providers/next-theme.tsx | 2 +- 8 files changed, 7 insertions(+), 12 deletions(-) diff --git a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx index a8e02ca9f..0cb523c3f 100644 --- a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx @@ -58,6 +58,7 @@ export const UsersDataTable = ({ perPage, }); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedSearchString]); const onPaginationChange = (page: number, perPage: number) => { diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx index 91f470f74..69e7d1142 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import { Recipient } from '@documenso/prisma/client'; +import type { Recipient } from '@documenso/prisma/client'; import { StackAvatar } from './stack-avatar'; diff --git a/apps/web/src/components/branding/logo.tsx b/apps/web/src/components/branding/logo.tsx index 6cd4c550c..92087a149 100644 --- a/apps/web/src/components/branding/logo.tsx +++ b/apps/web/src/components/branding/logo.tsx @@ -1,4 +1,4 @@ -import { SVGAttributes } from 'react'; +import type { SVGAttributes } from 'react'; export type LogoProps = SVGAttributes; diff --git a/apps/web/src/components/formatter/template-type.tsx b/apps/web/src/components/formatter/template-type.tsx index a7f10105e..3bcb3b05e 100644 --- a/apps/web/src/components/formatter/template-type.tsx +++ b/apps/web/src/components/formatter/template-type.tsx @@ -1,9 +1,9 @@ -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import { Globe, Lock } from 'lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react'; -import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client'; +import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; type TemplateTypeIcon = { diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx index 66df7bbab..8a6177b5b 100644 --- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -47,12 +47,9 @@ export const ViewRecoveryCodesDialog = () => { data: recoveryCodes, mutate, isLoading, - isError, error, } = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); - // error?.data?.code - const viewRecoveryCodesForm = useForm({ defaultValues: { token: '', diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index c3f8eca37..42d69047f 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -55,11 +55,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { }); const isSubmitting = form.formState.isSubmitting; - const hasTwoFactorAuthentication = user.twoFactorEnabled; const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); - const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } = - trpc.profile.deleteAccount.useMutation(); const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => { try { diff --git a/apps/web/src/components/ui/background.tsx b/apps/web/src/components/ui/background.tsx index 5763967ec..0e0bea5ca 100644 --- a/apps/web/src/components/ui/background.tsx +++ b/apps/web/src/components/ui/background.tsx @@ -1,4 +1,4 @@ -import { SVGAttributes } from 'react'; +import type { SVGAttributes } from 'react'; export type BackgroundProps = Omit, 'viewBox'>; diff --git a/apps/web/src/providers/next-theme.tsx b/apps/web/src/providers/next-theme.tsx index 6e9122e5a..d15114606 100644 --- a/apps/web/src/providers/next-theme.tsx +++ b/apps/web/src/providers/next-theme.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ThemeProvider as NextThemesProvider } from 'next-themes'; -import { ThemeProviderProps } from 'next-themes/dist/types'; +import type { ThemeProviderProps } from 'next-themes/dist/types'; export function ThemeProvider({ children, ...props }: ThemeProviderProps) { return {children}; From b436331d7d217706b0df37249febb1f7c9e966db Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sat, 30 Mar 2024 14:00:34 +0800 Subject: [PATCH 02/11] fix: improve error log coverage --- packages/trpc/server/admin-router/router.ts | 9 + .../trpc/server/api-token-router/router.ts | 16 +- .../trpc/server/document-router/router.ts | 26 +- packages/trpc/server/field-router/router.ts | 34 ++- packages/trpc/server/profile-router/router.ts | 14 + .../trpc/server/singleplayer-router/router.ts | 256 +++++++++--------- .../trpc/server/template-router/router.ts | 2 + packages/trpc/server/webhook-router/router.ts | 12 + 8 files changed, 218 insertions(+), 151 deletions(-) diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index b37510be7..05ee84736 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -29,6 +29,8 @@ export const adminRouter = router({ try { return await findDocuments({ term, page, perPage }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to retrieve the documents. Please try again.', @@ -44,6 +46,8 @@ export const adminRouter = router({ try { return await updateUser({ id, name, email, roles }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to retrieve the specified account. Please try again.', @@ -59,6 +63,8 @@ export const adminRouter = router({ try { return await updateRecipient({ id, name, email }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to update the recipient provided.', @@ -79,6 +85,8 @@ export const adminRouter = router({ userId: ctx.user.id, }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to update the site setting provided.', @@ -95,6 +103,7 @@ export const adminRouter = router({ return await sealDocument({ documentId: id, isResealing: true }); } catch (err) { console.log('resealDocument error', err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to reseal the document provided.', diff --git a/packages/trpc/server/api-token-router/router.ts b/packages/trpc/server/api-token-router/router.ts index 14e75e001..55129f029 100644 --- a/packages/trpc/server/api-token-router/router.ts +++ b/packages/trpc/server/api-token-router/router.ts @@ -16,7 +16,9 @@ export const apiTokenRouter = router({ getTokens: authenticatedProcedure.query(async ({ ctx }) => { try { return await getUserTokens({ userId: ctx.user.id }); - } catch (e) { + } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to find your API tokens. Please try again.', @@ -34,7 +36,9 @@ export const apiTokenRouter = router({ id, userId: ctx.user.id, }); - } catch (e) { + } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to find this API token. Please try again.', @@ -54,7 +58,9 @@ export const apiTokenRouter = router({ tokenName, expiresIn: expirationDate, }); - } catch (e) { + } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to create an API token. Please try again.', @@ -73,7 +79,9 @@ export const apiTokenRouter = router({ teamId, userId: ctx.user.id, }); - } catch (e) { + } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to delete this API Token. Please try again.', diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 4a6f11e60..6e7e8764f 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -115,6 +115,8 @@ export const documentRouter = router({ requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { + console.error(err); + if (err instanceof TRPCError) { throw err; } @@ -222,13 +224,19 @@ export const documentRouter = router({ const userId = ctx.user.id; - return await updateTitle({ - title, - userId, - teamId, - documentId, - requestMetadata: extractNextApiRequestMetadata(ctx.req), - }); + try { + return await updateTitle({ + title, + userId, + teamId, + documentId, + requestMetadata: extractNextApiRequestMetadata(ctx.req), + }); + } catch (err) { + console.error(err); + + throw err; + } }), setPasswordForDocument: authenticatedProcedure @@ -347,7 +355,9 @@ export const documentRouter = router({ userId: ctx.user.id, }); return documents; - } catch (error) { + } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We are unable to search for documents. Please try again later.', diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 4b299b6a1..354e937a5 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -52,20 +52,26 @@ export const fieldRouter = router({ .mutation(async ({ input, ctx }) => { const { templateId, fields } = input; - await setFieldsForTemplate({ - userId: ctx.user.id, - templateId, - fields: fields.map((field) => ({ - id: field.nativeId, - signerEmail: field.signerEmail, - type: field.type, - pageNumber: field.pageNumber, - pageX: field.pageX, - pageY: field.pageY, - pageWidth: field.pageWidth, - pageHeight: field.pageHeight, - })), - }); + try { + await setFieldsForTemplate({ + userId: ctx.user.id, + templateId, + fields: fields.map((field) => ({ + id: field.nativeId, + signerEmail: field.signerEmail, + type: field.type, + pageNumber: field.pageNumber, + pageX: field.pageX, + pageY: field.pageY, + pageWidth: field.pageWidth, + pageHeight: field.pageHeight, + })), + }); + } catch (err) { + console.error(err); + + throw err; + } }), signFieldWithToken: procedure diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 542ac2807..eb5f54274 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -37,6 +37,8 @@ export const profileRouter = router({ ...input, }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to find user security audit logs. Please try again.', @@ -50,6 +52,8 @@ export const profileRouter = router({ return await getUserById({ id }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to retrieve the specified account. Please try again.', @@ -108,6 +112,8 @@ export const profileRouter = router({ return { success: true, url: user.url }; } catch (err) { + console.error(err); + const error = AppError.parseError(err); if (error.code !== AppErrorCode.UNKNOWN_ERROR) { @@ -135,6 +141,8 @@ export const profileRouter = router({ requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { + console.error(err); + let message = 'We were unable to update your profile. Please review the information you provided and try again.'; @@ -171,6 +179,8 @@ export const profileRouter = router({ requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { + console.error(err); + let message = 'We were unable to reset your password. Please try again.'; if (err instanceof Error) { @@ -192,6 +202,8 @@ export const profileRouter = router({ return await sendConfirmationToken({ email }); } catch (err) { + console.error(err); + let message = 'We were unable to send a confirmation email. Please try again.'; if (err instanceof Error) { @@ -211,6 +223,8 @@ export const profileRouter = router({ id: ctx.user.id, }); } catch (err) { + console.error(err); + let message = 'We were unable to delete your account. Please try again.'; if (err instanceof Error) { diff --git a/packages/trpc/server/singleplayer-router/router.ts b/packages/trpc/server/singleplayer-router/router.ts index 2095d7d42..33b125110 100644 --- a/packages/trpc/server/singleplayer-router/router.ts +++ b/packages/trpc/server/singleplayer-router/router.ts @@ -29,151 +29,157 @@ export const singleplayerRouter = router({ createSinglePlayerDocument: procedure .input(ZCreateSinglePlayerDocumentMutationSchema) .mutation(async ({ input }) => { - const { signer, fields, documentData, documentName } = input; + try { + const { signer, fields, documentData, documentName } = input; - const document = await getFile({ - data: documentData.data, - type: documentData.type, - }); - - const doc = await PDFDocument.load(document); - - const createdAt = new Date(); - - const isBase64 = signer.signature.startsWith('data:image/png;base64,'); - const signatureImageAsBase64 = isBase64 ? signer.signature : null; - const typedSignature = !isBase64 ? signer.signature : null; - - // Update the document with the fields inserted. - for (const field of fields) { - const isSignatureField = field.type === FieldType.SIGNATURE; - - await insertFieldInPDF(doc, { - ...mapField(field, signer), - Signature: isSignatureField - ? { - created: createdAt, - signatureImageAsBase64, - typedSignature, - // Dummy data. - id: -1, - recipientId: -1, - fieldId: -1, - } - : null, - // Dummy data. - id: -1, - secondaryId: '-1', - documentId: -1, - templateId: null, - recipientId: -1, + const document = await getFile({ + data: documentData.data, + type: documentData.type, }); - } - const unsignedPdfBytes = await doc.save(); + const doc = await PDFDocument.load(document); - const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) }); + const createdAt = new Date(); - const { token } = await prisma.$transaction( - async (tx) => { - const token = alphaid(); + const isBase64 = signer.signature.startsWith('data:image/png;base64,'); + const signatureImageAsBase64 = isBase64 ? signer.signature : null; + const typedSignature = !isBase64 ? signer.signature : null; - // Fetch service user who will be the owner of the document. - const serviceUser = await tx.user.findFirstOrThrow({ - where: { - email: SERVICE_USER_EMAIL, - }, + // Update the document with the fields inserted. + for (const field of fields) { + const isSignatureField = field.type === FieldType.SIGNATURE; + + await insertFieldInPDF(doc, { + ...mapField(field, signer), + Signature: isSignatureField + ? { + created: createdAt, + signatureImageAsBase64, + typedSignature, + // Dummy data. + id: -1, + recipientId: -1, + fieldId: -1, + } + : null, + // Dummy data. + id: -1, + secondaryId: '-1', + documentId: -1, + templateId: null, + recipientId: -1, }); + } - const { id: documentDataId } = await putFile({ - name: `${documentName}.pdf`, - type: 'application/pdf', - arrayBuffer: async () => Promise.resolve(signedPdfBuffer), - }); + const unsignedPdfBytes = await doc.save(); - // Create document. - const document = await tx.document.create({ - data: { - title: documentName, - status: DocumentStatus.COMPLETED, - documentDataId, - userId: serviceUser.id, - createdAt, - }, - }); + const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) }); - // Create recipient. - const recipient = await tx.recipient.create({ - data: { - documentId: document.id, - name: signer.name, - email: signer.email, - token, - signedAt: createdAt, - readStatus: ReadStatus.OPENED, - signingStatus: SigningStatus.SIGNED, - sendStatus: SendStatus.SENT, - }, - }); + const { token } = await prisma.$transaction( + async (tx) => { + const token = alphaid(); - // Create fields and signatures. - await Promise.all( - fields.map(async (field) => { - const insertedField = await tx.field.create({ - data: { - documentId: document.id, - recipientId: recipient.id, - ...mapField(field, signer), - }, - }); + // Fetch service user who will be the owner of the document. + const serviceUser = await tx.user.findFirstOrThrow({ + where: { + email: SERVICE_USER_EMAIL, + }, + }); - if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) { - await tx.signature.create({ + const { id: documentDataId } = await putFile({ + name: `${documentName}.pdf`, + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(signedPdfBuffer), + }); + + // Create document. + const document = await tx.document.create({ + data: { + title: documentName, + status: DocumentStatus.COMPLETED, + documentDataId, + userId: serviceUser.id, + createdAt, + }, + }); + + // Create recipient. + const recipient = await tx.recipient.create({ + data: { + documentId: document.id, + name: signer.name, + email: signer.email, + token, + signedAt: createdAt, + readStatus: ReadStatus.OPENED, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + }, + }); + + // Create fields and signatures. + await Promise.all( + fields.map(async (field) => { + const insertedField = await tx.field.create({ data: { - fieldId: insertedField.id, - signatureImageAsBase64, - typedSignature, + documentId: document.id, recipientId: recipient.id, + ...mapField(field, signer), }, }); - } - }), - ); - return { document, token }; - }, - { - maxWait: 5000, - timeout: 30000, - }, - ); + if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) { + await tx.signature.create({ + data: { + fieldId: insertedField.id, + signatureImageAsBase64, + typedSignature, + recipientId: recipient.id, + }, + }); + } + }), + ); - const template = createElement(DocumentSelfSignedEmailTemplate, { - documentName: documentName, - assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000', - }); + return { document, token }; + }, + { + maxWait: 5000, + timeout: 30000, + }, + ); - const [html, text] = await Promise.all([ - renderAsync(template), - renderAsync(template, { plainText: true }), - ]); + const template = createElement(DocumentSelfSignedEmailTemplate, { + documentName: documentName, + assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000', + }); - // Send email to signer. - await mailer.sendMail({ - to: { - address: signer.email, - name: signer.name, - }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, - subject: 'Document signed', - html, - text, - attachments: [{ content: signedPdfBuffer, filename: documentName }], - }); + const [html, text] = await Promise.all([ + renderAsync(template), + renderAsync(template, { plainText: true }), + ]); - return token; + // Send email to signer. + await mailer.sendMail({ + to: { + address: signer.email, + name: signer.name, + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: 'Document signed', + html, + text, + attachments: [{ content: signedPdfBuffer, filename: documentName }], + }); + + return token; + } catch (err) { + console.error(err); + + throw err; + } }), }); diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 2dd4d51c8..4ed567b2b 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -56,6 +56,8 @@ export const templateRouter = router({ recipients: input.recipients, }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to create this document. Please try again later.', diff --git a/packages/trpc/server/webhook-router/router.ts b/packages/trpc/server/webhook-router/router.ts index 08b1b9bce..d1479457b 100644 --- a/packages/trpc/server/webhook-router/router.ts +++ b/packages/trpc/server/webhook-router/router.ts @@ -21,6 +21,8 @@ export const webhookRouter = router({ try { return await getWebhooksByUserId(ctx.user.id); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to fetch your webhooks. Please try again later.', @@ -36,6 +38,8 @@ export const webhookRouter = router({ try { return await getWebhooksByTeamId(teamId, ctx.user.id); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to fetch your webhooks. Please try again later.', @@ -55,6 +59,8 @@ export const webhookRouter = router({ teamId, }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to fetch your webhook. Please try again later.', @@ -77,6 +83,8 @@ export const webhookRouter = router({ userId: ctx.user.id, }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to create this webhook. Please try again later.', @@ -96,6 +104,8 @@ export const webhookRouter = router({ userId: ctx.user.id, }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to create this webhook. Please try again later.', @@ -116,6 +126,8 @@ export const webhookRouter = router({ teamId, }); } catch (err) { + console.error(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We were unable to create this webhook. Please try again later.', From cbe62704940ee0877f62a417c078db855c9c9fe7 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sun, 31 Mar 2024 15:49:12 +0800 Subject: [PATCH 03/11] feat: add passkey and 2FA document action auth options (#1065) ## Description Add the following document action auth options: - 2FA - Passkey If the user does not have the required auth setup, we onboard them directly. ## Changes made Note: Added secondaryId to the VerificationToken schema ## Testing Performed Tested locally, pending preview tests ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have added/updated tests that prove the effectiveness of these changes. - [X] I have followed the project's coding style guidelines. ## Summary by CodeRabbit - **New Features** - Introduced components for 2FA, account, and passkey authentication during document signing. - Added "Require passkey" option to document settings and signer authentication settings. - Enhanced form submission and loading states for improved user experience. - **Refactor** - Optimized authentication components to efficiently support multiple authentication methods. - **Chores** - Updated and renamed functions and components for clarity and consistency across the authentication system. - Refined sorting options and database schema to support new authentication features. - **Bug Fixes** - Adjusted SignInForm to verify browser support for WebAuthn before proceeding. --- .../passkeys/create-passkey-dialog.tsx | 4 +- .../sign/[token]/document-action-auth-2fa.tsx | 172 ++++++++++++ .../[token]/document-action-auth-account.tsx | 79 ++++++ .../[token]/document-action-auth-dialog.tsx | 207 ++------------ .../[token]/document-action-auth-passkey.tsx | 252 ++++++++++++++++++ .../sign/[token]/document-auth-provider.tsx | 74 ++++- .../src/app/(signing)/sign/[token]/form.tsx | 8 +- .../2fa/enable-authenticator-app-dialog.tsx | 10 +- apps/web/src/components/forms/signin.tsx | 2 +- .../e2e/document-auth/action-auth.spec.ts | 6 +- .../e2e/pr-718-add-stepper-component.spec.ts | 22 +- packages/lib/constants/document-auth.ts | 21 +- packages/lib/next-auth/auth-options.ts | 4 +- .../create-passkey-authentication-options.ts | 76 ++++++ .../create-passkey-registration-options.ts | 4 +- .../auth/create-passkey-signin-options.ts | 4 +- .../lib/server-only/auth/create-passkey.ts | 4 +- .../lib/server-only/auth/find-passkeys.ts | 9 +- .../document/is-recipient-authorized.ts | 141 +++++++++- packages/lib/types/document-auth.ts | 40 ++- packages/lib/utils/authenticator.ts | 2 +- .../migration.sql | 18 ++ packages/prisma/schema.prisma | 15 +- packages/trpc/server/auth-router/router.ts | 21 ++ packages/trpc/server/auth-router/schema.ts | 6 + .../primitives/document-flow/add-settings.tsx | 4 + .../primitives/document-flow/add-signers.tsx | 4 + 27 files changed, 966 insertions(+), 243 deletions(-) create mode 100644 apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx create mode 100644 apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx create mode 100644 apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx create mode 100644 packages/lib/server-only/auth/create-passkey-authentication-options.ts create mode 100644 packages/prisma/migrations/20240327074701_add_secondary_verification_id/migration.sql diff --git a/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx b/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx index c07d638c0..f6db55e10 100644 --- a/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx @@ -38,6 +38,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type CreatePasskeyDialogProps = { trigger?: React.ReactNode; + onSuccess?: () => void; } & Omit; const ZCreatePasskeyFormSchema = z.object({ @@ -48,7 +49,7 @@ type TCreatePasskeyFormSchema = z.infer; const parser = new UAParser(); -export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogProps) => { +export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => { const [open, setOpen] = useState(false); const [formError, setFormError] = useState(null); @@ -84,6 +85,7 @@ export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogPr duration: 5000, }); + onSuccess?.(); setOpen(false); } catch (err) { if (err.name === 'NotAllowedError') { diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx new file mode 100644 index 000000000..98bcacf10 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { RecipientRole } from '@documenso/prisma/client'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DialogFooter } from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; + +import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog'; + +import { useRequiredDocumentAuthContext } from './document-auth-provider'; + +export type DocumentActionAuth2FAProps = { + actionTarget?: 'FIELD' | 'DOCUMENT'; + actionVerb?: string; + open: boolean; + onOpenChange: (value: boolean) => void; + onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; +}; + +const Z2FAAuthFormSchema = z.object({ + token: z + .string() + .min(4, { message: 'Token must at least 4 characters long' }) + .max(10, { message: 'Token must be at most 10 characters long' }), +}); + +type T2FAAuthFormSchema = z.infer; + +export const DocumentActionAuth2FA = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onReauthFormSubmit, + open, + onOpenChange, +}: DocumentActionAuth2FAProps) => { + const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = + useRequiredDocumentAuthContext(); + + const form = useForm({ + resolver: zodResolver(Z2FAAuthFormSchema), + defaultValues: { + token: '', + }, + }); + + const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false); + const [formErrorCode, setFormErrorCode] = useState(null); + + const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => { + try { + setIsCurrentlyAuthenticating(true); + + await onReauthFormSubmit({ + type: DocumentAuth.TWO_FACTOR_AUTH, + token, + }); + + setIsCurrentlyAuthenticating(false); + + onOpenChange(false); + } catch (err) { + setIsCurrentlyAuthenticating(false); + + const error = AppError.parseError(err); + setFormErrorCode(error.code); + + // Todo: Alert. + } + }; + + useEffect(() => { + form.reset({ + token: '', + }); + + setIs2FASetupSuccessful(false); + setFormErrorCode(null); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + if (!user?.twoFactorEnabled && !is2FASetupSuccessful) { + return ( +
+ + +

+ {recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' + ? 'You need to setup 2FA to mark this document as viewed.' + : `You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`} +

+ + {user?.identityProvider === 'DOCUMENSO' && ( +

+ By enabling 2FA, you will be required to enter a code from your authenticator app + every time you sign in. +

+ )} +
+
+ + + + + setIs2FASetupSuccessful(true)} /> + +
+ ); + } + + return ( +
+ +
+
+ ( + + 2FA token + + + + + + + + )} + /> + + {formErrorCode && ( + + Unauthorized + + We were unable to verify your details. Please try again or contact support + + + )} + + + + + + +
+
+
+ + ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx new file mode 100644 index 000000000..c09a60189 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; + +import { DateTime } from 'luxon'; +import { signOut } from 'next-auth/react'; + +import { RecipientRole } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DialogFooter } from '@documenso/ui/primitives/dialog'; + +import { useRequiredDocumentAuthContext } from './document-auth-provider'; + +export type DocumentActionAuthAccountProps = { + actionTarget?: 'FIELD' | 'DOCUMENT'; + actionVerb?: string; + onOpenChange: (value: boolean) => void; +}; + +export const DocumentActionAuthAccount = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onOpenChange, +}: DocumentActionAuthAccountProps) => { + const { recipient } = useRequiredDocumentAuthContext(); + + const [isSigningOut, setIsSigningOut] = useState(false); + + const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation(); + + const handleChangeAccount = async (email: string) => { + try { + setIsSigningOut(true); + + const encryptedEmail = await encryptSecondaryData({ + data: email, + expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), + }); + + await signOut({ + callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`, + }); + } catch { + setIsSigningOut(false); + + // Todo: Alert. + } + }; + + return ( +
+ + + {actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? ( + + To mark this document as viewed, you need to be logged in as{' '} + {recipient.email} + + ) : ( + + To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged + in as {recipient.email} + + )} + + + + + + + + +
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx index 7ab92f75c..0aed60be0 100644 --- a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx @@ -1,13 +1,4 @@ -/** - * Note: This file has some commented out stuff for password auth which is no longer possible. - * - * Leaving it here until after we add passkeys and 2FA since it can be reused. - */ -import { useState } from 'react'; - -import { DateTime } from 'luxon'; -import { signOut } from 'next-auth/react'; -import { match } from 'ts-pattern'; +import { P, match } from 'ts-pattern'; import { DocumentAuth, @@ -15,18 +6,17 @@ import { type TRecipientActionAuthTypes, } from '@documenso/lib/types/document-auth'; import type { FieldType } from '@documenso/prisma/client'; -import { trpc } from '@documenso/trpc/react'; -import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; -import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from '@documenso/ui/primitives/dialog'; +import { DocumentActionAuth2FA } from './document-action-auth-2fa'; +import { DocumentActionAuthAccount } from './document-action-auth-account'; +import { DocumentActionAuthPasskey } from './document-action-auth-passkey'; import { useRequiredDocumentAuthContext } from './document-auth-provider'; export type DocumentActionAuthDialogProps = { @@ -34,7 +24,6 @@ export type DocumentActionAuthDialogProps = { documentAuthType: TRecipientActionAuthTypes; description?: string; actionTarget: FieldType | 'DOCUMENT'; - isSubmitting?: boolean; open: boolean; onOpenChange: (value: boolean) => void; @@ -44,96 +33,24 @@ export type DocumentActionAuthDialogProps = { onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; }; -// const ZReauthFormSchema = z.object({ -// password: ZCurrentPasswordSchema, -// }); -// type TReauthFormSchema = z.infer; - export const DocumentActionAuthDialog = ({ title, description, documentAuthType, - // onReauthFormSubmit, - isSubmitting, open, onOpenChange, + onReauthFormSubmit, }: DocumentActionAuthDialogProps) => { - const { recipient } = useRequiredDocumentAuthContext(); - - // const form = useForm({ - // resolver: zodResolver(ZReauthFormSchema), - // defaultValues: { - // password: '', - // }, - // }); - - const [isSigningOut, setIsSigningOut] = useState(false); - - const isLoading = isSigningOut || isSubmitting; // || form.formState.isSubmitting; - - const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation(); - - // const [formErrorCode, setFormErrorCode] = useState(null); - // const onFormSubmit = async (_values: TReauthFormSchema) => { - // const documentAuthValue: TRecipientActionAuth = match(documentAuthType) - // // Todo: Add passkey. - // // .with(DocumentAuthType.PASSKEY, (type) => ({ - // // type, - // // value, - // // })) - // .otherwise((type) => ({ - // type, - // })); - - // try { - // await onReauthFormSubmit(documentAuthValue); - - // onOpenChange(false); - // } catch (e) { - // const error = AppError.parseError(e); - // setFormErrorCode(error.code); - - // // Suppress unauthorized errors since it's handled in this component. - // if (error.code === AppErrorCode.UNAUTHORIZED) { - // return; - // } - - // throw error; - // } - // }; - - const handleChangeAccount = async (email: string) => { - try { - setIsSigningOut(true); - - const encryptedEmail = await encryptSecondaryData({ - data: email, - expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), - }); - - await signOut({ - callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`, - }); - } catch { - setIsSigningOut(false); - - // Todo: Alert. - } - }; + const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentAuthContext(); const handleOnOpenChange = (value: boolean) => { - if (isLoading) { + if (isCurrentlyAuthenticating) { return; } onOpenChange(value); }; - // useEffect(() => { - // form.reset(); - // setFormErrorCode(null); - // }, [open, form]); - return ( @@ -141,100 +58,32 @@ export const DocumentActionAuthDialog = ({ {title || 'Sign field'} - {description || `Reauthentication is required to sign the field`} + {description || 'Reauthentication is required to sign this field'} - {match(documentAuthType) - .with(DocumentAuth.ACCOUNT, () => ( -
- - - To sign this field, you need to be logged in as {recipient.email} - - - - - - - - -
+ {match({ documentAuthType, user }) + .with( + { documentAuthType: DocumentAuth.ACCOUNT }, + { user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in. + () => , + ) + .with({ documentAuthType: DocumentAuth.PASSKEY }, () => ( + )) - .with(DocumentAuth.EXPLICIT_NONE, () => null) + .with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => ( + + )) + .with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null) .exhaustive()} - - {/*
- -
- - Email - - - - - - - ( - - Password - - - - - - - - )} - /> - - {formErrorCode && ( - - {match(formErrorCode) - .with(AppErrorCode.UNAUTHORIZED, () => ( - <> - Unauthorized - - We were unable to verify your details. Please ensure the details are - correct - - - )) - .otherwise(() => ( - <> - Something went wrong - - We were unable to sign this field at this time. Please try again or - contact support. - - - ))} - - )} - - - - - - -
-
- */}
); diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx new file mode 100644 index 000000000..da1be1f38 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx @@ -0,0 +1,252 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'; +import { Loader } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { RecipientRole } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DialogFooter } from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; + +import { CreatePasskeyDialog } from '~/app/(dashboard)/settings/security/passkeys/create-passkey-dialog'; + +import { useRequiredDocumentAuthContext } from './document-auth-provider'; + +export type DocumentActionAuthPasskeyProps = { + actionTarget?: 'FIELD' | 'DOCUMENT'; + actionVerb?: string; + open: boolean; + onOpenChange: (value: boolean) => void; + onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; +}; + +const ZPasskeyAuthFormSchema = z.object({ + passkeyId: z.string(), +}); + +type TPasskeyAuthFormSchema = z.infer; + +export const DocumentActionAuthPasskey = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onReauthFormSubmit, + open, + onOpenChange, +}: DocumentActionAuthPasskeyProps) => { + const { + recipient, + passkeyData, + preferredPasskeyId, + setPreferredPasskeyId, + isCurrentlyAuthenticating, + setIsCurrentlyAuthenticating, + refetchPasskeys, + } = useRequiredDocumentAuthContext(); + + const form = useForm({ + resolver: zodResolver(ZPasskeyAuthFormSchema), + defaultValues: { + passkeyId: preferredPasskeyId || '', + }, + }); + + const { mutateAsync: createPasskeyAuthenticationOptions } = + trpc.auth.createPasskeyAuthenticationOptions.useMutation(); + + const [formErrorCode, setFormErrorCode] = useState(null); + + const onFormSubmit = async ({ passkeyId }: TPasskeyAuthFormSchema) => { + try { + setPreferredPasskeyId(passkeyId); + setIsCurrentlyAuthenticating(true); + + const { options, tokenReference } = await createPasskeyAuthenticationOptions({ + preferredPasskeyId: passkeyId, + }); + + const authenticationResponse = await startAuthentication(options); + + await onReauthFormSubmit({ + type: DocumentAuth.PASSKEY, + authenticationResponse, + tokenReference, + }); + + setIsCurrentlyAuthenticating(false); + + onOpenChange(false); + } catch (err) { + setIsCurrentlyAuthenticating(false); + + if (err.name === 'NotAllowedError') { + return; + } + + const error = AppError.parseError(err); + setFormErrorCode(error.code); + + // Todo: Alert. + } + }; + + useEffect(() => { + form.reset({ + passkeyId: preferredPasskeyId || '', + }); + + setFormErrorCode(null); + }, [open, form, preferredPasskeyId]); + + if (!browserSupportsWebAuthn()) { + return ( +
+ + + Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '} + this {actionTarget.toLowerCase()}. + + + + + + +
+ ); + } + + if (passkeyData.isInitialLoading || (passkeyData.isError && passkeyData.passkeys.length === 0)) { + return ( +
+ +
+ ); + } + + if (passkeyData.isError) { + return ( +
+ + Something went wrong while loading your passkeys. + + + + + + + +
+ ); + } + + if (passkeyData.passkeys.length === 0) { + return ( +
+ + + {recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' + ? 'You need to setup a passkey to mark this document as viewed.' + : `You need to setup a passkey to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`} + + + + + + + refetchPasskeys()} + trigger={} + /> + +
+ ); + } + + return ( +
+ +
+
+ ( + + Passkey + + + + + + + + )} + /> + + {formErrorCode && ( + + Unauthorized + + We were unable to verify your details. Please try again or contact support + + + )} + + + + + + +
+
+
+ + ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx index c216f3905..86f673db0 100644 --- a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx @@ -1,10 +1,10 @@ 'use client'; -import { createContext, useContext, useMemo, useState } from 'react'; +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { match } from 'ts-pattern'; -import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; +import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth'; import type { TDocumentAuthOptions, TRecipientAccessAuthTypes, @@ -13,11 +13,25 @@ import type { } from '@documenso/lib/types/document-auth'; import { DocumentAuth } from '@documenso/lib/types/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; -import { type Document, FieldType, type Recipient, type User } from '@documenso/prisma/client'; +import { + type Document, + FieldType, + type Passkey, + type Recipient, + type User, +} from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog'; import { DocumentActionAuthDialog } from './document-action-auth-dialog'; +type PasskeyData = { + passkeys: Omit[]; + isInitialLoading: boolean; + isRefetching: boolean; + isError: boolean; +}; + export type DocumentAuthContextValue = { executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise; document: Document; @@ -29,7 +43,13 @@ export type DocumentAuthContextValue = { derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null; derivedRecipientActionAuth: TRecipientActionAuthTypes | null; isAuthRedirectRequired: boolean; + isCurrentlyAuthenticating: boolean; + setIsCurrentlyAuthenticating: (_value: boolean) => void; + passkeyData: PasskeyData; + preferredPasskeyId: string | null; + setPreferredPasskeyId: (_value: string | null) => void; user?: User | null; + refetchPasskeys: () => Promise; }; const DocumentAuthContext = createContext(null); @@ -64,6 +84,9 @@ export const DocumentAuthProvider = ({ const [document, setDocument] = useState(initialDocument); const [recipient, setRecipient] = useState(initialRecipient); + const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false); + const [preferredPasskeyId, setPreferredPasskeyId] = useState(null); + const { documentAuthOption, recipientAuthOption, @@ -78,6 +101,23 @@ export const DocumentAuthProvider = ({ [document, recipient], ); + const passkeyQuery = trpc.auth.findPasskeys.useQuery( + { + perPage: MAXIMUM_PASSKEYS, + }, + { + keepPreviousData: true, + enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY, + }, + ); + + const passkeyData: PasskeyData = { + passkeys: passkeyQuery.data?.data || [], + isInitialLoading: passkeyQuery.isInitialLoading, + isRefetching: passkeyQuery.isRefetching, + isError: passkeyQuery.isError, + }; + const [documentAuthDialogPayload, setDocumentAuthDialogPayload] = useState(null); @@ -101,7 +141,7 @@ export const DocumentAuthProvider = ({ .with(DocumentAuth.EXPLICIT_NONE, () => ({ type: DocumentAuth.EXPLICIT_NONE, })) - .with(null, () => null) + .with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null) .exhaustive(); const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { @@ -124,11 +164,27 @@ export const DocumentAuthProvider = ({ }); }; + useEffect(() => { + const { passkeys } = passkeyData; + + if (!preferredPasskeyId && passkeys.length > 0) { + setPreferredPasskeyId(passkeys[0].id); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [passkeyData.passkeys]); + + // Assume that a user must be logged in for any auth requirements. const isAuthRedirectRequired = Boolean( - DOCUMENT_AUTH_TYPES[derivedRecipientActionAuth || '']?.isAuthRedirectRequired && - !preCalculatedActionAuthOptions, + derivedRecipientActionAuth && + derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE && + user?.email !== recipient.email, ); + const refetchPasskeys = async () => { + await passkeyQuery.refetch(); + }; + return ( {children} diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 2b9b9d294..70897a716 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -42,10 +42,10 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin const { mutateAsync: completeDocumentWithToken } = trpc.recipient.completeDocumentWithToken.useMutation(); - const { - handleSubmit, - formState: { isSubmitting }, - } = useForm(); + const { handleSubmit, formState } = useForm(); + + // Keep the loading state going if successful since the redirect may take some time. + const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful; const uninsertedFields = useMemo(() => { return sortFieldsByPosition(fields.filter((field) => !field.inserted)); diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx index 0a6aac5dc..ce0b66ba4 100644 --- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -41,8 +41,13 @@ export const ZEnable2FAForm = z.object({ export type TEnable2FAForm = z.infer; -export const EnableAuthenticatorAppDialog = () => { +export type EnableAuthenticatorAppDialogProps = { + onSuccess?: () => void; +}; + +export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => { const { toast } = useToast(); + const router = useRouter(); const [isOpen, setIsOpen] = useState(false); @@ -79,6 +84,7 @@ export const EnableAuthenticatorAppDialog = () => { const data = await enable2FA({ code: token }); setRecoveryCodes(data.recoveryCodes); + onSuccess?.(); toast({ title: 'Two-factor authentication enabled', @@ -89,7 +95,7 @@ export const EnableAuthenticatorAppDialog = () => { toast({ title: 'Unable to setup two-factor authentication', description: - 'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.', + 'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.', variant: 'destructive', }); } diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 6fa5492ac..8d4dd7cd0 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -124,7 +124,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign }; const onSignInWithPasskey = async () => { - if (!browserSupportsWebAuthn) { + if (!browserSupportsWebAuthn()) { toast({ title: 'Not supported', description: 'Passkeys are not supported on this browser', diff --git a/packages/app-tests/e2e/document-auth/action-auth.spec.ts b/packages/app-tests/e2e/document-auth/action-auth.spec.ts index 88ed1ac1d..b263dbd04 100644 --- a/packages/app-tests/e2e/document-auth/action-auth.spec.ts +++ b/packages/app-tests/e2e/document-auth/action-auth.spec.ts @@ -191,7 +191,7 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth' await page.locator(`#field-${field.id}`).getByRole('button').click(); await expect(page.getByRole('paragraph')).toContainText( - 'Reauthentication is required to sign the field', + 'Reauthentication is required to sign this field', ); await page.getByRole('button', { name: 'Cancel' }).click(); } @@ -260,7 +260,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au await page.locator(`#field-${field.id}`).getByRole('button').click(); await expect(page.getByRole('paragraph')).toContainText( - 'Reauthentication is required to sign the field', + 'Reauthentication is required to sign this field', ); await page.getByRole('button', { name: 'Cancel' }).click(); } @@ -371,7 +371,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an await page.locator(`#field-${field.id}`).getByRole('button').click(); await expect(page.getByRole('paragraph')).toContainText( - 'Reauthentication is required to sign the field', + 'Reauthentication is required to sign this field', ); await page.getByRole('button', { name: 'Cancel' }).click(); } diff --git a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts b/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts index 70b0cfe72..142133367 100644 --- a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts +++ b/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts @@ -1,13 +1,25 @@ import { expect, test } from '@playwright/test'; import path from 'node:path'; -import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email'; +import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@documenso/prisma/client'; import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from './fixtures/authentication'; +const getDocumentByToken = async (token: string) => { + return await prisma.document.findFirstOrThrow({ + where: { + Recipient: { + some: { + token, + }, + }, + }, + }); +}; + test(`[PR-718]: should be able to create a document`, async ({ page }) => { await page.goto('/signin'); @@ -246,7 +258,7 @@ test('should be able to create, send and sign a document', async ({ page }) => { await page.waitForURL(`/sign/${token}`); // Check if document has been viewed - const { status } = await getDocumentByToken({ token }); + const { status } = await getDocumentByToken(token); expect(status).toBe(DocumentStatus.PENDING); await page.getByRole('button', { name: 'Complete' }).click(); @@ -257,7 +269,7 @@ test('should be able to create, send and sign a document', async ({ page }) => { await expect(page.getByText('You have signed')).toBeVisible(); // Check if document has been signed - const { status: completedStatus } = await getDocumentByToken({ token }); + const { status: completedStatus } = await getDocumentByToken(token); expect(completedStatus).toBe(DocumentStatus.COMPLETED); }); @@ -331,7 +343,7 @@ test('should be able to create, send with redirect url, sign a document and redi await page.waitForURL(`/sign/${token}`); // Check if document has been viewed - const { status } = await getDocumentByToken({ token }); + const { status } = await getDocumentByToken(token); expect(status).toBe(DocumentStatus.PENDING); await page.getByRole('button', { name: 'Complete' }).click(); @@ -341,6 +353,6 @@ test('should be able to create, send with redirect url, sign a document and redi await page.waitForURL('https://documenso.com'); // Check if document has been signed - const { status: completedStatus } = await getDocumentByToken({ token }); + const { status: completedStatus } = await getDocumentByToken(token); expect(completedStatus).toBe(DocumentStatus.COMPLETED); }); diff --git a/packages/lib/constants/document-auth.ts b/packages/lib/constants/document-auth.ts index 81f22236e..77c8d7b58 100644 --- a/packages/lib/constants/document-auth.ts +++ b/packages/lib/constants/document-auth.ts @@ -4,26 +4,21 @@ import { DocumentAuth } from '../types/document-auth'; type DocumentAuthTypeData = { key: TDocumentAuth; value: string; - - /** - * Whether this authentication event will require the user to halt and - * redirect. - * - * Defaults to false. - */ - isAuthRedirectRequired?: boolean; }; export const DOCUMENT_AUTH_TYPES: Record = { [DocumentAuth.ACCOUNT]: { key: DocumentAuth.ACCOUNT, value: 'Require account', - isAuthRedirectRequired: true, }, - // [DocumentAuthType.PASSKEY]: { - // key: DocumentAuthType.PASSKEY, - // value: 'Require passkey', - // }, + [DocumentAuth.PASSKEY]: { + key: DocumentAuth.PASSKEY, + value: 'Require passkey', + }, + [DocumentAuth.TWO_FACTOR_AUTH]: { + key: DocumentAuth.TWO_FACTOR_AUTH, + value: 'Require 2FA', + }, [DocumentAuth.EXPLICIT_NONE]: { key: DocumentAuth.EXPLICIT_NONE, value: 'None (Overrides global settings)', diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 425c7e70a..6805eedbe 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -22,7 +22,7 @@ import { sendConfirmationToken } from '../server-only/user/send-confirmation-tok import type { TAuthenticationResponseJSONSchema } from '../types/webauthn'; import { ZAuthenticationResponseJSONSchema } from '../types/webauthn'; import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata'; -import { getAuthenticatorRegistrationOptions } from '../utils/authenticator'; +import { getAuthenticatorOptions } from '../utils/authenticator'; import { ErrorCode } from './error-codes'; export const NEXT_AUTH_OPTIONS: AuthOptions = { @@ -196,7 +196,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { const user = passkey.User; - const { rpId, origin } = getAuthenticatorRegistrationOptions(); + const { rpId, origin } = getAuthenticatorOptions(); const verification = await verifyAuthenticationResponse({ response: requestBodyCrediential, diff --git a/packages/lib/server-only/auth/create-passkey-authentication-options.ts b/packages/lib/server-only/auth/create-passkey-authentication-options.ts new file mode 100644 index 000000000..e7c4178d6 --- /dev/null +++ b/packages/lib/server-only/auth/create-passkey-authentication-options.ts @@ -0,0 +1,76 @@ +import { generateAuthenticationOptions } from '@simplewebauthn/server'; +import type { AuthenticatorTransportFuture } from '@simplewebauthn/types'; +import { DateTime } from 'luxon'; + +import { prisma } from '@documenso/prisma'; +import type { Passkey } from '@documenso/prisma/client'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { getAuthenticatorOptions } from '../../utils/authenticator'; + +type CreatePasskeyAuthenticationOptions = { + userId: number; + + /** + * The ID of the passkey to request authentication for. + * + * If not set, we allow the browser client to handle choosing. + */ + preferredPasskeyId?: string; +}; + +export const createPasskeyAuthenticationOptions = async ({ + userId, + preferredPasskeyId, +}: CreatePasskeyAuthenticationOptions) => { + const { rpId, timeout } = getAuthenticatorOptions(); + + let preferredPasskey: Pick | null = null; + + if (preferredPasskeyId) { + preferredPasskey = await prisma.passkey.findFirst({ + where: { + userId, + id: preferredPasskeyId, + }, + select: { + credentialId: true, + transports: true, + }, + }); + + if (!preferredPasskey) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Requested passkey not found'); + } + } + + const options = await generateAuthenticationOptions({ + rpID: rpId, + userVerification: 'preferred', + timeout, + allowCredentials: preferredPasskey + ? [ + { + id: preferredPasskey.credentialId, + type: 'public-key', + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + transports: preferredPasskey.transports as AuthenticatorTransportFuture[], + }, + ] + : undefined, + }); + + const { secondaryId } = await prisma.verificationToken.create({ + data: { + userId, + token: options.challenge, + expires: DateTime.now().plus({ minutes: 2 }).toJSDate(), + identifier: 'PASSKEY_CHALLENGE', + }, + }); + + return { + tokenReference: secondaryId, + options, + }; +}; diff --git a/packages/lib/server-only/auth/create-passkey-registration-options.ts b/packages/lib/server-only/auth/create-passkey-registration-options.ts index 5c9d73b8a..8f2b3d53a 100644 --- a/packages/lib/server-only/auth/create-passkey-registration-options.ts +++ b/packages/lib/server-only/auth/create-passkey-registration-options.ts @@ -5,7 +5,7 @@ import { DateTime } from 'luxon'; import { prisma } from '@documenso/prisma'; import { PASSKEY_TIMEOUT } from '../../constants/auth'; -import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; +import { getAuthenticatorOptions } from '../../utils/authenticator'; type CreatePasskeyRegistrationOptions = { userId: number; @@ -27,7 +27,7 @@ export const createPasskeyRegistrationOptions = async ({ const { passkeys } = user; - const { rpName, rpId: rpID } = getAuthenticatorRegistrationOptions(); + const { rpName, rpId: rpID } = getAuthenticatorOptions(); const options = await generateRegistrationOptions({ rpName, diff --git a/packages/lib/server-only/auth/create-passkey-signin-options.ts b/packages/lib/server-only/auth/create-passkey-signin-options.ts index 03241edd0..e6f9a7152 100644 --- a/packages/lib/server-only/auth/create-passkey-signin-options.ts +++ b/packages/lib/server-only/auth/create-passkey-signin-options.ts @@ -3,14 +3,14 @@ import { DateTime } from 'luxon'; import { prisma } from '@documenso/prisma'; -import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; +import { getAuthenticatorOptions } from '../../utils/authenticator'; type CreatePasskeySigninOptions = { sessionId: string; }; export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => { - const { rpId, timeout } = getAuthenticatorRegistrationOptions(); + const { rpId, timeout } = getAuthenticatorOptions(); const options = await generateAuthenticationOptions({ rpID: rpId, diff --git a/packages/lib/server-only/auth/create-passkey.ts b/packages/lib/server-only/auth/create-passkey.ts index c493d8205..0ec86845d 100644 --- a/packages/lib/server-only/auth/create-passkey.ts +++ b/packages/lib/server-only/auth/create-passkey.ts @@ -7,7 +7,7 @@ import { UserSecurityAuditLogType } from '@documenso/prisma/client'; import { MAXIMUM_PASSKEYS } from '../../constants/auth'; import { AppError, AppErrorCode } from '../../errors/app-error'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; -import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; +import { getAuthenticatorOptions } from '../../utils/authenticator'; type CreatePasskeyOptions = { userId: number; @@ -64,7 +64,7 @@ export const createPasskey = async ({ throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired'); } - const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorRegistrationOptions(); + const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorOptions(); const verification = await verifyRegistrationResponse({ response: verificationResponse, diff --git a/packages/lib/server-only/auth/find-passkeys.ts b/packages/lib/server-only/auth/find-passkeys.ts index 26eac95c3..8f21c8aa6 100644 --- a/packages/lib/server-only/auth/find-passkeys.ts +++ b/packages/lib/server-only/auth/find-passkeys.ts @@ -11,6 +11,7 @@ export interface FindPasskeysOptions { orderBy?: { column: keyof Passkey; direction: 'asc' | 'desc'; + nulls?: Prisma.NullsOrder; }; } @@ -21,8 +22,9 @@ export const findPasskeys = async ({ perPage = 10, orderBy, }: FindPasskeysOptions) => { - const orderByColumn = orderBy?.column ?? 'name'; + const orderByColumn = orderBy?.column ?? 'lastUsedAt'; const orderByDirection = orderBy?.direction ?? 'desc'; + const orderByNulls: Prisma.NullsOrder | undefined = orderBy?.nulls ?? 'last'; const whereClause: Prisma.PasskeyWhereInput = { userId, @@ -41,7 +43,10 @@ export const findPasskeys = async ({ skip: Math.max(page - 1, 0) * perPage, take: perPage, orderBy: { - [orderByColumn]: orderByDirection, + [orderByColumn]: { + sort: orderByDirection, + nulls: orderByNulls, + }, }, select: { id: true, diff --git a/packages/lib/server-only/document/is-recipient-authorized.ts b/packages/lib/server-only/document/is-recipient-authorized.ts index 2c7e9b6e4..5da50d6c7 100644 --- a/packages/lib/server-only/document/is-recipient-authorized.ts +++ b/packages/lib/server-only/document/is-recipient-authorized.ts @@ -1,10 +1,15 @@ +import { verifyAuthenticationResponse } from '@simplewebauthn/server'; import { match } from 'ts-pattern'; import { prisma } from '@documenso/prisma'; import type { Document, Recipient } from '@documenso/prisma/client'; +import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token'; +import { AppError, AppErrorCode } from '../../errors/app-error'; import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth'; import { DocumentAuth } from '../../types/document-auth'; +import type { TAuthenticationResponseJSONSchema } from '../../types/webauthn'; +import { getAuthenticatorOptions } from '../../utils/authenticator'; import { extractDocumentAuthMethods } from '../../utils/document-auth'; type IsRecipientAuthorizedOptions = { @@ -63,17 +68,20 @@ export const isRecipientAuthorized = async ({ return true; } + // Create auth options when none are passed for account. + if (!authOptions && authMethod === DocumentAuth.ACCOUNT) { + authOptions = { + type: DocumentAuth.ACCOUNT, + }; + } + // Authentication required does not match provided method. - if (authOptions && authOptions.type !== authMethod) { + if (!authOptions || authOptions.type !== authMethod || !userId) { return false; } - return await match(authMethod) - .with(DocumentAuth.ACCOUNT, async () => { - if (userId === undefined) { - return false; - } - + return await match(authOptions) + .with({ type: DocumentAuth.ACCOUNT }, async () => { const recipientUser = await getUserByEmail(recipient.email); if (!recipientUser) { @@ -82,5 +90,124 @@ export const isRecipientAuthorized = async ({ return recipientUser.id === userId; }) + .with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => { + return await isPasskeyAuthValid({ + userId, + authenticationResponse, + tokenReference, + }); + }) + .with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => { + const user = await prisma.user.findFirst({ + where: { + id: userId, + }, + }); + + // Should not be possible. + if (!user) { + throw new AppError(AppErrorCode.NOT_FOUND, 'User not found'); + } + + return await verifyTwoFactorAuthenticationToken({ + user, + totpCode: token, + }); + }) .exhaustive(); }; + +type VerifyPasskeyOptions = { + /** + * The ID of the user who initiated the request. + */ + userId: number; + + /** + * The secondary ID of the verification token. + */ + tokenReference: string; + + /** + * The response from the passkey authenticator. + */ + authenticationResponse: TAuthenticationResponseJSONSchema; +}; + +/** + * Whether the provided passkey authenticator response is valid and the user is + * authenticated. + */ +const isPasskeyAuthValid = async (options: VerifyPasskeyOptions): Promise => { + return verifyPasskey(options) + .then(() => true) + .catch(() => false); +}; + +/** + * Verifies whether the provided passkey authenticator is valid and the user is + * authenticated. + * + * Will throw an error if the user should not be authenticated. + */ +const verifyPasskey = async ({ + userId, + tokenReference, + authenticationResponse, +}: VerifyPasskeyOptions): Promise => { + const passkey = await prisma.passkey.findFirst({ + where: { + credentialId: Buffer.from(authenticationResponse.id, 'base64'), + userId, + }, + }); + + if (!passkey) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found'); + } + + const verificationToken = await prisma.verificationToken + .delete({ + where: { + userId, + secondaryId: tokenReference, + }, + }) + .catch(() => null); + + if (!verificationToken) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Token not found'); + } + + if (verificationToken.expires < new Date()) { + throw new AppError(AppErrorCode.EXPIRED_CODE, 'Token expired'); + } + + const { rpId, origin } = getAuthenticatorOptions(); + + const verification = await verifyAuthenticationResponse({ + response: authenticationResponse, + expectedChallenge: verificationToken.token, + expectedOrigin: origin, + expectedRPID: rpId, + authenticator: { + credentialID: new Uint8Array(Array.from(passkey.credentialId)), + credentialPublicKey: new Uint8Array(passkey.credentialPublicKey), + counter: Number(passkey.counter), + }, + }).catch(() => null); // May want to log this for insights. + + if (verification?.verified !== true) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'User is not authorized'); + } + + await prisma.passkey.update({ + where: { + id: passkey.id, + }, + data: { + lastUsedAt: new Date(), + counter: verification.authenticationInfo.newCounter, + }, + }); +}; diff --git a/packages/lib/types/document-auth.ts b/packages/lib/types/document-auth.ts index 730806d0c..eccd119eb 100644 --- a/packages/lib/types/document-auth.ts +++ b/packages/lib/types/document-auth.ts @@ -1,9 +1,16 @@ import { z } from 'zod'; +import { ZAuthenticationResponseJSONSchema } from './webauthn'; + /** * All the available types of document authentication options for both access and action. */ -export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'EXPLICIT_NONE']); +export const ZDocumentAuthTypesSchema = z.enum([ + 'ACCOUNT', + 'PASSKEY', + 'TWO_FACTOR_AUTH', + 'EXPLICIT_NONE', +]); export const DocumentAuth = ZDocumentAuthTypesSchema.Enum; const ZDocumentAuthAccountSchema = z.object({ @@ -14,12 +21,25 @@ const ZDocumentAuthExplicitNoneSchema = z.object({ type: z.literal(DocumentAuth.EXPLICIT_NONE), }); +const ZDocumentAuthPasskeySchema = z.object({ + type: z.literal(DocumentAuth.PASSKEY), + authenticationResponse: ZAuthenticationResponseJSONSchema, + tokenReference: z.string().min(1), +}); + +const ZDocumentAuth2FASchema = z.object({ + type: z.literal(DocumentAuth.TWO_FACTOR_AUTH), + token: z.string().min(4).max(10), +}); + /** * All the document auth methods for both accessing and actioning. */ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [ ZDocumentAuthAccountSchema, ZDocumentAuthExplicitNoneSchema, + ZDocumentAuthPasskeySchema, + ZDocumentAuth2FASchema, ]); /** @@ -35,8 +55,16 @@ export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); * * Must keep these two in sync. */ -export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); // Todo: Add passkeys here. -export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); +export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ + ZDocumentAuthAccountSchema, + ZDocumentAuthPasskeySchema, + ZDocumentAuth2FASchema, +]); +export const ZDocumentActionAuthTypesSchema = z.enum([ + DocumentAuth.ACCOUNT, + DocumentAuth.PASSKEY, + DocumentAuth.TWO_FACTOR_AUTH, +]); /** * The recipient access auth methods. @@ -54,11 +82,15 @@ export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); * Must keep these two in sync. */ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [ - ZDocumentAuthAccountSchema, // Todo: Add passkeys here. + ZDocumentAuthAccountSchema, + ZDocumentAuthPasskeySchema, + ZDocumentAuth2FASchema, ZDocumentAuthExplicitNoneSchema, ]); export const ZRecipientActionAuthTypesSchema = z.enum([ DocumentAuth.ACCOUNT, + DocumentAuth.PASSKEY, + DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.EXPLICIT_NONE, ]); diff --git a/packages/lib/utils/authenticator.ts b/packages/lib/utils/authenticator.ts index b5563a4ed..b689d82e9 100644 --- a/packages/lib/utils/authenticator.ts +++ b/packages/lib/utils/authenticator.ts @@ -4,7 +4,7 @@ import { PASSKEY_TIMEOUT } from '../constants/auth'; /** * Extracts common fields to identify the RP (relying party) */ -export const getAuthenticatorRegistrationOptions = () => { +export const getAuthenticatorOptions = () => { const webAppBaseUrl = new URL(WEBAPP_BASE_URL); const rpId = webAppBaseUrl.hostname; diff --git a/packages/prisma/migrations/20240327074701_add_secondary_verification_id/migration.sql b/packages/prisma/migrations/20240327074701_add_secondary_verification_id/migration.sql new file mode 100644 index 000000000..7f7aa53c8 --- /dev/null +++ b/packages/prisma/migrations/20240327074701_add_secondary_verification_id/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - A unique constraint covering the columns `[secondaryId]` on the table `VerificationToken` will be added. If there are existing duplicate values, this will fail. + - The required column `secondaryId` was added to the `VerificationToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + +*/ +-- AlterTable +ALTER TABLE "VerificationToken" ADD COLUMN "secondaryId" TEXT; + +-- Set all null secondaryId fields to a uuid +UPDATE "VerificationToken" SET "secondaryId" = gen_random_uuid()::text WHERE "secondaryId" IS NULL; + +-- Restrict the VerificationToken to required +ALTER TABLE "VerificationToken" ALTER COLUMN "secondaryId" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_secondaryId_key" ON "VerificationToken"("secondaryId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index d632ae60e..868b8d8e1 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -126,13 +126,14 @@ model AnonymousVerificationToken { } model VerificationToken { - id Int @id @default(autoincrement()) - identifier String - token String @unique - expires DateTime - createdAt DateTime @default(now()) - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + secondaryId String @unique @default(cuid()) + identifier String + token String @unique + expires DateTime + createdAt DateTime @default(now()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } enum WebhookTriggerEvents { diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 165882856..f9a1795d7 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -7,6 +7,7 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey'; +import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options'; import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options'; import { createPasskeySigninOptions } from '@documenso/lib/server-only/auth/create-passkey-signin-options'; import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey'; @@ -19,6 +20,7 @@ import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract- import { authenticatedProcedure, procedure, router } from '../trpc'; import { + ZCreatePasskeyAuthenticationOptionsMutationSchema, ZCreatePasskeyMutationSchema, ZDeletePasskeyMutationSchema, ZFindPasskeysQuerySchema, @@ -115,6 +117,25 @@ export const authRouter = router({ } }), + createPasskeyAuthenticationOptions: authenticatedProcedure + .input(ZCreatePasskeyAuthenticationOptionsMutationSchema) + .mutation(async ({ ctx, input }) => { + try { + return await createPasskeyAuthenticationOptions({ + userId: ctx.user.id, + preferredPasskeyId: input?.preferredPasskeyId, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'We were unable to create the authentication options for the passkey. Please try again later.', + }); + } + }), + createPasskeyRegistrationOptions: authenticatedProcedure.mutation(async ({ ctx }) => { try { return await createPasskeyRegistrationOptions({ diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index d78b429fc..b84c5e1c9 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -40,6 +40,12 @@ export const ZCreatePasskeyMutationSchema = z.object({ verificationResponse: ZRegistrationResponseJSONSchema, }); +export const ZCreatePasskeyAuthenticationOptionsMutationSchema = z + .object({ + preferredPasskeyId: z.string().optional(), + }) + .optional(); + export const ZDeletePasskeyMutationSchema = z.object({ passkeyId: z.string().trim().min(1), }); diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index e56640f06..a5c0c5d9e 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -219,6 +219,10 @@ export const AddSettingsFormPartial = ({
  • Require account - The recipient must be signed in
  • +
  • + Require passkey - The recipient must have an account + and passkey configured via their settings +
  • None - No authentication required
  • diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 3d1263914..f815ca4cd 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -287,6 +287,10 @@ export const AddSignersFormPartial = ({ Require account - The recipient must be signed in +
  • + Require passkey - The recipient must have + an account and passkey configured via their settings +
  • None - No authentication required
  • From 48a8f5fe076358465c353f3f83cdf8b61693273f Mon Sep 17 00:00:00 2001 From: Mythie Date: Tue, 2 Apr 2024 14:16:36 +0700 Subject: [PATCH 04/11] chore: add disclosure --- .../(signing)/sign/[token]/sign-dialog.tsx | 42 +++++-- .../sign/[token]/signature-field.tsx | 4 + .../articles/signature-disclosure/page.tsx | 108 ++++++++++++++++++ .../components/general/signing-disclosure.tsx | 29 +++++ 4 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/app/(unauthenticated)/articles/signature-disclosure/page.tsx create mode 100644 apps/web/src/components/general/signing-disclosure.tsx diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx index 9b2877033..3e6961467 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -7,9 +7,11 @@ import { Dialog, DialogContent, DialogFooter, + DialogTitle, DialogTrigger, } from '@documenso/ui/primitives/dialog'; +import { SigningDisclosure } from '~/components/general/signing-disclosure'; import { truncateTitle } from '~/helpers/truncate-title'; export type SignDialogProps = { @@ -66,23 +68,39 @@ export const SignDialog = ({ {isComplete ? 'Complete' : 'Next field'} + -
    +
    - {role === RecipientRole.VIEWER && 'Mark Document as Viewed'} - {role === RecipientRole.SIGNER && 'Sign Document'} - {role === RecipientRole.APPROVER && 'Approve Document'} -
    -
    - {role === RecipientRole.VIEWER && - `You are about to finish viewing "${truncatedTitle}". Are you sure?`} - {role === RecipientRole.SIGNER && - `You are about to finish signing "${truncatedTitle}". Are you sure?`} - {role === RecipientRole.APPROVER && - `You are about to finish approving "${truncatedTitle}". Are you sure?`} + {role === RecipientRole.VIEWER && 'Complete Viewing'} + {role === RecipientRole.SIGNER && 'Complete Signing'} + {role === RecipientRole.APPROVER && 'Complete Approval'}
    +
    + +
    + {role === RecipientRole.VIEWER && ( + + You are about to complete viewing "{truncatedTitle}". +
    Are you sure? +
    + )} + {role === RecipientRole.SIGNER && ( + + You are about to complete signing "{truncatedTitle}". +
    Are you sure? +
    + )} + {role === RecipientRole.APPROVER && ( + + You are about to complete approving "{truncatedTitle}". +
    Are you sure? +
    + )}
    + +
    + +
    +
    +
    + ); +} diff --git a/apps/web/src/components/general/signing-disclosure.tsx b/apps/web/src/components/general/signing-disclosure.tsx new file mode 100644 index 000000000..bd1ef9707 --- /dev/null +++ b/apps/web/src/components/general/signing-disclosure.tsx @@ -0,0 +1,29 @@ +import type { HTMLAttributes } from 'react'; + +import Link from 'next/link'; + +import { cn } from '@documenso/ui/lib/utils'; + +export type SigningDisclosureProps = HTMLAttributes; + +export const SigningDisclosure = ({ className, ...props }: SigningDisclosureProps) => { + return ( +

    + By proceeding with your electronic signature, you acknowledge and consent that it will be used + to sign the given document and holds the same legal validity as a handwritten signature. By + completing the electronic signing process, you affirm your understanding and acceptance of + these conditions. + + Read the full{' '} + + signature disclosure + + . + +

    + ); +}; From 484f603a6bead91e092876bcd64c9d3fdb30a8c3 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Wed, 3 Apr 2024 12:35:47 +0700 Subject: [PATCH 05/11] chore: remove coming soon (#1074) **Description:** This PR removes the coming soon text from the connections bento card --------- Signed-off-by: Adithya Krishna --- .../(marketing)/share-connect-paid-widget-bento.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx b/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx index 144810203..666580cf1 100644 --- a/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx +++ b/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import Image from 'next/image'; @@ -51,7 +51,7 @@ export const ShareConnectPaidWidgetBento = ({

    - Connections (Soon). + Connections Create connections and automations with Zapier and more to integrate with your favorite tools.

    From 58481f66b8f8f63d0cd13c9c4cf53c6b6880e2e6 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 3 Apr 2024 15:18:36 +0800 Subject: [PATCH 06/11] fix: enforce 2FA for email password SSO linked accounts (#1072) ## Description Fixed issue where accounts that were initially created via email/password, then linked to an SSO account, can bypass the 2FA during login if they use their email password. ## Testing Performed Tested locally, and 2FA is now required for linked SSO accounts --- .../app-tests/e2e/pr-718-add-stepper-component.spec.ts | 4 ++-- packages/app-tests/e2e/test-auth-flow.spec.ts | 2 +- packages/lib/server-only/2fa/is-2fa-availble.ts | 8 ++------ 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts b/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts index 142133367..f24d74076 100644 --- a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts +++ b/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts @@ -262,7 +262,7 @@ test('should be able to create, send and sign a document', async ({ page }) => { expect(status).toBe(DocumentStatus.PENDING); await page.getByRole('button', { name: 'Complete' }).click(); - await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible(); + await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible(); await page.getByRole('button', { name: 'Sign' }).click(); await page.waitForURL(`/sign/${token}/complete`); @@ -347,7 +347,7 @@ test('should be able to create, send with redirect url, sign a document and redi expect(status).toBe(DocumentStatus.PENDING); await page.getByRole('button', { name: 'Complete' }).click(); - await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible(); + await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible(); await page.getByRole('button', { name: 'Sign' }).click(); await page.waitForURL('https://documenso.com'); diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/test-auth-flow.spec.ts index 9c9500053..3b07371f1 100644 --- a/packages/app-tests/e2e/test-auth-flow.spec.ts +++ b/packages/app-tests/e2e/test-auth-flow.spec.ts @@ -30,7 +30,7 @@ test('user can sign up with email and password', async ({ page }: { page: Page } } await page.getByRole('button', { name: 'Next', exact: true }).click(); - await page.getByLabel('Public profile username').fill('username-123'); + await page.getByLabel('Public profile username').fill(Date.now().toString()); await page.getByRole('button', { name: 'Complete', exact: true }).click(); diff --git a/packages/lib/server-only/2fa/is-2fa-availble.ts b/packages/lib/server-only/2fa/is-2fa-availble.ts index d06a0085d..605d45215 100644 --- a/packages/lib/server-only/2fa/is-2fa-availble.ts +++ b/packages/lib/server-only/2fa/is-2fa-availble.ts @@ -1,4 +1,4 @@ -import { User } from '@documenso/prisma/client'; +import type { User } from '@documenso/prisma/client'; import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; @@ -9,9 +9,5 @@ type IsTwoFactorAuthenticationEnabledOptions = { export const isTwoFactorAuthenticationEnabled = ({ user, }: IsTwoFactorAuthenticationEnabledOptions) => { - return ( - user.twoFactorEnabled && - user.identityProvider === 'DOCUMENSO' && - typeof DOCUMENSO_ENCRYPTION_KEY === 'string' - ); + return user.twoFactorEnabled && typeof DOCUMENSO_ENCRYPTION_KEY === 'string'; }; From d1ffcb00f307bac8c8763e3ac39c868dd668059b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 3 Apr 2024 15:32:34 +0800 Subject: [PATCH 07/11] feat: add axiom web vitals (#1071) ## Description Added support for Axiom web vitals https://axiom.co/docs/apps/vercel#web-vitals --- apps/marketing/next.config.js | 3 ++- apps/marketing/package.json | 1 + apps/marketing/src/app/layout.tsx | 3 +++ apps/web/next.config.js | 3 ++- apps/web/package.json | 1 + apps/web/src/app/layout.tsx | 3 +++ package-lock.json | 23 +++++++++++++++++++++++ 7 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/marketing/next.config.js b/apps/marketing/next.config.js index 940536efa..0f7b7ad5c 100644 --- a/apps/marketing/next.config.js +++ b/apps/marketing/next.config.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); const { withContentlayer } = require('next-contentlayer'); +const { withAxiom } = require('next-axiom'); const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`]; @@ -95,4 +96,4 @@ const config = { }, }; -module.exports = withContentlayer(config); +module.exports = withAxiom(withContentlayer(config)); diff --git a/apps/marketing/package.json b/apps/marketing/package.json index f6af3a9ff..b9cee4f45 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -26,6 +26,7 @@ "micro": "^10.0.1", "next": "14.0.3", "next-auth": "4.24.5", + "next-axiom": "^1.1.1", "next-contentlayer": "^0.3.4", "next-plausible": "^3.10.1", "perfect-freehand": "^1.2.0", diff --git a/apps/marketing/src/app/layout.tsx b/apps/marketing/src/app/layout.tsx index 99a1a6483..2790adb35 100644 --- a/apps/marketing/src/app/layout.tsx +++ b/apps/marketing/src/app/layout.tsx @@ -2,6 +2,7 @@ import { Suspense } from 'react'; import { Caveat, Inter } from 'next/font/google'; +import { AxiomWebVitals } from 'next-axiom'; import { PublicEnvScript } from 'next-runtime-env'; import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag'; @@ -67,6 +68,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo + + diff --git a/apps/web/next.config.js b/apps/web/next.config.js index d670dab47..af82847c0 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); const { version } = require('./package.json'); +const { withAxiom } = require('next-axiom'); const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`]; @@ -91,4 +92,4 @@ const config = { }, }; -module.exports = config; +module.exports = withAxiom(config); diff --git a/apps/web/package.json b/apps/web/package.json index 4f6617d1e..484659740 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "micro": "^10.0.1", "next": "14.0.3", "next-auth": "4.24.5", + "next-axiom": "^1.1.1", "next-plausible": "^3.10.1", "next-themes": "^0.2.1", "perfect-freehand": "^1.2.0", diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 7753e1e53..0f3d1607f 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import { Suspense } from 'react'; import { Caveat, Inter } from 'next/font/google'; +import { AxiomWebVitals } from 'next-axiom'; import { PublicEnvScript } from 'next-runtime-env'; import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag'; @@ -71,6 +72,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo + + diff --git a/package-lock.json b/package-lock.json index 3dc4e9776..5b1f8f0be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "micro": "^10.0.1", "next": "14.0.3", "next-auth": "4.24.5", + "next-axiom": "^1.1.1", "next-contentlayer": "^0.3.4", "next-plausible": "^3.10.1", "perfect-freehand": "^1.2.0", @@ -111,6 +112,7 @@ "micro": "^10.0.1", "next": "14.0.3", "next-auth": "4.24.5", + "next-axiom": "^1.1.1", "next-plausible": "^3.10.1", "next-themes": "^0.2.1", "perfect-freehand": "^1.2.0", @@ -16668,6 +16670,22 @@ } } }, + "node_modules/next-axiom": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/next-axiom/-/next-axiom-1.1.1.tgz", + "integrity": "sha512-0r/TJ+/zetD+uDc7B+2E7WpC86hEtQ1U+DuWYrP/JNmUz+ZdPFbrZgzOSqaZ6TwYbXP56VVlPfYwq1YsKHTHYQ==", + "dependencies": { + "remeda": "^1.29.0", + "whatwg-fetch": "^3.6.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "next": ">=13.4", + "react": ">=18.0.0" + } + }, "node_modules/next-contentlayer": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/next-contentlayer/-/next-contentlayer-0.3.4.tgz", @@ -22936,6 +22954,11 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", From 56c550c9d21de9f1c2b3e5693d53cb2fad2f70af Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 3 Apr 2024 17:13:35 +0800 Subject: [PATCH 08/11] fix: refactor tests (#1066) ## Changes Made - Refactor/optimise tests - Reduce flakiness - Add parallel tests (if there's enough CPU capacity) - Removed explicit worker count when running parallel tests. Defaults to 50% of CPU capacity. Might want to consider sharding the test across runners in the future as our tests grows. --- .../e2e/command-menu/document-search.spec.ts | 54 +++++ .../e2e/document-auth/access-auth.spec.ts | 1 - .../e2e/document-auth/action-auth.spec.ts | 2 +- .../stepper-component.spec.ts} | 111 ++++----- .../e2e/documents/delete-documents.spec.ts | 172 ++++++++++++++ .../app-tests/e2e/fixtures/authentication.ts | 33 +-- .../e2e/pr-711-deletion-of-documents.spec.ts | 159 ------------- ...dd-document-search-to-command-menu.spec.ts | 54 ----- .../app-tests/e2e/teams/manage-team.spec.ts | 8 +- .../e2e/templates/manage-templates.spec.ts | 2 +- .../auth-flow.spec.ts} | 7 +- .../delete-account.spec.ts} | 11 +- .../update-name.spec.ts} | 10 +- packages/app-tests/playwright.config.ts | 5 +- packages/prisma/seed/documents.ts | 9 +- .../seed/pr-711-deletion-of-documents.ts | 223 ------------------ ...713-add-document-search-to-command-menu.ts | 168 ------------- packages/prisma/seed/teams.ts | 5 +- packages/prisma/seed/users.ts | 16 +- 19 files changed, 318 insertions(+), 732 deletions(-) create mode 100644 packages/app-tests/e2e/command-menu/document-search.spec.ts rename packages/app-tests/e2e/{pr-718-add-stepper-component.spec.ts => document-flow/stepper-component.spec.ts} (81%) create mode 100644 packages/app-tests/e2e/documents/delete-documents.spec.ts delete mode 100644 packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts delete mode 100644 packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts rename packages/app-tests/e2e/{test-auth-flow.spec.ts => user/auth-flow.spec.ts} (88%) rename packages/app-tests/e2e/{test-delete-user.spec.ts => user/delete-account.spec.ts} (80%) rename packages/app-tests/e2e/{test-update-user-name.spec.ts => user/update-name.spec.ts} (81%) delete mode 100644 packages/prisma/seed/pr-711-deletion-of-documents.ts delete mode 100644 packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts diff --git a/packages/app-tests/e2e/command-menu/document-search.spec.ts b/packages/app-tests/e2e/command-menu/document-search.spec.ts new file mode 100644 index 000000000..bc1a934d0 --- /dev/null +++ b/packages/app-tests/e2e/command-menu/document-search.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test'; + +import { seedPendingDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test('[COMMAND_MENU]: should see sent documents', async ({ page }) => { + const user = await seedUser(); + const recipient = await seedUser(); + const document = await seedPendingDocument(user, [recipient]); + + await apiSignin({ + page, + email: user.email, + }); + + await page.keyboard.press('Meta+K'); + + await page.getByPlaceholder('Type a command or search...').first().fill(document.title); + await expect(page.getByRole('option', { name: document.title })).toBeVisible(); +}); + +test('[COMMAND_MENU]: should see received documents', async ({ page }) => { + const user = await seedUser(); + const recipient = await seedUser(); + const document = await seedPendingDocument(user, [recipient]); + + await apiSignin({ + page, + email: recipient.email, + }); + + await page.keyboard.press('Meta+K'); + + await page.getByPlaceholder('Type a command or search...').first().fill(document.title); + await expect(page.getByRole('option', { name: document.title })).toBeVisible(); +}); + +test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) => { + const user = await seedUser(); + const recipient = await seedUser(); + const document = await seedPendingDocument(user, [recipient]); + + await apiSignin({ + page, + email: recipient.email, + }); + + await page.keyboard.press('Meta+K'); + + await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email); + await expect(page.getByRole('option', { name: document.title })).toBeVisible(); +}); diff --git a/packages/app-tests/e2e/document-auth/access-auth.spec.ts b/packages/app-tests/e2e/document-auth/access-auth.spec.ts index 0306689ce..b57969b50 100644 --- a/packages/app-tests/e2e/document-auth/access-auth.spec.ts +++ b/packages/app-tests/e2e/document-auth/access-auth.spec.ts @@ -71,7 +71,6 @@ test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page await apiSignin({ page, email: recipientWithAccount.email, - redirectPath: '/', }); // Check that the one logged in is granted access. diff --git a/packages/app-tests/e2e/document-auth/action-auth.spec.ts b/packages/app-tests/e2e/document-auth/action-auth.spec.ts index b263dbd04..ac69a6c22 100644 --- a/packages/app-tests/e2e/document-auth/action-auth.spec.ts +++ b/packages/app-tests/e2e/document-auth/action-auth.spec.ts @@ -14,7 +14,7 @@ import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/user import { apiSignin, apiSignout } from '../fixtures/authentication'; -test.describe.configure({ mode: 'parallel' }); +test.describe.configure({ mode: 'parallel', timeout: 60000 }); test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }) => { const user = await seedUser(); diff --git a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts similarity index 81% rename from packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts rename to packages/app-tests/e2e/document-flow/stepper-component.spec.ts index f24d74076..ee6b160cc 100644 --- a/packages/app-tests/e2e/pr-718-add-stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -4,10 +4,13 @@ import path from 'node:path'; import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email'; import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@documenso/prisma/client'; -import { seedUser } from '@documenso/prisma/seed/users'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; -import { apiSignin } from './fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; +// Can't use the function in server-only/document due to it indirectly using +// require imports. const getDocumentByToken = async (token: string) => { return await prisma.document.findFirstOrThrow({ where: { @@ -20,11 +23,7 @@ const getDocumentByToken = async (token: string) => { }); }; -test(`[PR-718]: should be able to create a document`, async ({ page }) => { - await page.goto('/signin'); - - const documentTitle = `example-${Date.now()}.pdf`; - +test('[DOCUMENT_FLOW]: should be able to upload a PDF document', async ({ page }) => { const user = await seedUser(); await apiSignin({ @@ -32,7 +31,7 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => { email: user.email, }); - // Upload document + // Upload document. const [fileChooser] = await Promise.all([ page.waitForEvent('filechooser'), page.locator('input[type=file]').evaluate((e) => { @@ -42,10 +41,23 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => { }), ]); - await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf')); + await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf')); - // Wait to be redirected to the edit page + // Wait to be redirected to the edit page. await page.waitForURL(/\/documents\/\d+/); +}); + +test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) => { + const user = await seedUser(); + const document = await seedBlankDocument(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + const documentTitle = `example-${Date.now()}.pdf`; // Set general settings await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); @@ -91,34 +103,23 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => { // Assert document was created await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); + + await unseedUser(user.id); }); -test('should be able to create a document with multiple recipients', async ({ page }) => { - await page.goto('/signin'); - - const documentTitle = `example-${Date.now()}.pdf`; - +test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients', async ({ + page, +}) => { const user = await seedUser(); + const document = await seedBlankDocument(user); await apiSignin({ page, email: user.email, + redirectPath: `/documents/${document.id}/edit`, }); - // Upload document - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - page.locator('input[type=file]').evaluate((e) => { - if (e instanceof HTMLInputElement) { - e.click(); - } - }), - ]); - - await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf')); - - // Wait to be redirected to the edit page - await page.waitForURL(/\/documents\/\d+/); + const documentTitle = `example-${Date.now()}.pdf`; // Set title await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); @@ -187,34 +188,21 @@ test('should be able to create a document with multiple recipients', async ({ pa // Assert document was created await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); + + await unseedUser(user.id); }); -test('should be able to create, send and sign a document', async ({ page }) => { - await page.goto('/signin'); - - const documentTitle = `example-${Date.now()}.pdf`; - +test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', async ({ page }) => { const user = await seedUser(); + const document = await seedBlankDocument(user); await apiSignin({ page, email: user.email, + redirectPath: `/documents/${document.id}/edit`, }); - // Upload document - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - page.locator('input[type=file]').evaluate((e) => { - if (e instanceof HTMLInputElement) { - e.click(); - } - }), - ]); - - await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf')); - - // Wait to be redirected to the edit page - await page.waitForURL(/\/documents\/\d+/); + const documentTitle = `example-${Date.now()}.pdf`; // Set title await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); @@ -271,36 +259,23 @@ test('should be able to create, send and sign a document', async ({ page }) => { // Check if document has been signed const { status: completedStatus } = await getDocumentByToken(token); expect(completedStatus).toBe(DocumentStatus.COMPLETED); + + await unseedUser(user.id); }); -test('should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({ +test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({ page, }) => { - await page.goto('/signin'); - - const documentTitle = `example-${Date.now()}.pdf`; - const user = await seedUser(); + const document = await seedBlankDocument(user); await apiSignin({ page, email: user.email, + redirectPath: `/documents/${document.id}/edit`, }); - // Upload document - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - page.locator('input[type=file]').evaluate((e) => { - if (e instanceof HTMLInputElement) { - e.click(); - } - }), - ]); - - await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf')); - - // Wait to be redirected to the edit page - await page.waitForURL(/\/documents\/\d+/); + const documentTitle = `example-${Date.now()}.pdf`; // Set title & advanced redirect await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); @@ -355,4 +330,6 @@ test('should be able to create, send with redirect url, sign a document and redi // Check if document has been signed const { status: completedStatus } = await getDocumentByToken(token); expect(completedStatus).toBe(DocumentStatus.COMPLETED); + + await unseedUser(user.id); }); diff --git a/packages/app-tests/e2e/documents/delete-documents.spec.ts b/packages/app-tests/e2e/documents/delete-documents.spec.ts new file mode 100644 index 000000000..3658f1bc9 --- /dev/null +++ b/packages/app-tests/e2e/documents/delete-documents.spec.ts @@ -0,0 +1,172 @@ +import { expect, test } from '@playwright/test'; + +import { + seedCompletedDocument, + seedDraftDocument, + seedPendingDocument, +} from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin, apiSignout } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'serial' }); + +const seedDeleteDocumentsTestRequirements = async () => { + const [sender, recipientA, recipientB] = await Promise.all([seedUser(), seedUser(), seedUser()]); + + const [draftDocument, pendingDocument, completedDocument] = await Promise.all([ + seedDraftDocument(sender, [recipientA, recipientB], { + createDocumentOptions: { title: 'Document 1 - Draft' }, + }), + seedPendingDocument(sender, [recipientA, recipientB], { + createDocumentOptions: { title: 'Document 1 - Pending' }, + }), + seedCompletedDocument(sender, [recipientA, recipientB], { + createDocumentOptions: { title: 'Document 1 - Completed' }, + }), + ]); + + return { + sender, + recipients: [recipientA, recipientB], + draftDocument, + pendingDocument, + completedDocument, + }; +}; + +test('[DOCUMENTS]: seeded documents should be visible', async ({ page }) => { + const { sender, recipients } = await seedDeleteDocumentsTestRequirements(); + + await apiSignin({ + page, + email: sender.email, + }); + + await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible(); + + await apiSignout({ page }); + + for (const recipient of recipients) { + await apiSignin({ + page, + email: recipient.email, + }); + + await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible(); + + await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible(); + + await apiSignout({ page }); + } +}); + +test('[DOCUMENTS]: deleting a completed document should not remove it from recipients', async ({ + page, +}) => { + const { sender, recipients } = await seedDeleteDocumentsTestRequirements(); + + await apiSignin({ + page, + email: sender.email, + }); + + // open actions menu + await page + .locator('tr', { hasText: 'Document 1 - Completed' }) + .getByRole('cell', { name: 'Download' }) + .getByRole('button') + .nth(1) + .click(); + + // delete document + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible(); + + await apiSignout({ page }); + + for (const recipient of recipients) { + await apiSignin({ + page, + email: recipient.email, + }); + + await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible(); + await page.getByRole('link', { name: 'Document 1 - Completed' }).click(); + await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible(); + + await apiSignout({ page }); + } +}); + +test('[DOCUMENTS]: deleting a pending document should remove it from recipients', async ({ + page, +}) => { + const { sender, pendingDocument } = await seedDeleteDocumentsTestRequirements(); + + await apiSignin({ + page, + email: sender.email, + }); + + // open actions menu + await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click(); + + // delete document + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible(); + + // signout + await apiSignout({ page }); + + for (const recipient of pendingDocument.Recipient) { + await apiSignin({ + page, + email: recipient.email, + }); + + await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible(); + + await page.goto(`/sign/${recipient.token}`); + await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible(); + + await page.goto('/documents'); + await page.waitForURL('/documents'); + + await apiSignout({ page }); + } +}); + +test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({ + page, +}) => { + const { sender } = await seedDeleteDocumentsTestRequirements(); + + await apiSignin({ + page, + email: sender.email, + }); + + // open actions menu + await page + .locator('tr', { hasText: 'Document 1 - Draft' }) + .getByRole('cell', { name: 'Edit' }) + .getByRole('button') + .click(); + + // delete document + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible(); + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible(); +}); diff --git a/packages/app-tests/e2e/fixtures/authentication.ts b/packages/app-tests/e2e/fixtures/authentication.ts index 9f3a50756..fe52b65d8 100644 --- a/packages/app-tests/e2e/fixtures/authentication.ts +++ b/packages/app-tests/e2e/fixtures/authentication.ts @@ -13,38 +13,11 @@ type LoginOptions = { redirectPath?: string; }; -export const manualLogin = async ({ - page, - email = 'example@documenso.com', - password = 'password', - redirectPath, -}: LoginOptions) => { - await page.goto(`${WEBAPP_BASE_URL}/signin`); - - await page.getByLabel('Email').click(); - await page.getByLabel('Email').fill(email); - - await page.getByLabel('Password', { exact: true }).fill(password); - await page.getByLabel('Password', { exact: true }).press('Enter'); - - if (redirectPath) { - await page.waitForURL(`${WEBAPP_BASE_URL}/documents`); - await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`); - } -}; - -export const manualSignout = async ({ page }: LoginOptions) => { - await page.waitForTimeout(1000); - await page.getByTestId('menu-switcher').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); - await page.waitForURL(`${WEBAPP_BASE_URL}/signin`); -}; - export const apiSignin = async ({ page, email = 'example@documenso.com', password = 'password', - redirectPath = '/', + redirectPath = '/documents', }: LoginOptions) => { const { request } = page.context(); @@ -59,9 +32,7 @@ export const apiSignin = async ({ }, }); - if (redirectPath) { - await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`); - } + await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`); }; export const apiSignout = async ({ page }: { page: Page }) => { diff --git a/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts b/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts deleted file mode 100644 index da95c66f0..000000000 --- a/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { expect, test } from '@playwright/test'; - -import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents'; - -import { manualLogin, manualSignout } from './fixtures/authentication'; - -test.describe.configure({ mode: 'serial' }); - -test('[PR-711]: seeded documents should be visible', async ({ page }) => { - const [sender, ...recipients] = TEST_USERS; - - await page.goto('/signin'); - - await page.getByLabel('Email').fill(sender.email); - await page.getByLabel('Password', { exact: true }).fill(sender.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - - await page.waitForURL('/documents'); - - await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible(); - await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible(); - await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible(); - - await manualSignout({ page }); - - for (const recipient of recipients) { - await page.waitForURL('/signin'); - await manualLogin({ page, email: recipient.email, password: recipient.password }); - - await page.waitForURL('/documents'); - - await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible(); - await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible(); - - await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible(); - - await manualSignout({ page }); - } -}); - -test('[PR-711]: deleting a completed document should not remove it from recipients', async ({ - page, -}) => { - const [sender, ...recipients] = TEST_USERS; - - await page.goto('/signin'); - - // sign in - await page.getByLabel('Email').fill(sender.email); - await page.getByLabel('Password', { exact: true }).fill(sender.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - - await page.waitForURL('/documents'); - - // open actions menu - await page - .locator('tr', { hasText: 'Document 1 - Completed' }) - .getByRole('cell', { name: 'Download' }) - .getByRole('button') - .nth(1) - .click(); - - // delete document - await page.getByRole('menuitem', { name: 'Delete' }).click(); - await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); - await page.getByRole('button', { name: 'Delete' }).click(); - - await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible(); - - await manualSignout({ page }); - - for (const recipient of recipients) { - await page.waitForURL('/signin'); - await page.goto('/signin'); - - // sign in - await page.getByLabel('Email').fill(recipient.email); - await page.getByLabel('Password', { exact: true }).fill(recipient.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - - await page.waitForURL('/documents'); - - await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible(); - - await page.goto(`/sign/completed-token-${recipients.indexOf(recipient)}`); - await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible(); - - await page.goto('/documents'); - await manualSignout({ page }); - } -}); - -test('[PR-711]: deleting a pending document should remove it from recipients', async ({ page }) => { - const [sender, ...recipients] = TEST_USERS; - - for (const recipient of recipients) { - await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`); - - await expect(page.getByText('Waiting for others to sign').nth(0)).toBeVisible(); - } - - await page.goto('/signin'); - - await manualLogin({ page, email: sender.email, password: sender.password }); - await page.waitForURL('/documents'); - - // open actions menu - await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click(); - - // delete document - await page.getByRole('menuitem', { name: 'Delete' }).click(); - await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); - await page.getByRole('button', { name: 'Delete' }).click(); - - await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible(); - - // signout - await manualSignout({ page }); - - for (const recipient of recipients) { - await page.waitForURL('/signin'); - - await manualLogin({ page, email: recipient.email, password: recipient.password }); - await page.waitForURL('/documents'); - - await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible(); - - await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`); - await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible(); - - await page.goto('/documents'); - await page.waitForURL('/documents'); - - await manualSignout({ page }); - } -}); - -test('[PR-711]: deleting a draft document should remove it without additional prompting', async ({ - page, -}) => { - const [sender] = TEST_USERS; - - await manualLogin({ page, email: sender.email, password: sender.password }); - await page.waitForURL('/documents'); - - // open actions menu - await page - .locator('tr', { hasText: 'Document 1 - Draft' }) - .getByRole('cell', { name: 'Edit' }) - .getByRole('button') - .click(); - - // delete document - await page.getByRole('menuitem', { name: 'Delete' }).click(); - await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible(); - await page.getByRole('button', { name: 'Delete' }).click(); - - await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible(); -}); diff --git a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts deleted file mode 100644 index 44cfe1e37..000000000 --- a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { expect, test } from '@playwright/test'; - -import { TEST_USERS } from '@documenso/prisma/seed/pr-713-add-document-search-to-command-menu'; - -test('[PR-713]: should see sent documents', async ({ page }) => { - const [user] = TEST_USERS; - - await page.goto('/signin'); - - await page.getByLabel('Email').fill(user.email); - await page.getByLabel('Password', { exact: true }).fill(user.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - - await page.waitForURL('/documents'); - - await page.keyboard.press('Meta+K'); - - await page.getByPlaceholder('Type a command or search...').first().fill('sent'); - await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible(); -}); - -test('[PR-713]: should see received documents', async ({ page }) => { - const [user] = TEST_USERS; - - await page.goto('/signin'); - - await page.getByLabel('Email').fill(user.email); - await page.getByLabel('Password', { exact: true }).fill(user.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - - await page.waitForURL('/documents'); - - await page.keyboard.press('Meta+K'); - - await page.getByPlaceholder('Type a command or search...').first().fill('received'); - await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible(); -}); - -test('[PR-713]: should be able to search by recipient', async ({ page }) => { - const [user, recipient] = TEST_USERS; - - await page.goto('/signin'); - - await page.getByLabel('Email').fill(user.email); - await page.getByLabel('Password', { exact: true }).fill(user.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - - await page.waitForURL('/documents'); - - await page.keyboard.press('Meta+K'); - - await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email); - await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible(); -}); diff --git a/packages/app-tests/e2e/teams/manage-team.spec.ts b/packages/app-tests/e2e/teams/manage-team.spec.ts index a1deb1995..7403ab9c9 100644 --- a/packages/app-tests/e2e/teams/manage-team.spec.ts +++ b/packages/app-tests/e2e/teams/manage-team.spec.ts @@ -11,6 +11,11 @@ test.describe.configure({ mode: 'parallel' }); test('[TEAMS]: create team', async ({ page }) => { const user = await seedUser(); + test.skip( + process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true', + 'Test skipped because billing is enabled.', + ); + await apiSignin({ page, email: user.email, @@ -26,9 +31,6 @@ test('[TEAMS]: create team', async ({ page }) => { await page.getByTestId('dialog-create-team-button').waitFor({ state: 'hidden' }); - const isCheckoutRequired = page.url().includes('pending'); - test.skip(isCheckoutRequired, 'Test skipped because billing is enabled.'); - // Goto new team settings page. await page.getByRole('row').filter({ hasText: teamId }).getByRole('link').nth(1).click(); diff --git a/packages/app-tests/e2e/templates/manage-templates.spec.ts b/packages/app-tests/e2e/templates/manage-templates.spec.ts index a89b308eb..a298d1e38 100644 --- a/packages/app-tests/e2e/templates/manage-templates.spec.ts +++ b/packages/app-tests/e2e/templates/manage-templates.spec.ts @@ -108,7 +108,7 @@ test('[TEMPLATES]: delete template', async ({ page }) => { await page.getByRole('button', { name: 'Delete' }).click(); await expect(page.getByText('Template deleted').first()).toBeVisible(); - await page.waitForTimeout(1000); + await page.reload(); } await unseedTeam(team.url); diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/user/auth-flow.spec.ts similarity index 88% rename from packages/app-tests/e2e/test-auth-flow.spec.ts rename to packages/app-tests/e2e/user/auth-flow.spec.ts index 3b07371f1..94338ec21 100644 --- a/packages/app-tests/e2e/test-auth-flow.spec.ts +++ b/packages/app-tests/e2e/user/auth-flow.spec.ts @@ -2,6 +2,7 @@ import { type Page, expect, test } from '@playwright/test'; import { extractUserVerificationToken, + seedTestEmail, seedUser, unseedUser, unseedUserByEmail, @@ -9,9 +10,9 @@ import { test.use({ storageState: { cookies: [], origins: [] } }); -test('user can sign up with email and password', async ({ page }: { page: Page }) => { +test('[USER] can sign up with email and password', async ({ page }: { page: Page }) => { const username = 'Test User'; - const email = `test-user-${Date.now()}@auth-flow.documenso.com`; + const email = seedTestEmail(); const password = 'Password123#'; await page.goto('/signup'); @@ -50,7 +51,7 @@ test('user can sign up with email and password', async ({ page }: { page: Page } await unseedUserByEmail(email); }); -test('user can login with user and password', async ({ page }: { page: Page }) => { +test('[USER] can sign in using email and password', async ({ page }: { page: Page }) => { const user = await seedUser(); await page.goto('/signin'); diff --git a/packages/app-tests/e2e/test-delete-user.spec.ts b/packages/app-tests/e2e/user/delete-account.spec.ts similarity index 80% rename from packages/app-tests/e2e/test-delete-user.spec.ts rename to packages/app-tests/e2e/user/delete-account.spec.ts index 6eb72bad9..e04283240 100644 --- a/packages/app-tests/e2e/test-delete-user.spec.ts +++ b/packages/app-tests/e2e/user/delete-account.spec.ts @@ -4,19 +4,16 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { seedUser } from '@documenso/prisma/seed/users'; -import { manualLogin } from './fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; -test('delete user', async ({ page }) => { +test('[USER] delete account', async ({ page }) => { const user = await seedUser(); - await manualLogin({ - page, - email: user.email, - redirectPath: '/settings', - }); + await apiSignin({ page, email: user.email, redirectPath: '/settings' }); await page.getByRole('button', { name: 'Delete Account' }).click(); await page.getByLabel('Confirm Email').fill(user.email); + await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled(); await page.getByRole('button', { name: 'Confirm Deletion' }).click(); diff --git a/packages/app-tests/e2e/test-update-user-name.spec.ts b/packages/app-tests/e2e/user/update-name.spec.ts similarity index 81% rename from packages/app-tests/e2e/test-update-user-name.spec.ts rename to packages/app-tests/e2e/user/update-name.spec.ts index 509db651b..ca26fbf3d 100644 --- a/packages/app-tests/e2e/test-update-user-name.spec.ts +++ b/packages/app-tests/e2e/user/update-name.spec.ts @@ -3,16 +3,12 @@ import { expect, test } from '@playwright/test'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { seedUser } from '@documenso/prisma/seed/users'; -import { manualLogin } from './fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; -test('update user name', async ({ page }) => { +test('[USER] update full name', async ({ page }) => { const user = await seedUser(); - await manualLogin({ - page, - email: user.email, - redirectPath: '/settings/profile', - }); + await apiSignin({ page, email: user.email, redirectPath: '/settings/profile' }); await page.getByLabel('Full Name').fill('John Doe'); diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts index 0796bb1e1..725f4bb04 100644 --- a/packages/app-tests/playwright.config.ts +++ b/packages/app-tests/playwright.config.ts @@ -17,12 +17,11 @@ export default defineConfig({ testDir: './e2e', /* Run tests in files in parallel */ fullyParallel: true, + workers: '50%', /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + retries: process.env.CI ? 2 : 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts index 1fceca900..6c1e698c5 100644 --- a/packages/prisma/seed/documents.ts +++ b/packages/prisma/seed/documents.ts @@ -213,7 +213,14 @@ export const seedPendingDocument = async ( }); } - return document; + return prisma.document.findFirstOrThrow({ + where: { + id: document.id, + }, + include: { + Recipient: true, + }, + }); }; export const seedPendingDocumentNoFields = async ({ diff --git a/packages/prisma/seed/pr-711-deletion-of-documents.ts b/packages/prisma/seed/pr-711-deletion-of-documents.ts deleted file mode 100644 index d2706b734..000000000 --- a/packages/prisma/seed/pr-711-deletion-of-documents.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { User } from '@prisma/client'; -import fs from 'node:fs'; -import path from 'node:path'; - -import { hashSync } from '@documenso/lib/server-only/auth/hash'; - -import { prisma } from '..'; -import { - DocumentDataType, - DocumentStatus, - FieldType, - Prisma, - ReadStatus, - SendStatus, - SigningStatus, -} from '../client'; - -const PULL_REQUEST_NUMBER = 711; -const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`; - -export const TEST_USERS = [ - { - name: 'Sender 1', - email: `sender1@${EMAIL_DOMAIN}`, - password: 'Password123', - }, - { - name: 'Sender 2', - email: `sender2@${EMAIL_DOMAIN}`, - password: 'Password123', - }, - { - name: 'Sender 3', - email: `sender3@${EMAIL_DOMAIN}`, - password: 'Password123', - }, -] as const; - -const examplePdf = fs - .readFileSync(path.join(__dirname, '../../../assets/example.pdf')) - .toString('base64'); - -export const seedDatabase = async () => { - const users = await Promise.all( - TEST_USERS.map(async (u) => - prisma.user.create({ - data: { - name: u.name, - email: u.email, - password: hashSync(u.password), - emailVerified: new Date(), - url: u.email, - }, - }), - ), - ); - - const [user1, user2, user3] = users; - - await createDraftDocument(user1, [user2, user3]); - await createPendingDocument(user1, [user2, user3]); - await createCompletedDocument(user1, [user2, user3]); -}; - -const createDraftDocument = async (sender: User, recipients: User[]) => { - const documentData = await prisma.documentData.create({ - data: { - type: DocumentDataType.BYTES_64, - data: examplePdf, - initialData: examplePdf, - }, - }); - - const document = await prisma.document.create({ - data: { - title: `[${PULL_REQUEST_NUMBER}] Document 1 - Draft`, - status: DocumentStatus.DRAFT, - documentDataId: documentData.id, - userId: sender.id, - }, - }); - - for (const recipient of recipients) { - const index = recipients.indexOf(recipient); - - await prisma.recipient.create({ - data: { - email: String(recipient.email), - name: String(recipient.name), - token: `draft-token-${index}`, - readStatus: ReadStatus.NOT_OPENED, - sendStatus: SendStatus.NOT_SENT, - signingStatus: SigningStatus.NOT_SIGNED, - signedAt: new Date(), - Document: { - connect: { - id: document.id, - }, - }, - Field: { - create: { - page: 1, - type: FieldType.NAME, - inserted: true, - customText: String(recipient.name), - positionX: new Prisma.Decimal(1), - positionY: new Prisma.Decimal(1), - width: new Prisma.Decimal(1), - height: new Prisma.Decimal(1), - documentId: document.id, - }, - }, - }, - }); - } -}; - -const createPendingDocument = async (sender: User, recipients: User[]) => { - const documentData = await prisma.documentData.create({ - data: { - type: DocumentDataType.BYTES_64, - data: examplePdf, - initialData: examplePdf, - }, - }); - - const document = await prisma.document.create({ - data: { - title: `[${PULL_REQUEST_NUMBER}] Document 1 - Pending`, - status: DocumentStatus.PENDING, - documentDataId: documentData.id, - userId: sender.id, - }, - }); - - for (const recipient of recipients) { - const index = recipients.indexOf(recipient); - - await prisma.recipient.create({ - data: { - email: String(recipient.email), - name: String(recipient.name), - token: `pending-token-${index}`, - readStatus: ReadStatus.OPENED, - sendStatus: SendStatus.SENT, - signingStatus: SigningStatus.SIGNED, - signedAt: new Date(), - Document: { - connect: { - id: document.id, - }, - }, - Field: { - create: { - page: 1, - type: FieldType.NAME, - inserted: true, - customText: String(recipient.name), - positionX: new Prisma.Decimal(1), - positionY: new Prisma.Decimal(1), - width: new Prisma.Decimal(1), - height: new Prisma.Decimal(1), - documentId: document.id, - }, - }, - }, - }); - } -}; - -const createCompletedDocument = async (sender: User, recipients: User[]) => { - const documentData = await prisma.documentData.create({ - data: { - type: DocumentDataType.BYTES_64, - data: examplePdf, - initialData: examplePdf, - }, - }); - - const document = await prisma.document.create({ - data: { - title: `[${PULL_REQUEST_NUMBER}] Document 1 - Completed`, - status: DocumentStatus.COMPLETED, - documentDataId: documentData.id, - completedAt: new Date(), - userId: sender.id, - }, - }); - - for (const recipient of recipients) { - const index = recipients.indexOf(recipient); - - await prisma.recipient.create({ - data: { - email: String(recipient.email), - name: String(recipient.name), - token: `completed-token-${index}`, - readStatus: ReadStatus.OPENED, - sendStatus: SendStatus.SENT, - signingStatus: SigningStatus.SIGNED, - signedAt: new Date(), - Document: { - connect: { - id: document.id, - }, - }, - Field: { - create: { - page: 1, - type: FieldType.NAME, - inserted: true, - customText: String(recipient.name), - positionX: new Prisma.Decimal(1), - positionY: new Prisma.Decimal(1), - width: new Prisma.Decimal(1), - height: new Prisma.Decimal(1), - documentId: document.id, - }, - }, - }, - }); - } -}; diff --git a/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts b/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts deleted file mode 100644 index 0fe27b703..000000000 --- a/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { User } from '@prisma/client'; -import fs from 'node:fs'; -import path from 'node:path'; - -import { hashSync } from '@documenso/lib/server-only/auth/hash'; - -import { prisma } from '..'; -import { - DocumentDataType, - DocumentStatus, - FieldType, - Prisma, - ReadStatus, - SendStatus, - SigningStatus, -} from '../client'; - -// -// https://github.com/documenso/documenso/pull/713 -// - -const PULL_REQUEST_NUMBER = 713; - -const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`; - -export const TEST_USERS = [ - { - name: 'User 1', - email: `user1@${EMAIL_DOMAIN}`, - password: 'Password123', - }, - { - name: 'User 2', - email: `user2@${EMAIL_DOMAIN}`, - password: 'Password123', - }, -] as const; - -const examplePdf = fs - .readFileSync(path.join(__dirname, '../../../assets/example.pdf')) - .toString('base64'); - -export const seedDatabase = async () => { - const users = await Promise.all( - TEST_USERS.map(async (u) => - prisma.user.create({ - data: { - name: u.name, - email: u.email, - password: hashSync(u.password), - emailVerified: new Date(), - url: u.email, - }, - }), - ), - ); - - const [user1, user2] = users; - - await createSentDocument(user1, [user2]); - await createReceivedDocument(user2, [user1]); -}; - -const createSentDocument = async (sender: User, recipients: User[]) => { - const documentData = await prisma.documentData.create({ - data: { - type: DocumentDataType.BYTES_64, - data: examplePdf, - initialData: examplePdf, - }, - }); - - const document = await prisma.document.create({ - data: { - title: `[${PULL_REQUEST_NUMBER}] Document - Sent`, - status: DocumentStatus.PENDING, - documentDataId: documentData.id, - userId: sender.id, - }, - }); - - for (const recipient of recipients) { - const index = recipients.indexOf(recipient); - - await prisma.recipient.create({ - data: { - email: String(recipient.email), - name: String(recipient.name), - token: `sent-token-${index}`, - readStatus: ReadStatus.NOT_OPENED, - sendStatus: SendStatus.SENT, - signingStatus: SigningStatus.NOT_SIGNED, - signedAt: new Date(), - Document: { - connect: { - id: document.id, - }, - }, - Field: { - create: { - page: 1, - type: FieldType.NAME, - inserted: true, - customText: String(recipient.name), - positionX: new Prisma.Decimal(1), - positionY: new Prisma.Decimal(1), - width: new Prisma.Decimal(1), - height: new Prisma.Decimal(1), - documentId: document.id, - }, - }, - }, - }); - } -}; - -const createReceivedDocument = async (sender: User, recipients: User[]) => { - const documentData = await prisma.documentData.create({ - data: { - type: DocumentDataType.BYTES_64, - data: examplePdf, - initialData: examplePdf, - }, - }); - - const document = await prisma.document.create({ - data: { - title: `[${PULL_REQUEST_NUMBER}] Document - Received`, - status: DocumentStatus.PENDING, - documentDataId: documentData.id, - userId: sender.id, - }, - }); - - for (const recipient of recipients) { - const index = recipients.indexOf(recipient); - - await prisma.recipient.create({ - data: { - email: String(recipient.email), - name: String(recipient.name), - token: `received-token-${index}`, - readStatus: ReadStatus.NOT_OPENED, - sendStatus: SendStatus.SENT, - signingStatus: SigningStatus.NOT_SIGNED, - signedAt: new Date(), - Document: { - connect: { - id: document.id, - }, - }, - Field: { - create: { - page: 1, - type: FieldType.NAME, - inserted: true, - customText: String(recipient.name), - positionX: new Prisma.Decimal(1), - positionY: new Prisma.Decimal(1), - width: new Prisma.Decimal(1), - height: new Prisma.Decimal(1), - documentId: document.id, - }, - }, - }, - }); - } -}; diff --git a/packages/prisma/seed/teams.ts b/packages/prisma/seed/teams.ts index 99b0df8d5..aaae866d0 100644 --- a/packages/prisma/seed/teams.ts +++ b/packages/prisma/seed/teams.ts @@ -1,8 +1,11 @@ +import { customAlphabet } from 'nanoid'; + import { prisma } from '..'; import { TeamMemberInviteStatus, TeamMemberRole } from '../client'; import { seedUser } from './users'; const EMAIL_DOMAIN = `test.documenso.com`; +const nanoid = customAlphabet('1234567890abcdef', 10); type SeedTeamOptions = { createTeamMembers?: number; @@ -13,7 +16,7 @@ export const seedTeam = async ({ createTeamMembers = 0, createTeamEmail, }: SeedTeamOptions = {}) => { - const teamUrl = `team-${Date.now()}`; + const teamUrl = `team-${nanoid()}`; const teamEmail = createTeamEmail === true ? `${teamUrl}@${EMAIL_DOMAIN}` : createTeamEmail; const teamOwner = await seedUser({ diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts index fd8706fea..9f7f80a71 100644 --- a/packages/prisma/seed/users.ts +++ b/packages/prisma/seed/users.ts @@ -1,3 +1,5 @@ +import { customAlphabet } from 'nanoid'; + import { hashSync } from '@documenso/lib/server-only/auth/hash'; import { prisma } from '..'; @@ -11,12 +13,22 @@ type SeedUserOptions = { verified?: boolean; }; +const nanoid = customAlphabet('1234567890abcdef', 10); + export const seedUser = async ({ - name = `user-${Date.now()}`, - email = `user-${Date.now()}@test.documenso.com`, + name, + email, password = 'password', verified = true, }: SeedUserOptions = {}) => { + if (!name) { + name = nanoid(); + } + + if (!email) { + email = `${nanoid()}@test.documenso.com`; + } + return await prisma.user.create({ data: { name, From 2ef619226e3ef710889af9cde16f2d5c5b42b783 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Thu, 4 Apr 2024 17:35:24 +0530 Subject: [PATCH 09/11] chore: remove duplicate env vars (#1075) **Description:** The `.env.example` had duplicate keys so removed them in this PR Signed-off-by: Adithya Krishna --- .env.example | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.env.example b/.env.example index 2fb7c3845..bc052aead 100644 --- a/.env.example +++ b/.env.example @@ -40,16 +40,6 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS= # OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport. NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS= -# [[SIGNING]] -# OPTIONAL: Defines the signing transport to use. Available options: local (default) -NEXT_PRIVATE_SIGNING_TRANSPORT="local" -# OPTIONAL: Defines the passphrase for the signing certificate. -NEXT_PRIVATE_SIGNING_PASSPHRASE= -# OPTIONAL: Defines the file contents for the signing certificate as a base64 encoded string. -NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS= -# OPTIONAL: Defines the file path for the signing certificate. defaults to ./example/cert.p12 -NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH= - # [[STORAGE]] # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 NEXT_PUBLIC_UPLOAD_TRANSPORT="database" From d4a7eb299e38cd5be16b64b940587be4d566e80d Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 4 Apr 2024 21:18:55 +0800 Subject: [PATCH 10/11] chore: add 2FA reauth docs (#1076) ## Description Update the tooltips to show documentation for 2FA --- packages/ui/primitives/document-flow/add-settings.tsx | 4 ++++ packages/ui/primitives/document-flow/add-signers.tsx | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index a5c0c5d9e..ea962dee5 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -223,6 +223,10 @@ export const AddSettingsFormPartial = ({ Require passkey - The recipient must have an account and passkey configured via their settings +
  • + Require 2FA - The recipient must have an account and + 2FA enabled via their settings +
  • None - No authentication required
  • diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index f815ca4cd..7af4a06bc 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -291,6 +291,10 @@ export const AddSignersFormPartial = ({ Require passkey - The recipient must have an account and passkey configured via their settings +
  • + Require 2FA - The recipient must have an + account and 2FA enabled via their settings +
  • None - No authentication required
  • From b87154001a8143ad194a2a26ed1078fa57e40473 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Thu, 4 Apr 2024 22:00:39 +0530 Subject: [PATCH 11/11] feat: Ability to send team invitation in bulk (#930) fixes #923 https://github.com/documenso/documenso/assets/81948346/9f7cf419-91ec-4f43-b2c7-6fd3d0c13bfe --------- Co-authored-by: David Nguyen --- apps/web/package.json | 2 + .../dialogs/invite-team-member-dialog.tsx | 323 +++++++++++++----- package-lock.json | 16 + 3 files changed, 255 insertions(+), 86 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 484659740..71b480000 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "next-axiom": "^1.1.1", "next-plausible": "^3.10.1", "next-themes": "^0.2.1", + "papaparse": "^5.4.1", "perfect-freehand": "^1.2.0", "posthog-js": "^1.75.3", "posthog-node": "^3.1.1", @@ -59,6 +60,7 @@ "@types/formidable": "^2.0.6", "@types/luxon": "^3.3.1", "@types/node": "20.1.0", + "@types/papaparse": "^5.3.14", "@types/react": "18.2.18", "@types/react-dom": "18.2.7", "@types/ua-parser-js": "^0.7.39", diff --git a/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx index 482142c99..4adceda3d 100644 --- a/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx +++ b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx @@ -1,19 +1,22 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; -import { Mail, PlusCircle, Trash } from 'lucide-react'; +import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react'; +import Papa, { type ParseResult } from 'papaparse'; import { useFieldArray, useForm } from 'react-hook-form'; import { z } from 'zod'; +import { downloadFile } from '@documenso/lib/client-only/download-file'; import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; import { TeamMemberRole } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Dialog, DialogContent, @@ -39,6 +42,7 @@ import { SelectTrigger, SelectValue, } from '@documenso/ui/primitives/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type InviteTeamMembersDialogProps = { @@ -51,18 +55,45 @@ const ZInviteTeamMembersFormSchema = z .object({ invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations, }) - .refine( - (schema) => { - const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase()); + // Display exactly which rows are duplicates. + .superRefine((items, ctx) => { + const uniqueEmails = new Map(); - return new Set(emails).size === emails.length; - }, - // Dirty hack to handle errors when .root is populated for an array type - { message: 'Members must have unique emails', path: ['members__root'] }, - ); + for (const [index, invitation] of items.invitations.entries()) { + const email = invitation.email.toLowerCase(); + + const firstFoundIndex = uniqueEmails.get(email); + + if (firstFoundIndex === undefined) { + uniqueEmails.set(email, index); + continue; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Emails must be unique', + path: ['invitations', index, 'email'], + }); + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Emails must be unique', + path: ['invitations', firstFoundIndex, 'email'], + }); + } + }); type TInviteTeamMembersFormSchema = z.infer; +type TabTypes = 'INDIVIDUAL' | 'BULK'; + +const ZImportTeamMemberSchema = z.array( + z.object({ + email: z.string().email(), + role: z.nativeEnum(TeamMemberRole), + }), +); + export const InviteTeamMembersDialog = ({ currentUserTeamRole, teamId, @@ -70,6 +101,8 @@ export const InviteTeamMembersDialog = ({ ...props }: InviteTeamMembersDialogProps) => { const [open, setOpen] = useState(false); + const fileInputRef = useRef(null); + const [invitationType, setInvitationType] = useState('INDIVIDUAL'); const { toast } = useToast(); @@ -130,9 +163,75 @@ export const InviteTeamMembersDialog = ({ useEffect(() => { if (!open) { form.reset(); + setInvitationType('INDIVIDUAL'); } }, [open, form]); + const onFileInputChange = (e: React.ChangeEvent) => { + if (!e.target.files?.length) { + return; + } + + const csvFile = e.target.files[0]; + + Papa.parse(csvFile, { + skipEmptyLines: true, + comments: 'Work email,Job title', + complete: (results: ParseResult) => { + const members = results.data.map((row) => { + const [email, role] = row; + + return { + email: email.trim(), + role: role.trim().toUpperCase(), + }; + }); + + // Remove the first row if it contains the headers. + if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') { + members.shift(); + } + + try { + const importedInvitations = ZImportTeamMemberSchema.parse(members); + + form.setValue('invitations', importedInvitations); + form.clearErrors('invitations'); + + setInvitationType('INDIVIDUAL'); + } catch (err) { + console.error(err.message); + + toast({ + variant: 'destructive', + title: 'Something went wrong', + description: 'Please check the CSV file and make sure it is according to our format', + }); + } + }, + }); + }; + + const downloadTemplate = () => { + const data = [ + { email: 'admin@documenso.com', role: 'Admin' }, + { email: 'manager@documenso.com', role: 'Manager' }, + { email: 'member@documenso.com', role: 'Member' }, + ]; + + const csvContent = + 'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n'); + + const blob = new Blob([csvContent], { + type: 'text/csv', + }); + + downloadFile({ + filename: 'documenso-team-member-invites-template.csv', + data: blob, + }); + }; + return ( -
    - -
    - {teamMemberInvites.map((teamMemberInvite, index) => ( -
    - ( - - {index === 0 && Email address} - - - - - - )} - /> + setInvitationType(value as TabTypes)} + > + + + + Invite Members + - ( - - {index === 0 && Role} - - - - - - )} - /> + + + +
    +
    + {teamMemberInvites.map((teamMemberInvite, index) => ( +
    + ( + + {index === 0 && Email address} + + + + + + )} + /> - +
    + ))} +
    + + -
    - ))} + + Add more + - + + + + + +
    +
    + + + + +
    + + fileInputRef.current?.click()} + > + + +

    Click here to upload

    + + +
    +
    - - - - - - +
    +
    +
    ); diff --git a/package-lock.json b/package-lock.json index 5b1f8f0be..1d8663908 100644 --- a/package-lock.json +++ b/package-lock.json @@ -115,6 +115,7 @@ "next-axiom": "^1.1.1", "next-plausible": "^3.10.1", "next-themes": "^0.2.1", + "papaparse": "^5.4.1", "perfect-freehand": "^1.2.0", "posthog-js": "^1.75.3", "posthog-node": "^3.1.1", @@ -138,6 +139,7 @@ "@types/formidable": "^2.0.6", "@types/luxon": "^3.3.1", "@types/node": "20.1.0", + "@types/papaparse": "^5.3.14", "@types/react": "18.2.18", "@types/react-dom": "18.2.7", "@types/ua-parser-js": "^0.7.39", @@ -8081,6 +8083,15 @@ "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==" }, + "node_modules/@types/papaparse": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz", + "integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse5": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", @@ -17254,6 +17265,11 @@ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, + "node_modules/papaparse": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", + "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",