diff --git a/.env.example b/.env.example index a9b600c03..2fb7c3845 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,7 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5 NEXT_PRIVATE_STRIPE_API_KEY= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= +NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID= # [[FEATURES]] # OPTIONAL: Leave blank to disable PostHog and feature flags. diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index 48b967de8..3f1c11259 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -161,6 +161,7 @@ export const SinglePlayerClient = () => { signingStatus: 'NOT_SIGNED', sendStatus: 'NOT_SENT', role: 'SIGNER', + authOptions: null, }; const onFileDrop = async (file: File) => { diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 90f605602..2e2f0c889 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -8,19 +8,18 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META, } from '@documenso/lib/constants/trpc'; -import { DocumentStatus } from '@documenso/prisma/client'; import type { DocumentWithDetails } from '@documenso/prisma/types/document'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields'; import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types'; +import { AddSettingsFormPartial } from '@documenso/ui/primitives/document-flow/add-settings'; +import type { TAddSettingsFormSchema } from '@documenso/ui/primitives/document-flow/add-settings.types'; import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers'; import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types'; import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject'; import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types'; -import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title'; -import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; @@ -33,15 +32,17 @@ export type EditDocumentFormProps = { className?: string; initialDocument: DocumentWithDetails; documentRootPath: string; + isDocumentEnterprise: boolean; }; -type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject'; -const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'subject']; +type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject'; +const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields', 'subject']; export const EditDocumentForm = ({ className, initialDocument, documentRootPath, + isDocumentEnterprise, }: EditDocumentFormProps) => { const { toast } = useToast(); @@ -67,7 +68,7 @@ export const EditDocumentForm = ({ const { Recipient: recipients, Field: fields } = document; - const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation({ + const { mutateAsync: setSettingsForDocument } = trpc.document.setSettingsForDocument.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: (newData) => { utils.document.getDocumentWithDetailsById.setData( @@ -123,9 +124,9 @@ export const EditDocumentForm = ({ trpc.document.setPasswordForDocument.useMutation(); const documentFlow: Record = { - title: { - title: 'Add Title', - description: 'Add the title to the document.', + settings: { + title: 'General', + description: 'Configure general settings for the document.', stepIndex: 1, }, signers: { @@ -149,8 +150,7 @@ export const EditDocumentForm = ({ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined; - let initialStep: EditDocumentStep = - document.status === DocumentStatus.DRAFT ? 'title' : 'signers'; + let initialStep: EditDocumentStep = 'settings'; if ( searchParamStep && @@ -163,12 +163,23 @@ export const EditDocumentForm = ({ return initialStep; }); - const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => { + const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => { try { - await addTitle({ + const { timezone, dateFormat, redirectUrl } = data.meta; + + await setSettingsForDocument({ documentId: document.id, teamId: team?.id, - title: data.title, + data: { + title: data.title, + globalAccessAuth: data.globalAccessAuth ?? null, + globalActionAuth: data.globalActionAuth ?? null, + }, + meta: { + timezone, + dateFormat, + redirectUrl, + }, }); // Router refresh is here to clear the router cache for when navigating to /documents. @@ -180,7 +191,7 @@ export const EditDocumentForm = ({ toast({ title: 'Error', - description: 'An error occurred while updating title.', + description: 'An error occurred while updating the document settings.', variant: 'destructive', }); } @@ -191,7 +202,11 @@ export const EditDocumentForm = ({ await addSigners({ documentId: document.id, teamId: team?.id, - signers: data.signers, + signers: data.signers.map((signer) => ({ + ...signer, + // Explicitly set to null to indicate we want to remove auth if required. + actionAuth: signer.actionAuth || null, + })), }); // Router refresh is here to clear the router cache for when navigating to /documents. @@ -232,7 +247,7 @@ export const EditDocumentForm = ({ }; const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { - const { subject, message, timezone, dateFormat, redirectUrl } = data.meta; + const { subject, message } = data.meta; try { await sendDocument({ @@ -241,9 +256,6 @@ export const EditDocumentForm = ({ meta: { subject, message, - dateFormat, - timezone, - redirectUrl, }, }); @@ -310,24 +322,26 @@ export const EditDocumentForm = ({ currentStep={currentDocumentFlow.stepIndex} setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])} > - + + ); diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index a64831804..c13d8636b 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -6,7 +6,9 @@ import { getServerSession } from 'next-auth'; import { match } from 'ts-pattern'; import signingCelebration from '@documenso/assets/images/signing-celebration.png'; +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; @@ -17,6 +19,7 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { truncateTitle } from '~/helpers/truncate-title'; +import { SigningAuthPageView } from '../signing-auth-page'; import { DocumentPreviewButton } from './document-preview-button'; export type CompletedSigningPageProps = { @@ -32,8 +35,11 @@ export default async function CompletedSigningPage({ return notFound(); } + const { user } = await getServerComponentSession(); + const document = await getDocumentAndSenderByToken({ token, + requireAccessAuth: false, }).catch(() => null); if (!document || !document.documentData) { @@ -53,6 +59,17 @@ export default async function CompletedSigningPage({ return notFound(); } + const isDocumentAccessValid = await isRecipientAuthorized({ + type: 'ACCESS', + document, + recipient, + userId: user?.id, + }); + + if (!isDocumentAccessValid) { + return ; + } + const signatures = await getRecipientSignatures({ recipientId: recipient.id }); const recipientName = diff --git a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx index a06e7f2f9..dc1799bc1 100644 --- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx @@ -12,6 +12,8 @@ import { } from '@documenso/lib/constants/date-formats'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; @@ -54,16 +56,23 @@ export const DateField = ({ const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`; - const onSign = async () => { + const onSign = async (authOptions?: TRecipientActionAuth) => { try { await signFieldWithToken({ token: recipient.token, fieldId: field.id, value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, + authOptions, }); startTransition(() => router.refresh()); } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + console.error(err); toast({ 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 new file mode 100644 index 000000000..7ab92f75c --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx @@ -0,0 +1,241 @@ +/** + * 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 { + DocumentAuth, + type TRecipientActionAuth, + 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 { useRequiredDocumentAuthContext } from './document-auth-provider'; + +export type DocumentActionAuthDialogProps = { + title?: string; + documentAuthType: TRecipientActionAuthTypes; + description?: string; + actionTarget: FieldType | 'DOCUMENT'; + isSubmitting?: boolean; + open: boolean; + onOpenChange: (value: boolean) => void; + + /** + * The callback to run when the reauth form is filled out. + */ + 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, +}: 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 handleOnOpenChange = (value: boolean) => { + if (isLoading) { + return; + } + + onOpenChange(value); + }; + + // useEffect(() => { + // form.reset(); + // setFormErrorCode(null); + // }, [open, form]); + + return ( + + + + {title || 'Sign field'} + + + {description || `Reauthentication is required to sign the field`} + + + + {match(documentAuthType) + .with(DocumentAuth.ACCOUNT, () => ( +
+ + + To sign this field, you need to be logged in as {recipient.email} + + + + + + + + +
+ )) + .with(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-auth-provider.tsx b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx new file mode 100644 index 000000000..c216f3905 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { createContext, useContext, useMemo, useState } from 'react'; + +import { match } from 'ts-pattern'; + +import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; +import type { + TDocumentAuthOptions, + TRecipientAccessAuthTypes, + TRecipientActionAuthTypes, + TRecipientAuthOptions, +} 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 { DocumentActionAuthDialogProps } from './document-action-auth-dialog'; +import { DocumentActionAuthDialog } from './document-action-auth-dialog'; + +export type DocumentAuthContextValue = { + executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise; + document: Document; + documentAuthOption: TDocumentAuthOptions; + setDocument: (_value: Document) => void; + recipient: Recipient; + recipientAuthOption: TRecipientAuthOptions; + setRecipient: (_value: Recipient) => void; + derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null; + derivedRecipientActionAuth: TRecipientActionAuthTypes | null; + isAuthRedirectRequired: boolean; + user?: User | null; +}; + +const DocumentAuthContext = createContext(null); + +export const useDocumentAuthContext = () => { + return useContext(DocumentAuthContext); +}; + +export const useRequiredDocumentAuthContext = () => { + const context = useDocumentAuthContext(); + + if (!context) { + throw new Error('Document auth context is required'); + } + + return context; +}; + +export interface DocumentAuthProviderProps { + document: Document; + recipient: Recipient; + user?: User | null; + children: React.ReactNode; +} + +export const DocumentAuthProvider = ({ + document: initialDocument, + recipient: initialRecipient, + user, + children, +}: DocumentAuthProviderProps) => { + const [document, setDocument] = useState(initialDocument); + const [recipient, setRecipient] = useState(initialRecipient); + + const { + documentAuthOption, + recipientAuthOption, + derivedRecipientAccessAuth, + derivedRecipientActionAuth, + } = useMemo( + () => + extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }), + [document, recipient], + ); + + const [documentAuthDialogPayload, setDocumentAuthDialogPayload] = + useState(null); + + /** + * The pre calculated auth payload if the current user is authenticated correctly + * for the `derivedRecipientActionAuth`. + * + * Will be `null` if the user still requires authentication, or if they don't need + * authentication. + */ + const preCalculatedActionAuthOptions = match(derivedRecipientActionAuth) + .with(DocumentAuth.ACCOUNT, () => { + if (recipient.email !== user?.email) { + return null; + } + + return { + type: DocumentAuth.ACCOUNT, + }; + }) + .with(DocumentAuth.EXPLICIT_NONE, () => ({ + type: DocumentAuth.EXPLICIT_NONE, + })) + .with(null, () => null) + .exhaustive(); + + const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { + // Directly run callback if no auth required. + if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) { + await options.onReauthFormSubmit(); + return; + } + + // Run callback with precalculated auth options if available. + if (preCalculatedActionAuthOptions) { + setDocumentAuthDialogPayload(null); + await options.onReauthFormSubmit(preCalculatedActionAuthOptions); + return; + } + + // Request the required auth from the user. + setDocumentAuthDialogPayload({ + ...options, + }); + }; + + const isAuthRedirectRequired = Boolean( + DOCUMENT_AUTH_TYPES[derivedRecipientActionAuth || '']?.isAuthRedirectRequired && + !preCalculatedActionAuthOptions, + ); + + return ( + + {children} + + {documentAuthDialogPayload && derivedRecipientActionAuth && ( + setDocumentAuthDialogPayload(null)} + onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit} + actionTarget={documentAuthDialogPayload.actionTarget} + documentAuthType={derivedRecipientActionAuth} + /> + )} + + ); +}; + +type ExecuteActionAuthProcedureOptions = Omit< + DocumentActionAuthDialogProps, + 'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' +>; + +DocumentAuthProvider.displayName = 'DocumentAuthProvider'; diff --git a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx index d81116c21..bacfa5a16 100644 --- a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx @@ -7,6 +7,8 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; @@ -39,17 +41,24 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => { const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; - const onSign = async () => { + const onSign = async (authOptions?: TRecipientActionAuth) => { try { await signFieldWithToken({ token: recipient.token, fieldId: field.id, value: providedEmail ?? '', isBase64: false, + authOptions, }); startTransition(() => router.refresh()); } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + console.error(err); toast({ diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 7e6cf26b8..2b9b9d294 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -8,6 +8,7 @@ import { useSession } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; @@ -64,9 +65,20 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin return; } + await completeDocument(); + + // Reauth is currently not required for completing the document. + // await executeActionAuthProcedure({ + // onReauthFormSubmit: completeDocument, + // actionTarget: 'DOCUMENT', + // }); + }; + + const completeDocument = async (authOptions?: TRecipientActionAuth) => { await completeDocumentWithToken({ token: recipient.token, documentId: document.id, + authOptions, }); analytics.capture('App: Recipient has completed signing', { diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx index 9fd72da2d..f34fb6777 100644 --- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx @@ -7,7 +7,9 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; -import type { Recipient } from '@documenso/prisma/client'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { type Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; @@ -16,6 +18,7 @@ import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredSigningContext } from './provider'; import { SigningFieldContainer } from './signing-field-container'; @@ -32,6 +35,8 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredSigningContext(); + const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); + const [isPending, startTransition] = useTransition(); const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = @@ -47,9 +52,33 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { const [showFullNameModal, setShowFullNameModal] = useState(false); const [localFullName, setLocalFullName] = useState(''); - const onSign = async (source: 'local' | 'provider' = 'provider') => { + const onPreSign = () => { + if (!providedFullName) { + setShowFullNameModal(true); + return false; + } + + return true; + }; + + /** + * When the user clicks the sign button in the dialog where they enter their full name. + */ + const onDialogSignClick = () => { + setShowFullNameModal(false); + setProvidedFullName(localFullName); + + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localFullName), + actionTarget: field.type, + }); + }; + + const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => { try { - if (!providedFullName && !localFullName) { + const value = name || providedFullName; + + if (!value) { setShowFullNameModal(true); return; } @@ -57,18 +86,19 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { await signFieldWithToken({ token: recipient.token, fieldId: field.id, - value: source === 'local' && localFullName ? localFullName : providedFullName ?? '', + value, isBase64: false, + authOptions, }); - if (source === 'local' && !providedFullName) { - setProvidedFullName(localFullName); - } - - setLocalFullName(''); - startTransition(() => router.refresh()); } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + console.error(err); toast({ @@ -99,7 +129,13 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { }; return ( - + {isLoading && (
@@ -148,10 +184,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { type="button" className="flex-1" disabled={!localFullName} - onClick={() => { - setShowFullNameModal(false); - void onSign('local'); - }} + onClick={() => onDialogSignClick()} > Sign diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 83cdb93e2..e83f675ce 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -1,35 +1,24 @@ import { headers } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; -import { match } from 'ts-pattern'; - import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; -import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; -import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; -import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; -import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; -import { ElementVisible } from '@documenso/ui/primitives/element-visible'; -import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; -import { truncateTitle } from '~/helpers/truncate-title'; - -import { DateField } from './date-field'; -import { EmailField } from './email-field'; -import { SigningForm } from './form'; -import { NameField } from './name-field'; +import { DocumentAuthProvider } from './document-auth-provider'; import { NoLongerAvailable } from './no-longer-available'; import { SigningProvider } from './provider'; -import { SignatureField } from './signature-field'; -import { TextField } from './text-field'; +import { SigningAuthPageView } from './signing-auth-page'; +import { SigningPageView } from './signing-page-view'; export type SigningPageProps = { params: { @@ -42,6 +31,8 @@ export default async function SigningPage({ params: { token } }: SigningPageProp return notFound(); } + const { user } = await getServerComponentSession(); + const requestHeaders = Object.fromEntries(headers().entries()); const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); @@ -49,21 +40,40 @@ export default async function SigningPage({ params: { token } }: SigningPageProp const [document, fields, recipient] = await Promise.all([ getDocumentAndSenderByToken({ token, + userId: user?.id, + requireAccessAuth: false, }).catch(() => null), getFieldsForToken({ token }), getRecipientByToken({ token }).catch(() => null), - viewedDocument({ token, requestMetadata }).catch(() => null), ]); if (!document || !document.documentData || !recipient) { return notFound(); } - const truncatedTitle = truncateTitle(document.title); + const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }); - const { documentData, documentMeta } = document; + const isDocumentAccessValid = await isRecipientAuthorized({ + type: 'ACCESS', + document, + recipient, + userId: user?.id, + }); - const { user } = await getServerComponentSession(); + if (!isDocumentAccessValid) { + return ; + } + + await viewedDocument({ + token, + requestMetadata, + recipientAccessAuth: derivedRecipientAccessAuth, + }).catch(() => null); + + const { documentMeta } = document; if ( document.status === DocumentStatus.COMPLETED || @@ -109,73 +119,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp fullName={user?.email === recipient.email ? user.name : recipient.name} signature={user?.email === recipient.email ? user.signature : undefined} > -
-

- {truncatedTitle} -

- -
-

- {document.User.name} ({document.User.email}) has invited you to{' '} - {recipient.role === RecipientRole.VIEWER && 'view'} - {recipient.role === RecipientRole.SIGNER && 'sign'} - {recipient.role === RecipientRole.APPROVER && 'approve'} this document. -

-
- -
- - - - - - -
- -
-
- - - {fields.map((field) => - match(field.type) - .with(FieldType.SIGNATURE, () => ( - - )) - .with(FieldType.NAME, () => ( - - )) - .with(FieldType.DATE, () => ( - - )) - .with(FieldType.EMAIL, () => ( - - )) - .with(FieldType.TEXT, () => ( - - )) - .otherwise(() => null), - )} - -
+ + + ); } 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 a9aedbc3d..9b2877033 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -33,8 +33,28 @@ export const SignDialog = ({ const truncatedTitle = truncateTitle(document.title); const isComplete = fields.every((field) => field.inserted); + const handleOpenChange = (open: boolean) => { + if (isSubmitting || !isComplete) { + return; + } + + // Reauth is currently not required for signing the document. + // if (isAuthRedirectRequired) { + // await executeActionAuthProcedure({ + // actionTarget: 'DOCUMENT', + // onReauthFormSubmit: () => { + // // Do nothing since the user should be redirected. + // }, + // }); + + // return; + // } + + setShowDialog(open); + }; + return ( - + diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-auth-page.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-auth-page.tsx new file mode 100644 index 000000000..fb19384cd --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/signing-auth-page.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useState } from 'react'; + +import { DateTime } from 'luxon'; +import { signOut } from 'next-auth/react'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type SigningAuthPageViewProps = { + email: string; +}; + +export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => { + const { toast } = useToast(); + + 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 { + toast({ + title: 'Something went wrong', + description: 'We were unable to log you out at this time.', + duration: 10000, + variant: 'destructive', + }); + } + + setIsSigningOut(false); + }; + + return ( +
+
+

Authentication required

+ +

+ You need to be logged in as {email} to view this page. +

+ + +
+
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx index b4805fa6b..825a15d0f 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx @@ -2,15 +2,38 @@ import React from 'react'; +import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { FieldType } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { FieldRootContainer } from '@documenso/ui/components/field/field'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import { useRequiredDocumentAuthContext } from './document-auth-provider'; + export type SignatureFieldProps = { field: FieldWithSignature; loading?: boolean; children: React.ReactNode; - onSign?: () => Promise | void; + + /** + * A function that is called before the field requires to be signed, or reauthed. + * + * Example, you may want to show a dialog prior to signing where they can enter a value. + * + * Once that action is complete, you will need to call `executeActionAuthProcedure` to proceed + * regardless if it requires reauth or not. + * + * If the function returns true, we will proceed with the signing process. Otherwise if + * false is returned we will not proceed. + */ + onPreSign?: () => Promise | boolean; + + /** + * The function required to be executed to insert the field. + * + * The auth values will be passed in if available. + */ + onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise | void; onRemove?: () => Promise | void; type?: 'Date' | 'Email' | 'Name' | 'Signature'; tooltipText?: string | null; @@ -19,18 +42,56 @@ export type SignatureFieldProps = { export const SigningFieldContainer = ({ field, loading, + onPreSign, onSign, onRemove, children, type, tooltipText, }: SignatureFieldProps) => { - const onSignFieldClick = async () => { - if (field.inserted) { + const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext(); + + const handleInsertField = async () => { + if (field.inserted || !onSign) { return; } - await onSign?.(); + // Bypass reauth for non signature fields. + if (field.type !== FieldType.SIGNATURE) { + const presignResult = await onPreSign?.(); + + if (presignResult === false) { + return; + } + + await onSign(); + return; + } + + if (isAuthRedirectRequired) { + await executeActionAuthProcedure({ + onReauthFormSubmit: () => { + // Do nothing since the user should be redirected. + }, + actionTarget: field.type, + }); + + return; + } + + // Handle any presign requirements, and halt if required. + if (onPreSign) { + const preSignResult = await onPreSign(); + + if (preSignResult === false) { + return; + } + } + + await executeActionAuthProcedure({ + onReauthFormSubmit: onSign, + actionTarget: field.type, + }); }; const onRemoveSignedFieldClick = async () => { @@ -47,7 +108,7 @@ export const SigningFieldContainer = ({ diff --git a/apps/web/src/components/document/document-history-sheet.tsx b/apps/web/src/components/document/document-history-sheet.tsx index 0d0c56aa2..fa9046ce5 100644 --- a/apps/web/src/components/document/document-history-sheet.tsx +++ b/apps/web/src/components/document/document-history-sheet.tsx @@ -7,6 +7,7 @@ import { match } from 'ts-pattern'; import { UAParser } from 'ua-parser-js'; import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs'; +import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs'; import { trpc } from '@documenso/trpc/react'; @@ -79,7 +80,11 @@ export const DocumentHistorySheet = ({ * @param text The text to format * @returns The formatted text */ - const formatGenericText = (text: string) => { + const formatGenericText = (text?: string | null) => { + if (!text) { + return ''; + } + return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' '); }; @@ -219,6 +224,24 @@ export const DocumentHistorySheet = ({ /> ), ) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, + ({ data }) => ( + + ), + ) .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => { if (data.changes.length === 0) { return null; @@ -281,6 +304,7 @@ export const DocumentHistorySheet = ({ ]} /> )) + .exhaustive()} {isUserDetailsVisible && ( diff --git a/apps/web/src/components/form/form-error-message.tsx b/apps/web/src/components/form/form-error-message.tsx deleted file mode 100644 index 6fa7c32b0..000000000 --- a/apps/web/src/components/form/form-error-message.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { AnimatePresence, motion } from 'framer-motion'; - -import { cn } from '@documenso/ui/lib/utils'; - -export type FormErrorMessageProps = { - className?: string; - error: { message?: string } | undefined; -}; - -export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => { - return ( - - {error && ( - - {error.message} - - )} - - ); -}; diff --git a/packages/app-tests/e2e/document-auth/access-auth.spec.ts b/packages/app-tests/e2e/document-auth/access-auth.spec.ts new file mode 100644 index 000000000..0306689ce --- /dev/null +++ b/packages/app-tests/e2e/document-auth/access-auth.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from '@playwright/test'; + +import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth'; +import { prisma } from '@documenso/prisma'; +import { seedPendingDocument } from '@documenso/prisma/seed/documents'; +import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page }) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const document = await seedPendingDocument(user, [ + recipientWithAccount, + 'recipientwithoutaccount@documenso.com', + ]); + + const recipients = await prisma.recipient.findMany({ + where: { + documentId: document.id, + }, + }); + + const tokens = recipients.map((recipient) => recipient.token); + + for (const token of tokens) { + await page.goto(`/sign/${token}`); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + } + + await unseedUser(user.id); +}); + +test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page }) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const document = await seedPendingDocument( + user, + [recipientWithAccount, 'recipientwithoutaccount@documenso.com'], + { + createDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: 'ACCOUNT', + globalActionAuth: null, + }), + }, + }, + ); + + const recipients = await prisma.recipient.findMany({ + where: { + documentId: document.id, + }, + }); + + // Check that both are denied access. + for (const recipient of recipients) { + const { email, token } = recipient; + + await page.goto(`/sign/${token}`); + await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible(); + await expect(page.getByRole('paragraph')).toContainText(email); + } + + await apiSignin({ + page, + email: recipientWithAccount.email, + redirectPath: '/', + }); + + // Check that the one logged in is granted access. + for (const recipient of recipients) { + const { email, token } = recipient; + + await page.goto(`/sign/${token}`); + + // Recipient should be granted access. + if (recipient.email === recipientWithAccount.email) { + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + } + + // Recipient should still be denied. + if (recipient.email !== recipientWithAccount.email) { + await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible(); + await expect(page.getByRole('paragraph')).toContainText(email); + } + } + + await unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); diff --git a/packages/app-tests/e2e/document-auth/action-auth.spec.ts b/packages/app-tests/e2e/document-auth/action-auth.spec.ts new file mode 100644 index 000000000..88ed1ac1d --- /dev/null +++ b/packages/app-tests/e2e/document-auth/action-auth.spec.ts @@ -0,0 +1,418 @@ +import { expect, test } from '@playwright/test'; + +import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; +import { + createDocumentAuthOptions, + createRecipientAuthOptions, +} from '@documenso/lib/utils/document-auth'; +import { FieldType } from '@documenso/prisma/client'; +import { + seedPendingDocumentNoFields, + seedPendingDocumentWithFullFields, +} from '@documenso/prisma/seed/documents'; +import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin, apiSignout } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [recipientWithAccount, seedTestEmail()], + }); + + // Check that both are granted access. + for (const recipient of recipients) { + const { token, Field } = recipient; + + const signUrl = `/sign/${token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + // Add signature. + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4); + await page.mouse.up(); + } + + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.getByLabel('Custom Text').fill('TEXT'); + await page.getByRole('button', { name: 'Save Text' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true'); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + } + + await unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); + +test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ page }) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [recipientWithAccount], + updateDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: null, + globalActionAuth: 'ACCOUNT', + }), + }, + }); + + const recipient = recipients[0]; + + const { token, Field } = recipient; + + const signUrl = `/sign/${token}`; + + await apiSignin({ + page, + email: recipientWithAccount.email, + redirectPath: signUrl, + }); + + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + // Add signature. + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4); + await page.mouse.up(); + } + + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.getByLabel('Custom Text').fill('TEXT'); + await page.getByRole('button', { name: 'Save Text' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true'); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + + await unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); + +// Currently document auth for signing/approving/viewing is not required. +test.skip('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({ + page, +}) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const { recipients } = await seedPendingDocumentNoFields({ + owner: user, + recipients: [recipientWithAccount], + updateDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: null, + globalActionAuth: 'ACCOUNT', + }), + }, + }); + + const recipient = recipients[0]; + + const { token } = recipient; + + await page.goto(`/sign/${token}`); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + await page.getByRole('button', { name: 'Complete' }).click(); + await expect(page.getByRole('paragraph')).toContainText( + 'Reauthentication is required to sign the document', + ); + + await unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); + +test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth', async ({ + page, +}) => { + const user = await seedUser(); + + const recipientWithAccount = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [recipientWithAccount, seedTestEmail()], + updateDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: null, + globalActionAuth: 'ACCOUNT', + }), + }, + }); + + // Check that both are denied access. + for (const recipient of recipients) { + const { token, Field } = recipient; + + await page.goto(`/sign/${token}`); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + for (const field of Field) { + if (field.type !== FieldType.SIGNATURE) { + continue; + } + + await page.locator(`#field-${field.id}`).getByRole('button').click(); + await expect(page.getByRole('paragraph')).toContainText( + 'Reauthentication is required to sign the field', + ); + await page.getByRole('button', { name: 'Cancel' }).click(); + } + } + + await unseedUser(user.id); + await unseedUser(recipientWithAccount.id); +}); + +test('[DOCUMENT_AUTH]: should allow field signing when required for recipient auth', async ({ + page, +}) => { + const user = await seedUser(); + + const recipientWithInheritAuth = await seedUser(); + const recipientWithExplicitNoneAuth = await seedUser(); + const recipientWithExplicitAccountAuth = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [ + recipientWithInheritAuth, + recipientWithExplicitNoneAuth, + recipientWithExplicitAccountAuth, + ], + recipientsCreateOptions: [ + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: null, + }), + }, + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: 'EXPLICIT_NONE', + }), + }, + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: 'ACCOUNT', + }), + }, + ], + fields: [FieldType.DATE], + }); + + for (const recipient of recipients) { + const { token, Field } = recipient; + const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); + + // This document has no global action auth, so only account should require auth. + const isAuthRequired = actionAuth === 'ACCOUNT'; + + const signUrl = `/sign/${token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + if (isAuthRequired) { + for (const field of Field) { + if (field.type !== FieldType.SIGNATURE) { + continue; + } + + await page.locator(`#field-${field.id}`).getByRole('button').click(); + await expect(page.getByRole('paragraph')).toContainText( + 'Reauthentication is required to sign the field', + ); + await page.getByRole('button', { name: 'Cancel' }).click(); + } + + // Sign in and it should work. + await apiSignin({ + page, + email: recipient.email, + redirectPath: signUrl, + }); + } + + // Add signature. + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4); + await page.mouse.up(); + } + + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.getByLabel('Custom Text').fill('TEXT'); + await page.getByRole('button', { name: 'Save Text' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', { + timeout: 5000, + }); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + + if (isAuthRequired) { + await apiSignout({ page }); + } + } +}); + +test('[DOCUMENT_AUTH]: should allow field signing when required for recipient and global auth', async ({ + page, +}) => { + const user = await seedUser(); + + const recipientWithInheritAuth = await seedUser(); + const recipientWithExplicitNoneAuth = await seedUser(); + const recipientWithExplicitAccountAuth = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: [ + recipientWithInheritAuth, + recipientWithExplicitNoneAuth, + recipientWithExplicitAccountAuth, + ], + recipientsCreateOptions: [ + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: null, + }), + }, + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: 'EXPLICIT_NONE', + }), + }, + { + authOptions: createRecipientAuthOptions({ + accessAuth: null, + actionAuth: 'ACCOUNT', + }), + }, + ], + fields: [FieldType.DATE], + updateDocumentOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: null, + globalActionAuth: 'ACCOUNT', + }), + }, + }); + + for (const recipient of recipients) { + const { token, Field } = recipient; + const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); + + // This document HAS global action auth, so account and inherit should require auth. + const isAuthRequired = actionAuth === 'ACCOUNT' || actionAuth === null; + + const signUrl = `/sign/${token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + if (isAuthRequired) { + for (const field of Field) { + if (field.type !== FieldType.SIGNATURE) { + continue; + } + + await page.locator(`#field-${field.id}`).getByRole('button').click(); + await expect(page.getByRole('paragraph')).toContainText( + 'Reauthentication is required to sign the field', + ); + await page.getByRole('button', { name: 'Cancel' }).click(); + } + + // Sign in and it should work. + await apiSignin({ + page, + email: recipient.email, + redirectPath: signUrl, + }); + } + + // Add signature. + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4); + await page.mouse.up(); + } + + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.getByLabel('Custom Text').fill('TEXT'); + await page.getByRole('button', { name: 'Save Text' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', { + timeout: 5000, + }); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); + + if (isAuthRequired) { + await apiSignout({ page }); + } + } +}); diff --git a/packages/app-tests/e2e/document-flow/settings-step.spec.ts b/packages/app-tests/e2e/document-flow/settings-step.spec.ts new file mode 100644 index 000000000..b416baa7c --- /dev/null +++ b/packages/app-tests/e2e/document-flow/settings-step.spec.ts @@ -0,0 +1,200 @@ +import { expect, test } from '@playwright/test'; + +import { + seedBlankDocument, + seedDraftDocument, + seedPendingDocument, +} from '@documenso/prisma/seed/documents'; +import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; +import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; +import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('[EE_ONLY]', () => { + const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || ''; + + test.beforeEach(() => { + test.skip( + process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId, + 'Billing required for this test', + ); + }); + + test('[DOCUMENT_FLOW] add action auth settings', async ({ page }) => { + const user = await seedUser(); + + await seedUserSubscription({ + userId: user.id, + priceId: enterprisePriceId, + }); + + const document = await seedBlankDocument(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + // Set EE action auth. + await page.getByTestId('documentActionSelectValue').click(); + await page.getByLabel('Require account').getByText('Require account').click(); + await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account'); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Return to the settings step to check that the results are saved correctly. + await page.getByRole('button', { name: 'Go Back' }).click(); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + + // Todo: Verify that the values are correct once we fix the issue where going back + // does not show the updated values. + // await expect(page.getByLabel('Title')).toContainText('New Title'); + // await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); + // await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account'); + + await unseedUser(user.id); + }); + + test('[DOCUMENT_FLOW] enterprise team member can add action auth settings', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + const teamMemberUser = team.members[1].user; + + // Make the team enterprise by giving the owner the enterprise subscription. + await seedUserSubscription({ + userId: team.ownerUserId, + priceId: enterprisePriceId, + }); + + const document = await seedBlankDocument(owner, { + createDocumentOptions: { + teamId: team.id, + }, + }); + + await apiSignin({ + page, + email: teamMemberUser.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit`, + }); + + // Set EE action auth. + await page.getByTestId('documentActionSelectValue').click(); + await page.getByLabel('Require account').getByText('Require account').click(); + await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account'); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Advanced settings should be visible. + await expect(page.getByLabel('Show advanced settings')).toBeVisible(); + + await unseedTeam(team.url); + }); + + test('[DOCUMENT_FLOW] enterprise team member should not have access to enterprise on personal account', async ({ + page, + }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const teamMemberUser = team.members[1].user; + + // Make the team enterprise by giving the owner the enterprise subscription. + await seedUserSubscription({ + userId: team.ownerUserId, + priceId: enterprisePriceId, + }); + + const document = await seedBlankDocument(teamMemberUser); + + await apiSignin({ + page, + email: teamMemberUser.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + // Global action auth should not be visible. + await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible(); + + // Next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Advanced settings should not be visible. + await expect(page.getByLabel('Show advanced settings')).not.toBeVisible(); + + await unseedTeam(team.url); + }); +}); + +test('[DOCUMENT_FLOW]: add settings', async ({ page }) => { + const user = await seedUser(); + const document = await seedBlankDocument(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + // Set title. + await page.getByLabel('Title').fill('New Title'); + + // Set access auth. + await page.getByTestId('documentAccessSelectValue').click(); + await page.getByLabel('Require account').getByText('Require account').click(); + await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); + + // Action auth should NOT be visible. + await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible(); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Return to the settings step to check that the results are saved correctly. + await page.getByRole('button', { name: 'Go Back' }).click(); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + + // Todo: Verify that the values are correct once we fix the issue where going back + // does not show the updated values. + // await expect(page.getByLabel('Title')).toContainText('New Title'); + // await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); + // await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account'); + + await unseedUser(user.id); +}); + +test('[DOCUMENT_FLOW]: title should be disabled depending on document status', async ({ page }) => { + const user = await seedUser(); + + const pendingDocument = await seedPendingDocument(user, []); + const draftDocument = await seedDraftDocument(user, []); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${pendingDocument.id}/edit`, + }); + + // Should be disabled for pending documents. + await expect(page.getByLabel('Title')).toBeDisabled(); + + // Should be enabled for draft documents. + await page.goto(`/documents/${draftDocument.id}/edit`); + await expect(page.getByLabel('Title')).toBeEnabled(); + + await unseedUser(user.id); +}); diff --git a/packages/app-tests/e2e/document-flow/signers-step.spec.ts b/packages/app-tests/e2e/document-flow/signers-step.spec.ts new file mode 100644 index 000000000..30d6ba11f --- /dev/null +++ b/packages/app-tests/e2e/document-flow/signers-step.spec.ts @@ -0,0 +1,118 @@ +import { expect, test } from '@playwright/test'; + +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; +import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('[EE_ONLY]', () => { + const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || ''; + + test.beforeEach(() => { + test.skip( + process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId, + 'Billing required for this test', + ); + }); + + test('[DOCUMENT_FLOW] add EE settings', async ({ page }) => { + const user = await seedUser(); + + await seedUserSubscription({ + userId: user.id, + priceId: enterprisePriceId, + }); + + const document = await seedBlankDocument(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Add 2 signers. + await page.getByPlaceholder('Email').fill('recipient1@documenso.com'); + await page.getByPlaceholder('Name').fill('Recipient 1'); + await page.getByRole('button', { name: 'Add Signer' }).click(); + await page + .getByRole('textbox', { name: 'Email', exact: true }) + .fill('recipient2@documenso.com'); + await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2'); + + // Display advanced settings. + await page.getByLabel('Show advanced settings').click(); + + // Navigate to the next step and back. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + await page.getByRole('button', { name: 'Go Back' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Todo: Fix stepper component back issue before finishing test. + + await unseedUser(user.id); + }); +}); + +// Note: Not complete yet due to issue with back button. +test('[DOCUMENT_FLOW]: add signers', async ({ page }) => { + const user = await seedUser(); + const document = await seedBlankDocument(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Add 2 signers. + await page.getByPlaceholder('Email').fill('recipient1@documenso.com'); + await page.getByPlaceholder('Name').fill('Recipient 1'); + await page.getByRole('button', { name: 'Add Signer' }).click(); + await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com'); + await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2'); + + // Advanced settings should not be visible for non EE users. + await expect(page.getByLabel('Show advanced settings')).toBeHidden(); + + // Navigate to the next step and back. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + await page.getByRole('button', { name: 'Go Back' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Todo: Fix stepper component back issue before finishing test. + + // // Expect that the advanced settings is unchecked, since no advanced settings were applied. + // await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false }); + + // // Add advanced settings for a single recipient. + // await page.getByLabel('Show advanced settings').click(); + // await page.getByRole('combobox').first().click(); + // await page.getByLabel('Require account').click(); + + // // Navigate to the next step and back. + // await page.getByRole('button', { name: 'Continue' }).click(); + // await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + // await page.getByRole('button', { name: 'Go Back' }).click(); + // await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced + // settings were applied. + + // Todo: Fix stepper component back issue before finishing test. + + await unseedUser(user.id); +}); diff --git a/packages/app-tests/e2e/fixtures/authentication.ts b/packages/app-tests/e2e/fixtures/authentication.ts index d59fccd1c..9f3a50756 100644 --- a/packages/app-tests/e2e/fixtures/authentication.ts +++ b/packages/app-tests/e2e/fixtures/authentication.ts @@ -1,8 +1,8 @@ -import type { Page } from '@playwright/test'; +import { type Page } from '@playwright/test'; import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; -type ManualLoginOptions = { +type LoginOptions = { page: Page; email?: string; password?: string; @@ -18,7 +18,7 @@ export const manualLogin = async ({ email = 'example@documenso.com', password = 'password', redirectPath, -}: ManualLoginOptions) => { +}: LoginOptions) => { await page.goto(`${WEBAPP_BASE_URL}/signin`); await page.getByLabel('Email').click(); @@ -33,9 +33,63 @@ export const manualLogin = async ({ } }; -export const manualSignout = async ({ page }: ManualLoginOptions) => { +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 = '/', +}: LoginOptions) => { + const { request } = page.context(); + + const csrfToken = await getCsrfToken(page); + + await request.post(`${WEBAPP_BASE_URL}/api/auth/callback/credentials`, { + form: { + email, + password, + json: true, + csrfToken, + }, + }); + + if (redirectPath) { + await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`); + } +}; + +export const apiSignout = async ({ page }: { page: Page }) => { + const { request } = page.context(); + + const csrfToken = await getCsrfToken(page); + + await request.post(`${WEBAPP_BASE_URL}/api/auth/signout`, { + form: { + csrfToken, + json: true, + }, + }); + + await page.goto(`${WEBAPP_BASE_URL}/signin`); +}; + +const getCsrfToken = async (page: Page) => { + const { request } = page.context(); + + const response = await request.fetch(`${WEBAPP_BASE_URL}/api/auth/csrf`, { + method: 'get', + }); + + const { csrfToken } = await response.json(); + if (!csrfToken) { + throw new Error('Invalid session'); + } + + return csrfToken; +}; 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 4327935bb..70b0cfe72 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 @@ -4,17 +4,21 @@ 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 { DocumentStatus } from '@documenso/prisma/client'; -import { TEST_USER } from '@documenso/prisma/seed/pr-718-add-stepper-component'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from './fixtures/authentication'; test(`[PR-718]: should be able to create a document`, async ({ page }) => { await page.goto('/signin'); const documentTitle = `example-${Date.now()}.pdf`; - // Sign in - await page.getByLabel('Email').fill(TEST_USER.email); - await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password); - await page.getByRole('button', { name: 'Sign In' }).click(); + const user = await seedUser(); + + await apiSignin({ + page, + email: user.email, + }); // Upload document const [fileChooser] = await Promise.all([ @@ -31,8 +35,8 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => { // Wait to be redirected to the edit page await page.waitForURL(/\/documents\/\d+/); - // Set title - await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible(); + // Set general settings + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await page.getByLabel('Title').fill(documentTitle); @@ -82,10 +86,12 @@ test('should be able to create a document with multiple recipients', async ({ pa const documentTitle = `example-${Date.now()}.pdf`; - // Sign in - await page.getByLabel('Email').fill(TEST_USER.email); - await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password); - await page.getByRole('button', { name: 'Sign In' }).click(); + const user = await seedUser(); + + await apiSignin({ + page, + email: user.email, + }); // Upload document const [fileChooser] = await Promise.all([ @@ -103,7 +109,7 @@ test('should be able to create a document with multiple recipients', async ({ pa await page.waitForURL(/\/documents\/\d+/); // Set title - await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await page.getByLabel('Title').fill(documentTitle); @@ -112,13 +118,12 @@ test('should be able to create a document with multiple recipients', async ({ pa // Add signers await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); - await page.getByLabel('Email*').fill('user1@example.com'); - await page.getByLabel('Name').fill('User 1'); - + // Add 2 signers. + await page.getByPlaceholder('Email').fill('user1@example.com'); + await page.getByPlaceholder('Name').fill('User 1'); await page.getByRole('button', { name: 'Add Signer' }).click(); - - await page.getByLabel('Email*').nth(1).fill('user2@example.com'); - await page.getByLabel('Name').nth(1).fill('User 2'); + await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com'); + await page.getByRole('textbox', { name: 'Name', exact: true }).fill('User 2'); await page.getByRole('button', { name: 'Continue' }).click(); @@ -177,10 +182,12 @@ test('should be able to create, send and sign a document', async ({ page }) => { const documentTitle = `example-${Date.now()}.pdf`; - // Sign in - await page.getByLabel('Email').fill(TEST_USER.email); - await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password); - await page.getByRole('button', { name: 'Sign In' }).click(); + const user = await seedUser(); + + await apiSignin({ + page, + email: user.email, + }); // Upload document const [fileChooser] = await Promise.all([ @@ -198,7 +205,7 @@ test('should be able to create, send and sign a document', async ({ page }) => { await page.waitForURL(/\/documents\/\d+/); // Set title - await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await page.getByLabel('Title').fill(documentTitle); @@ -207,8 +214,8 @@ test('should be able to create, send and sign a document', async ({ page }) => { // Add signers await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); - await page.getByLabel('Email*').fill('user1@example.com'); - await page.getByLabel('Name').fill('User 1'); + await page.getByPlaceholder('Email').fill('user1@example.com'); + await page.getByPlaceholder('Name').fill('User 1'); await page.getByRole('button', { name: 'Continue' }).click(); @@ -225,8 +232,9 @@ test('should be able to create, send and sign a document', async ({ page }) => { // Assert document was created await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); await page.getByRole('link', { name: documentTitle }).click(); + await page.waitForURL(/\/documents\/\d+/); - const url = await page.url().split('/'); + const url = page.url().split('/'); const documentId = url[url.length - 1]; const { token } = await getRecipientByEmail({ @@ -260,10 +268,12 @@ test('should be able to create, send with redirect url, sign a document and redi const documentTitle = `example-${Date.now()}.pdf`; - // Sign in - await page.getByLabel('Email').fill(TEST_USER.email); - await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password); - await page.getByRole('button', { name: 'Sign In' }).click(); + const user = await seedUser(); + + await apiSignin({ + page, + email: user.email, + }); // Upload document const [fileChooser] = await Promise.all([ @@ -280,18 +290,19 @@ test('should be able to create, send with redirect url, sign a document and redi // Wait to be redirected to the edit page await page.waitForURL(/\/documents\/\d+/); - // Set title - await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible(); - + // Set title & advanced redirect + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await page.getByLabel('Title').fill(documentTitle); + await page.getByRole('button', { name: 'Advanced Options' }).click(); + await page.getByLabel('Redirect URL').fill('https://documenso.com'); await page.getByRole('button', { name: 'Continue' }).click(); // Add signers await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); - await page.getByLabel('Email*').fill('user1@example.com'); - await page.getByLabel('Name').fill('User 1'); + await page.getByPlaceholder('Email').fill('user1@example.com'); + await page.getByPlaceholder('Name').fill('User 1'); await page.getByRole('button', { name: 'Continue' }).click(); @@ -299,11 +310,6 @@ test('should be able to create, send with redirect url, sign a document and redi await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await page.getByRole('button', { name: 'Continue' }).click(); - // Add subject and send - await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible(); - await page.getByRole('button', { name: 'Advanced Options' }).click(); - await page.getByLabel('Redirect URL').fill('https://documenso.com'); - await page.getByRole('button', { name: 'Send' }).click(); await page.waitForURL('/documents'); @@ -311,8 +317,9 @@ test('should be able to create, send with redirect url, sign a document and redi // Assert document was created await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); await page.getByRole('link', { name: documentTitle }).click(); + await page.waitForURL(/\/documents\/\d+/); - const url = await page.url().split('/'); + const url = page.url().split('/'); const documentId = url[url.length - 1]; const { token } = await getRecipientByEmail({ diff --git a/packages/app-tests/e2e/teams/manage-team.spec.ts b/packages/app-tests/e2e/teams/manage-team.spec.ts index aed56b2bc..a1deb1995 100644 --- a/packages/app-tests/e2e/teams/manage-team.spec.ts +++ b/packages/app-tests/e2e/teams/manage-team.spec.ts @@ -4,14 +4,14 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; import { seedUser } from '@documenso/prisma/seed/users'; -import { manualLogin } from '../fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; test.describe.configure({ mode: 'parallel' }); test('[TEAMS]: create team', async ({ page }) => { const user = await seedUser(); - await manualLogin({ + await apiSignin({ page, email: user.email, redirectPath: '/settings/teams', @@ -38,7 +38,7 @@ test('[TEAMS]: create team', async ({ page }) => { test('[TEAMS]: delete team', async ({ page }) => { const team = await seedTeam(); - await manualLogin({ + await apiSignin({ page, email: team.owner.email, redirectPath: `/t/${team.url}/settings`, @@ -56,7 +56,7 @@ test('[TEAMS]: delete team', async ({ page }) => { test('[TEAMS]: update team', async ({ page }) => { const team = await seedTeam(); - await manualLogin({ + await apiSignin({ page, email: team.owner.email, }); diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts index 210189ca7..8f70befc8 100644 --- a/packages/app-tests/e2e/teams/team-documents.spec.ts +++ b/packages/app-tests/e2e/teams/team-documents.spec.ts @@ -6,7 +6,7 @@ import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documen import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams'; import { seedUser } from '@documenso/prisma/seed/users'; -import { manualLogin, manualSignout } from '../fixtures/authentication'; +import { apiSignin, apiSignout } from '../fixtures/authentication'; test.describe.configure({ mode: 'parallel' }); @@ -30,7 +30,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => { // Run the test twice, once with the team owner and once with a team member to ensure the counts are the same. for (const user of [team.owner, teamMember2]) { - await manualLogin({ + await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents`, @@ -55,7 +55,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => { await checkDocumentTabCount(page, 'Draft', 1); await checkDocumentTabCount(page, 'All', 3); - await manualSignout({ page }); + await apiSignout({ page }); } await unseedTeam(team.url); @@ -126,7 +126,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa // Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same. for (const user of [team.owner, teamEmailMember]) { - await manualLogin({ + await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents`, @@ -151,7 +151,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa await checkDocumentTabCount(page, 'Draft', 1); await checkDocumentTabCount(page, 'All', 3); - await manualSignout({ page }); + await apiSignout({ page }); } await unseedTeamEmail({ teamId: team.id }); @@ -216,7 +216,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa }, ]); - await manualLogin({ + await apiSignin({ page, email: teamMember2.email, redirectPath: `/t/${team.url}/documents`, @@ -248,7 +248,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa test('[TEAMS]: delete pending team document', async ({ page }) => { const { team, teamMember2: currentUser } = await seedTeamDocuments(); - await manualLogin({ + await apiSignin({ page, email: currentUser.email, redirectPath: `/t/${team.url}/documents?status=PENDING`, @@ -266,7 +266,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => { test('[TEAMS]: resend pending team document', async ({ page }) => { const { team, teamMember2: currentUser } = await seedTeamDocuments(); - await manualLogin({ + await apiSignin({ page, email: currentUser.email, redirectPath: `/t/${team.url}/documents?status=PENDING`, diff --git a/packages/app-tests/e2e/teams/team-email.spec.ts b/packages/app-tests/e2e/teams/team-email.spec.ts index 953be5aaf..6ae820f59 100644 --- a/packages/app-tests/e2e/teams/team-email.spec.ts +++ b/packages/app-tests/e2e/teams/team-email.spec.ts @@ -4,14 +4,14 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams'; import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; -import { manualLogin } from '../fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; test.describe.configure({ mode: 'parallel' }); test('[TEAMS]: send team email request', async ({ page }) => { const team = await seedTeam(); - await manualLogin({ + await apiSignin({ page, email: team.owner.email, password: 'password', @@ -57,7 +57,7 @@ test('[TEAMS]: delete team email', async ({ page }) => { createTeamEmail: true, }); - await manualLogin({ + await apiSignin({ page, email: team.owner.email, redirectPath: `/t/${team.url}/settings`, @@ -86,7 +86,7 @@ test('[TEAMS]: team email owner removes access', async ({ page }) => { email: team.teamEmail.email, }); - await manualLogin({ + await apiSignin({ page, email: teamEmailOwner.email, redirectPath: `/settings/teams`, diff --git a/packages/app-tests/e2e/teams/team-members.spec.ts b/packages/app-tests/e2e/teams/team-members.spec.ts index 05f096c09..c85717729 100644 --- a/packages/app-tests/e2e/teams/team-members.spec.ts +++ b/packages/app-tests/e2e/teams/team-members.spec.ts @@ -4,7 +4,7 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams'; import { seedUser } from '@documenso/prisma/seed/users'; -import { manualLogin } from '../fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; test.describe.configure({ mode: 'parallel' }); @@ -13,7 +13,7 @@ test('[TEAMS]: update team member role', async ({ page }) => { createTeamMembers: 1, }); - await manualLogin({ + await apiSignin({ page, email: team.owner.email, password: 'password', @@ -75,7 +75,7 @@ test('[TEAMS]: member can leave team', async ({ page }) => { const teamMember = team.members[1]; - await manualLogin({ + await apiSignin({ page, email: teamMember.user.email, password: 'password', @@ -97,7 +97,7 @@ test('[TEAMS]: owner cannot leave team', async ({ page }) => { createTeamMembers: 1, }); - await manualLogin({ + await apiSignin({ page, email: team.owner.email, password: 'password', diff --git a/packages/app-tests/e2e/teams/transfer-team.spec.ts b/packages/app-tests/e2e/teams/transfer-team.spec.ts index a5d95b720..c8460baf8 100644 --- a/packages/app-tests/e2e/teams/transfer-team.spec.ts +++ b/packages/app-tests/e2e/teams/transfer-team.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams'; -import { manualLogin } from '../fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; test.describe.configure({ mode: 'parallel' }); @@ -14,7 +14,7 @@ test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => { const teamMember = team.members[1]; - await manualLogin({ + await apiSignin({ page, email: team.owner.email, password: 'password', diff --git a/packages/app-tests/e2e/templates/manage-templates.spec.ts b/packages/app-tests/e2e/templates/manage-templates.spec.ts index f89583097..a89b308eb 100644 --- a/packages/app-tests/e2e/templates/manage-templates.spec.ts +++ b/packages/app-tests/e2e/templates/manage-templates.spec.ts @@ -4,7 +4,7 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; import { seedTemplate } from '@documenso/prisma/seed/templates'; -import { manualLogin } from '../fixtures/authentication'; +import { apiSignin } from '../fixtures/authentication'; test.describe.configure({ mode: 'parallel' }); @@ -36,7 +36,7 @@ test('[TEMPLATES]: view templates', async ({ page }) => { teamId: team.id, }); - await manualLogin({ + await apiSignin({ page, email: owner.email, redirectPath: '/templates', @@ -81,7 +81,7 @@ test('[TEMPLATES]: delete template', async ({ page }) => { teamId: team.id, }); - await manualLogin({ + await apiSignin({ page, email: owner.email, redirectPath: '/templates', @@ -135,7 +135,7 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => { teamId: team.id, }); - await manualLogin({ + await apiSignin({ page, email: owner.email, redirectPath: '/templates', @@ -181,7 +181,7 @@ test('[TEMPLATES]: use template', async ({ page }) => { teamId: team.id, }); - await manualLogin({ + await apiSignin({ page, email: owner.email, redirectPath: '/templates', diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts index 672c2f7ef..0796bb1e1 100644 --- a/packages/app-tests/playwright.config.ts +++ b/packages/app-tests/playwright.config.ts @@ -1,10 +1,14 @@ import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// require('dotenv').config(); +const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`]; + +ENV_FILES.forEach((file) => { + dotenv.config({ + path: path.join(__dirname, `../../${file}`), + }); +}); /** * See https://playwright.dev/docs/test-configuration. diff --git a/packages/ee/server-only/util/is-document-enterprise.ts b/packages/ee/server-only/util/is-document-enterprise.ts new file mode 100644 index 000000000..01c2d7327 --- /dev/null +++ b/packages/ee/server-only/util/is-document-enterprise.ts @@ -0,0 +1,56 @@ +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { subscriptionsContainActiveEnterprisePlan } from '@documenso/lib/utils/billing'; +import { prisma } from '@documenso/prisma'; +import type { Subscription } from '@documenso/prisma/client'; + +export type IsUserEnterpriseOptions = { + userId: number; + teamId?: number; +}; + +/** + * Whether the user is enterprise, or has permission to use enterprise features on + * behalf of their team. + * + * It is assumed that the provided user is part of the provided team. + */ +export const isUserEnterprise = async ({ + userId, + teamId, +}: IsUserEnterpriseOptions): Promise => { + let subscriptions: Subscription[] = []; + + if (!IS_BILLING_ENABLED()) { + return false; + } + + if (teamId) { + subscriptions = await prisma.team + .findFirstOrThrow({ + where: { + id: teamId, + }, + select: { + owner: { + include: { + Subscription: true, + }, + }, + }, + }) + .then((team) => team.owner.Subscription); + } else { + subscriptions = await prisma.user + .findFirstOrThrow({ + where: { + id: userId, + }, + select: { + Subscription: true, + }, + }) + .then((user) => user.Subscription); + } + + return subscriptionsContainActiveEnterprisePlan(subscriptions); +}; diff --git a/packages/lib/constants/document-auth.ts b/packages/lib/constants/document-auth.ts new file mode 100644 index 000000000..81f22236e --- /dev/null +++ b/packages/lib/constants/document-auth.ts @@ -0,0 +1,31 @@ +import type { TDocumentAuth } from '../types/document-auth'; +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.EXPLICIT_NONE]: { + key: DocumentAuth.EXPLICIT_NONE, + value: 'None (Overrides global settings)', + }, +} satisfies Record; diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index f43f9c3ba..120df5ed6 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -137,12 +137,16 @@ export class AppError extends Error { } static parseFromJSONString(jsonString: string): AppError | null { - const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString)); + try { + const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString)); - if (!parsed.success) { + if (!parsed.success) { + return null; + } + + return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage); + } catch { return null; } - - return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage); } } diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index 5f58c5183..8e3b56002 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -7,6 +7,7 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; +import type { TRecipientActionAuth } from '../../types/document-auth'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { sealDocument } from './seal-document'; import { sendPendingEmail } from './send-pending-email'; @@ -14,6 +15,8 @@ import { sendPendingEmail } from './send-pending-email'; export type CompleteDocumentWithTokenOptions = { token: string; documentId: number; + userId?: number; + authOptions?: TRecipientActionAuth; requestMetadata?: RequestMetadata; }; @@ -71,32 +74,54 @@ export const completeDocumentWithToken = async ({ throw new Error(`Recipient ${recipient.id} has unsigned fields`); } - await prisma.recipient.update({ - where: { - id: recipient.id, - }, - data: { - signingStatus: SigningStatus.SIGNED, - signedAt: new Date(), - }, - }); + // Document reauth for completing documents is currently not required. - await prisma.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, - documentId: document.id, - user: { - name: recipient.name, - email: recipient.email, + // const { derivedRecipientActionAuth } = extractDocumentAuthMethods({ + // documentAuth: document.authOptions, + // recipientAuth: recipient.authOptions, + // }); + + // const isValid = await isRecipientAuthorized({ + // type: 'ACTION', + // document: document, + // recipient: recipient, + // userId, + // authOptions, + // }); + + // if (!isValid) { + // throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values'); + // } + + await prisma.$transaction(async (tx) => { + await tx.recipient.update({ + where: { + id: recipient.id, }, - requestMetadata, data: { - recipientEmail: recipient.email, - recipientName: recipient.name, - recipientId: recipient.id, - recipientRole: recipient.role, + signingStatus: SigningStatus.SIGNED, + signedAt: new Date(), }, - }), + }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, + documentId: document.id, + user: { + name: recipient.name, + email: recipient.email, + }, + requestMetadata, + data: { + recipientEmail: recipient.email, + recipientName: recipient.name, + recipientId: recipient.id, + recipientRole: recipient.role, + // actionAuth: derivedRecipientActionAuth || undefined, + }, + }), + }); }); const pendingRecipients = await prisma.recipient.count({ diff --git a/packages/lib/server-only/document/get-document-by-token.ts b/packages/lib/server-only/document/get-document-by-token.ts index 1594efbe4..6add46c1d 100644 --- a/packages/lib/server-only/document/get-document-by-token.ts +++ b/packages/lib/server-only/document/get-document-by-token.ts @@ -1,13 +1,39 @@ import { prisma } from '@documenso/prisma'; import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; +import { AppError, AppErrorCode } from '../../errors/app-error'; +import type { TDocumentAuthMethods } from '../../types/document-auth'; +import { isRecipientAuthorized } from './is-recipient-authorized'; + +export interface GetDocumentAndSenderByTokenOptions { + token: string; + userId?: number; + accessAuth?: TDocumentAuthMethods; + + /** + * Whether we enforce the access requirement. + * + * Defaults to true. + */ + requireAccessAuth?: boolean; +} + +export interface GetDocumentAndRecipientByTokenOptions { + token: string; + userId?: number; + accessAuth?: TDocumentAuthMethods; + + /** + * Whether we enforce the access requirement. + * + * Defaults to true. + */ + requireAccessAuth?: boolean; +} export type GetDocumentByTokenOptions = { token: string; }; -export type GetDocumentAndSenderByTokenOptions = GetDocumentByTokenOptions; -export type GetDocumentAndRecipientByTokenOptions = GetDocumentByTokenOptions; - export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) => { if (!token) { throw new Error('Missing token'); @@ -26,8 +52,13 @@ export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) = return result; }; +export type DocumentAndSender = Awaited>; + export const getDocumentAndSenderByToken = async ({ token, + userId, + accessAuth, + requireAccessAuth = true, }: GetDocumentAndSenderByTokenOptions) => { if (!token) { throw new Error('Missing token'); @@ -45,12 +76,40 @@ export const getDocumentAndSenderByToken = async ({ User: true, documentData: true, documentMeta: true, + Recipient: { + where: { + token, + }, + }, }, }); // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars const { password: _password, ...User } = result.User; + const recipient = result.Recipient[0]; + + // Sanity check, should not be possible. + if (!recipient) { + throw new Error('Missing recipient'); + } + + let documentAccessValid = true; + + if (requireAccessAuth) { + documentAccessValid = await isRecipientAuthorized({ + type: 'ACCESS', + document: result, + recipient, + userId, + authOptions: accessAuth, + }); + } + + if (!documentAccessValid) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values'); + } + return { ...result, User, @@ -62,6 +121,9 @@ export const getDocumentAndSenderByToken = async ({ */ export const getDocumentAndRecipientByToken = async ({ token, + userId, + accessAuth, + requireAccessAuth = true, }: GetDocumentAndRecipientByTokenOptions): Promise => { if (!token) { throw new Error('Missing token'); @@ -85,6 +147,29 @@ export const getDocumentAndRecipientByToken = async ({ }, }); + const recipient = result.Recipient[0]; + + // Sanity check, should not be possible. + if (!recipient) { + throw new Error('Missing recipient'); + } + + let documentAccessValid = true; + + if (requireAccessAuth) { + documentAccessValid = await isRecipientAuthorized({ + type: 'ACCESS', + document: result, + recipient, + userId, + authOptions: accessAuth, + }); + } + + if (!documentAccessValid) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values'); + } + return { ...result, Recipient: result.Recipient, diff --git a/packages/lib/server-only/document/is-recipient-authorized.ts b/packages/lib/server-only/document/is-recipient-authorized.ts new file mode 100644 index 000000000..2c7e9b6e4 --- /dev/null +++ b/packages/lib/server-only/document/is-recipient-authorized.ts @@ -0,0 +1,86 @@ +import { match } from 'ts-pattern'; + +import { prisma } from '@documenso/prisma'; +import type { Document, Recipient } from '@documenso/prisma/client'; + +import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth'; +import { DocumentAuth } from '../../types/document-auth'; +import { extractDocumentAuthMethods } from '../../utils/document-auth'; + +type IsRecipientAuthorizedOptions = { + type: 'ACCESS' | 'ACTION'; + document: Document; + recipient: Recipient; + + /** + * The ID of the user who initiated the request. + */ + userId?: number; + + /** + * The auth details to check. + * + * Optional because there are scenarios where no auth options are required such as + * using the user ID. + */ + authOptions?: TDocumentAuthMethods; +}; + +const getUserByEmail = async (email: string) => { + return await prisma.user.findFirst({ + where: { + email, + }, + select: { + id: true, + }, + }); +}; + +/** + * Whether the recipient is authorized to perform the requested operation on a + * document, given the provided auth options. + * + * @returns True if the recipient can perform the requested operation. + */ +export const isRecipientAuthorized = async ({ + type, + document, + recipient, + userId, + authOptions, +}: IsRecipientAuthorizedOptions): Promise => { + const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }); + + const authMethod: TDocumentAuth | null = + type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth; + + // Early true return when auth is not required. + if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) { + return true; + } + + // Authentication required does not match provided method. + if (authOptions && authOptions.type !== authMethod) { + return false; + } + + return await match(authMethod) + .with(DocumentAuth.ACCOUNT, async () => { + if (userId === undefined) { + return false; + } + + const recipientUser = await getUserByEmail(recipient.email); + + if (!recipientUser) { + return false; + } + + return recipientUser.id === userId; + }) + .exhaustive(); +}; diff --git a/packages/lib/server-only/document/send-completed-email.ts b/packages/lib/server-only/document/send-completed-email.ts index 7ff99bbdf..a397e47e7 100644 --- a/packages/lib/server-only/document/send-completed-email.ts +++ b/packages/lib/server-only/document/send-completed-email.ts @@ -95,7 +95,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo data: { emailType: 'DOCUMENT_COMPLETED', recipientEmail: owner.email, - recipientName: owner.name, + recipientName: owner.name ?? '', recipientId: owner.id, recipientRole: 'OWNER', isResending: false, diff --git a/packages/lib/server-only/document/update-document-settings.ts b/packages/lib/server-only/document/update-document-settings.ts new file mode 100644 index 000000000..73e0eec3b --- /dev/null +++ b/packages/lib/server-only/document/update-document-settings.ts @@ -0,0 +1,178 @@ +'use server'; + +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs'; +import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; +import { prisma } from '@documenso/prisma'; +import { DocumentStatus } from '@documenso/prisma/client'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth'; +import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth'; + +export type UpdateDocumentSettingsOptions = { + userId: number; + teamId?: number; + documentId: number; + data: { + title?: string; + globalAccessAuth?: TDocumentAccessAuthTypes | null; + globalActionAuth?: TDocumentActionAuthTypes | null; + }; + requestMetadata?: RequestMetadata; +}; + +export const updateDocumentSettings = async ({ + userId, + teamId, + documentId, + data, + requestMetadata, +}: UpdateDocumentSettingsOptions) => { + if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) { + throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update'); + } + + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + + const document = await prisma.document.findFirstOrThrow({ + where: { + id: documentId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + }); + + const { documentAuthOption } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + }); + + const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null; + const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null; + + // If the new global auth values aren't passed in, fallback to the current document values. + const newGlobalAccessAuth = + data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth; + const newGlobalActionAuth = + data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth; + + // Check if user has permission to set the global action auth. + if (newGlobalActionAuth) { + const isDocumentEnterprise = await isUserEnterprise({ + userId, + teamId, + }); + + if (!isDocumentEnterprise) { + throw new AppError( + AppErrorCode.UNAUTHORIZED, + 'You do not have permission to set the action auth', + ); + } + } + + const isTitleSame = data.title === document.title; + const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth; + const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth; + + const auditLogs: CreateDocumentAuditLogDataResponse[] = []; + + if (!isTitleSame && document.status !== DocumentStatus.DRAFT) { + throw new AppError( + AppErrorCode.INVALID_BODY, + 'You cannot update the title if the document has been sent', + ); + } + + if (!isTitleSame) { + auditLogs.push( + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED, + documentId, + user, + requestMetadata, + data: { + from: document.title, + to: data.title || '', + }, + }), + ); + } + + if (!isGlobalAccessSame) { + auditLogs.push( + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED, + documentId, + user, + requestMetadata, + data: { + from: documentGlobalAccessAuth, + to: newGlobalAccessAuth, + }, + }), + ); + } + + if (!isGlobalActionSame) { + auditLogs.push( + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED, + documentId, + user, + requestMetadata, + data: { + from: documentGlobalActionAuth, + to: newGlobalActionAuth, + }, + }), + ); + } + + // Early return if nothing is required. + if (auditLogs.length === 0) { + return document; + } + + return await prisma.$transaction(async (tx) => { + const authOptions = createDocumentAuthOptions({ + globalAccessAuth: newGlobalAccessAuth, + globalActionAuth: newGlobalActionAuth, + }); + + const updatedDocument = await tx.document.update({ + where: { + id: documentId, + }, + data: { + title: data.title, + authOptions, + }, + }); + + await tx.documentAuditLog.createMany({ + data: auditLogs, + }); + + return updatedDocument; + }); +}; diff --git a/packages/lib/server-only/document/viewed-document.ts b/packages/lib/server-only/document/viewed-document.ts index 9722b4fbf..73ca606cc 100644 --- a/packages/lib/server-only/document/viewed-document.ts +++ b/packages/lib/server-only/document/viewed-document.ts @@ -5,15 +5,21 @@ import { prisma } from '@documenso/prisma'; import { ReadStatus } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; +import type { TDocumentAccessAuthTypes } from '../../types/document-auth'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { getDocumentAndRecipientByToken } from './get-document-by-token'; export type ViewedDocumentOptions = { token: string; + recipientAccessAuth?: TDocumentAccessAuthTypes | null; requestMetadata?: RequestMetadata; }; -export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentOptions) => { +export const viewedDocument = async ({ + token, + recipientAccessAuth, + requestMetadata, +}: ViewedDocumentOptions) => { const recipient = await prisma.recipient.findFirst({ where: { token, @@ -51,12 +57,13 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO recipientId: recipient.id, recipientName: recipient.name, recipientRole: recipient.role, + accessAuth: recipientAccessAuth || undefined, }, }), }); }); - const document = await getDocumentAndRecipientByToken({ token }); + const document = await getDocumentAndRecipientByToken({ token, requireAccessAuth: false }); await triggerWebhook({ event: WebhookTriggerEvents.DOCUMENT_OPENED, diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts index aa3056f52..b8a5ccf8f 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -8,15 +8,21 @@ import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/clie import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; +import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import type { TRecipientActionAuth } from '../../types/document-auth'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; +import { extractDocumentAuthMethods } from '../../utils/document-auth'; +import { isRecipientAuthorized } from '../document/is-recipient-authorized'; export type SignFieldWithTokenOptions = { token: string; fieldId: number; value: string; isBase64?: boolean; + userId?: number; + authOptions?: TRecipientActionAuth; requestMetadata?: RequestMetadata; }; @@ -25,6 +31,8 @@ export const signFieldWithToken = async ({ fieldId, value, isBase64, + userId, + authOptions, requestMetadata, }: SignFieldWithTokenOptions) => { const field = await prisma.field.findFirstOrThrow({ @@ -71,6 +79,33 @@ export const signFieldWithToken = async ({ throw new Error(`Field ${fieldId} has no recipientId`); } + let { derivedRecipientActionAuth } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }); + + // Override all non-signature fields to not require any auth. + if (field.type !== FieldType.SIGNATURE) { + derivedRecipientActionAuth = null; + } + + let isValid = true; + + // Only require auth on signature fields for now. + if (field.type === FieldType.SIGNATURE) { + isValid = await isRecipientAuthorized({ + type: 'ACTION', + document: document, + recipient: recipient, + userId, + authOptions, + }); + } + + if (!isValid) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values'); + } + const documentMeta = await prisma.documentMeta.findFirst({ where: { documentId: document.id, @@ -158,9 +193,11 @@ export const signFieldWithToken = async ({ data: updatedField.customText, })) .exhaustive(), - fieldSecurity: { - type: 'NONE', - }, + fieldSecurity: derivedRecipientActionAuth + ? { + type: derivedRecipientActionAuth, + } + : undefined, }, }), }); diff --git a/packages/lib/server-only/field/update-field.ts b/packages/lib/server-only/field/update-field.ts index b59760cd2..84358d245 100644 --- a/packages/lib/server-only/field/update-field.ts +++ b/packages/lib/server-only/field/update-field.ts @@ -1,8 +1,9 @@ import { prisma } from '@documenso/prisma'; import type { FieldType, Team } from '@documenso/prisma/client'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; -import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; +import { createDocumentAuditLogData, diffFieldChanges } from '../../utils/document-audit-logs'; export type UpdateFieldOptions = { fieldId: number; @@ -33,7 +34,7 @@ export const updateField = async ({ pageHeight, requestMetadata, }: UpdateFieldOptions) => { - const field = await prisma.field.update({ + const oldField = await prisma.field.findFirstOrThrow({ where: { id: fieldId, Document: { @@ -55,23 +56,49 @@ export const updateField = async ({ }), }, }, - data: { - recipientId, - type, - page: pageNumber, - positionX: pageX, - positionY: pageY, - width: pageWidth, - height: pageHeight, - }, - include: { - Recipient: true, - }, }); - if (!field) { - throw new Error('Field not found'); - } + const field = prisma.$transaction(async (tx) => { + const updatedField = await tx.field.update({ + where: { + id: fieldId, + }, + data: { + recipientId, + type, + page: pageNumber, + positionX: pageX, + positionY: pageY, + width: pageWidth, + height: pageHeight, + }, + include: { + Recipient: true, + }, + }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED, + documentId, + user: { + id: team?.id ?? user.id, + email: team?.name ?? user.email, + name: team ? '' : user.name, + }, + data: { + fieldId: updatedField.secondaryId, + fieldRecipientEmail: updatedField.Recipient?.email ?? '', + fieldRecipientId: recipientId ?? -1, + fieldType: updatedField.type, + changes: diffFieldChanges(oldField, updatedField), + }, + requestMetadata, + }), + }); + + return updatedField; + }); const user = await prisma.user.findFirstOrThrow({ where: { @@ -99,24 +126,5 @@ export const updateField = async ({ }); } - await prisma.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: 'FIELD_UPDATED', - documentId, - user: { - id: team?.id ?? user.id, - email: team?.name ?? user.email, - name: team ? '' : user.name, - }, - data: { - fieldId: field.secondaryId, - fieldRecipientEmail: field.Recipient?.email ?? '', - fieldRecipientId: recipientId ?? -1, - fieldType: field.type, - }, - requestMetadata, - }), - }); - return field; }; diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts index f9f8426bc..2dfc599ef 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -1,15 +1,23 @@ +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { + type TRecipientActionAuthTypes, + ZRecipientAuthOptionsSchema, +} from '@documenso/lib/types/document-auth'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { nanoid } from '@documenso/lib/universal/id'; import { createDocumentAuditLogData, diffRecipientChanges, } from '@documenso/lib/utils/document-audit-logs'; +import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth'; import { prisma } from '@documenso/prisma'; import type { Recipient } from '@documenso/prisma/client'; import { RecipientRole } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client'; +import { AppError, AppErrorCode } from '../../errors/app-error'; + export interface SetRecipientsForDocumentOptions { userId: number; teamId?: number; @@ -19,6 +27,7 @@ export interface SetRecipientsForDocumentOptions { email: string; name: string; role: RecipientRole; + actionAuth?: TRecipientActionAuthTypes | null; }[]; requestMetadata?: RequestMetadata; } @@ -70,6 +79,23 @@ export const setRecipientsForDocument = async ({ throw new Error('Document already complete'); } + const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); + + // Check if user has permission to set the global action auth. + if (recipientsHaveActionAuth) { + const isDocumentEnterprise = await isUserEnterprise({ + userId, + teamId, + }); + + if (!isDocumentEnterprise) { + throw new AppError( + AppErrorCode.UNAUTHORIZED, + 'You do not have permission to set the action auth', + ); + } + } + const normalizedRecipients = recipients.map((recipient) => ({ ...recipient, email: recipient.email.toLowerCase(), @@ -112,6 +138,15 @@ export const setRecipientsForDocument = async ({ const persistedRecipients = await prisma.$transaction(async (tx) => { return await Promise.all( linkedRecipients.map(async (recipient) => { + let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions); + + if (recipient.actionAuth !== undefined) { + authOptions = createRecipientAuthOptions({ + accessAuth: authOptions.accessAuth, + actionAuth: recipient.actionAuth, + }); + } + const upsertedRecipient = await tx.recipient.upsert({ where: { id: recipient._persisted?.id ?? -1, @@ -125,6 +160,7 @@ export const setRecipientsForDocument = async ({ sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, + authOptions, }, create: { name: recipient.name, @@ -135,6 +171,7 @@ export const setRecipientsForDocument = async ({ sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, + authOptions, }, }); @@ -188,7 +225,10 @@ export const setRecipientsForDocument = async ({ documentId: documentId, user, requestMetadata, - data: baseAuditLog, + data: { + ...baseAuditLog, + actionAuth: recipient.actionAuth || undefined, + }, }), }); } diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index 14d594786..4b37aa485 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -8,6 +8,8 @@ import { z } from 'zod'; import { FieldType } from '@documenso/prisma/client'; +import { ZRecipientActionAuthTypesSchema } from './document-auth'; + export const ZDocumentAuditLogTypeSchema = z.enum([ // Document actions. 'EMAIL_SENT', @@ -26,6 +28,8 @@ export const ZDocumentAuditLogTypeSchema = z.enum([ 'DOCUMENT_DELETED', // When the document is soft deleted. 'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient. 'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient. + 'DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED', // When the global access authentication is updated. + 'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated. 'DOCUMENT_META_UPDATED', // When the document meta data is updated. 'DOCUMENT_OPENED', // When the document is opened by a recipient. 'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document. @@ -51,7 +55,13 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([ ]); export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']); -export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']); +export const ZRecipientDiffTypeSchema = z.enum([ + 'NAME', + 'ROLE', + 'EMAIL', + 'ACCESS_AUTH', + 'ACTION_AUTH', +]); export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum; export const DOCUMENT_EMAIL_TYPE = ZDocumentAuditLogEmailTypeSchema.Enum; @@ -107,25 +117,34 @@ export const ZDocumentAuditLogFieldDiffSchema = z.union([ ZFieldDiffPositionSchema, ]); -export const ZRecipientDiffNameSchema = z.object({ +export const ZGenericFromToSchema = z.object({ + from: z.string().nullable(), + to: z.string().nullable(), +}); + +export const ZRecipientDiffActionAuthSchema = ZGenericFromToSchema.extend({ + type: z.literal(RECIPIENT_DIFF_TYPE.ACCESS_AUTH), +}); + +export const ZRecipientDiffAccessAuthSchema = ZGenericFromToSchema.extend({ + type: z.literal(RECIPIENT_DIFF_TYPE.ACTION_AUTH), +}); + +export const ZRecipientDiffNameSchema = ZGenericFromToSchema.extend({ type: z.literal(RECIPIENT_DIFF_TYPE.NAME), - from: z.string(), - to: z.string(), }); -export const ZRecipientDiffRoleSchema = z.object({ +export const ZRecipientDiffRoleSchema = ZGenericFromToSchema.extend({ type: z.literal(RECIPIENT_DIFF_TYPE.ROLE), - from: z.string(), - to: z.string(), }); -export const ZRecipientDiffEmailSchema = z.object({ +export const ZRecipientDiffEmailSchema = ZGenericFromToSchema.extend({ type: z.literal(RECIPIENT_DIFF_TYPE.EMAIL), - from: z.string(), - to: z.string(), }); -export const ZDocumentAuditLogRecipientDiffSchema = z.union([ +export const ZDocumentAuditLogRecipientDiffSchema = z.discriminatedUnion('type', [ + ZRecipientDiffActionAuthSchema, + ZRecipientDiffAccessAuthSchema, ZRecipientDiffNameSchema, ZRecipientDiffRoleSchema, ZRecipientDiffEmailSchema, @@ -217,11 +236,11 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({ data: z.string(), }), ]), - - // Todo: Replace with union once we have more field security types. - fieldSecurity: z.object({ - type: z.literal('NONE'), - }), + fieldSecurity: z + .object({ + type: ZRecipientActionAuthTypesSchema, + }) + .optional(), }), }); @@ -236,6 +255,22 @@ export const ZDocumentAuditLogEventDocumentFieldUninsertedSchema = z.object({ }), }); +/** + * Event: Document global authentication access updated. + */ +export const ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED), + data: ZGenericFromToSchema, +}); + +/** + * Event: Document global authentication action updated. + */ +export const ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED), + data: ZGenericFromToSchema, +}); + /** * Event: Document meta updated. */ @@ -251,7 +286,9 @@ export const ZDocumentAuditLogEventDocumentMetaUpdatedSchema = z.object({ */ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED), - data: ZBaseRecipientDataSchema, + data: ZBaseRecipientDataSchema.extend({ + accessAuth: z.string().optional(), + }), }); /** @@ -259,7 +296,9 @@ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({ */ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED), - data: ZBaseRecipientDataSchema, + data: ZBaseRecipientDataSchema.extend({ + actionAuth: z.string().optional(), + }), }); /** @@ -303,7 +342,9 @@ export const ZDocumentAuditLogEventFieldRemovedSchema = z.object({ export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED), data: ZBaseFieldEventDataSchema.extend({ - changes: z.array(ZDocumentAuditLogFieldDiffSchema), + // Provide an empty array as a migration workaround due to a mistake where we were + // not passing through any changes via API/v1 due to a type error. + changes: z.preprocess((x) => x || [], z.array(ZDocumentAuditLogFieldDiffSchema)), }), }); @@ -312,7 +353,9 @@ export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({ */ export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED), - data: ZBaseRecipientDataSchema, + data: ZBaseRecipientDataSchema.extend({ + actionAuth: ZRecipientActionAuthTypesSchema.optional(), + }), }); /** @@ -352,6 +395,8 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( ZDocumentAuditLogEventDocumentDeletedSchema, ZDocumentAuditLogEventDocumentFieldInsertedSchema, ZDocumentAuditLogEventDocumentFieldUninsertedSchema, + ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema, + ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema, ZDocumentAuditLogEventDocumentMetaUpdatedSchema, ZDocumentAuditLogEventDocumentOpenedSchema, ZDocumentAuditLogEventDocumentRecipientCompleteSchema, diff --git a/packages/lib/types/document-auth.ts b/packages/lib/types/document-auth.ts new file mode 100644 index 000000000..730806d0c --- /dev/null +++ b/packages/lib/types/document-auth.ts @@ -0,0 +1,121 @@ +import { z } from 'zod'; + +/** + * All the available types of document authentication options for both access and action. + */ +export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'EXPLICIT_NONE']); +export const DocumentAuth = ZDocumentAuthTypesSchema.Enum; + +const ZDocumentAuthAccountSchema = z.object({ + type: z.literal(DocumentAuth.ACCOUNT), +}); + +const ZDocumentAuthExplicitNoneSchema = z.object({ + type: z.literal(DocumentAuth.EXPLICIT_NONE), +}); + +/** + * All the document auth methods for both accessing and actioning. + */ +export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [ + ZDocumentAuthAccountSchema, + ZDocumentAuthExplicitNoneSchema, +]); + +/** + * The global document access auth methods. + * + * Must keep these two in sync. + */ +export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); +export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); + +/** + * The global document action auth methods. + * + * Must keep these two in sync. + */ +export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); // Todo: Add passkeys here. +export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); + +/** + * The recipient access auth methods. + * + * Must keep these two in sync. + */ +export const ZRecipientAccessAuthSchema = z.discriminatedUnion('type', [ + ZDocumentAuthAccountSchema, +]); +export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); + +/** + * The recipient action auth methods. + * + * Must keep these two in sync. + */ +export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [ + ZDocumentAuthAccountSchema, // Todo: Add passkeys here. + ZDocumentAuthExplicitNoneSchema, +]); +export const ZRecipientActionAuthTypesSchema = z.enum([ + DocumentAuth.ACCOUNT, + DocumentAuth.EXPLICIT_NONE, +]); + +export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum; +export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum; +export const RecipientAccessAuth = ZRecipientAccessAuthTypesSchema.Enum; +export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum; + +/** + * Authentication options attached to the document. + */ +export const ZDocumentAuthOptionsSchema = z.preprocess( + (unknownValue) => { + if (unknownValue) { + return unknownValue; + } + + return { + globalAccessAuth: null, + globalActionAuth: null, + }; + }, + z.object({ + globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable(), + globalActionAuth: ZDocumentActionAuthTypesSchema.nullable(), + }), +); + +/** + * Authentication options attached to the recipient. + */ +export const ZRecipientAuthOptionsSchema = z.preprocess( + (unknownValue) => { + if (unknownValue) { + return unknownValue; + } + + return { + accessAuth: null, + actionAuth: null, + }; + }, + z.object({ + accessAuth: ZRecipientAccessAuthTypesSchema.nullable(), + actionAuth: ZRecipientActionAuthTypesSchema.nullable(), + }), +); + +export type TDocumentAuth = z.infer; +export type TDocumentAuthMethods = z.infer; +export type TDocumentAuthOptions = z.infer; +export type TDocumentAccessAuth = z.infer; +export type TDocumentAccessAuthTypes = z.infer; +export type TDocumentActionAuth = z.infer; +export type TDocumentActionAuthTypes = z.infer; +export type TRecipientAccessAuth = z.infer; +export type TRecipientAccessAuthTypes = z.infer; +export type TRecipientActionAuth = z.infer; +export type TRecipientActionAuthTypes = z.infer; +export type TRecipientAuthOptions = z.infer; diff --git a/packages/lib/utils/billing.ts b/packages/lib/utils/billing.ts index 048fa6ee0..6d2926420 100644 --- a/packages/lib/utils/billing.ts +++ b/packages/lib/utils/billing.ts @@ -1,3 +1,6 @@ +import { env } from 'next-runtime-env'; + +import { IS_BILLING_ENABLED } from '../constants/app'; import type { Subscription } from '.prisma/client'; import { SubscriptionStatus } from '.prisma/client'; @@ -13,3 +16,15 @@ export const subscriptionsContainsActivePlan = ( subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId), ); }; + +export const subscriptionsContainActiveEnterprisePlan = ( + subscriptions?: Subscription[], +): boolean => { + const enterprisePlanId = env('NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID'); + + if (!enterprisePlanId || !subscriptions || !IS_BILLING_ENABLED()) { + return false; + } + + return subscriptionsContainsActivePlan(subscriptions, [enterprisePlanId]); +}; diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index 65ffb2817..97ef38c8b 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -22,6 +22,7 @@ import { RECIPIENT_DIFF_TYPE, ZDocumentAuditLogSchema, } from '../types/document-audit-logs'; +import { ZRecipientAuthOptionsSchema } from '../types/document-auth'; import type { RequestMetadata } from '../universal/extract-request-metadata'; type CreateDocumentAuditLogDataOptions = { @@ -32,20 +33,20 @@ type CreateDocumentAuditLogDataOptions = { requestMetadata?: RequestMetadata; }; -type CreateDocumentAuditLogDataResponse = Pick< +export type CreateDocumentAuditLogDataResponse = Pick< DocumentAuditLog, 'type' | 'ipAddress' | 'userAgent' | 'email' | 'userId' | 'name' | 'documentId' > & { data: TDocumentAuditLog['data']; }; -export const createDocumentAuditLogData = ({ +export const createDocumentAuditLogData = ({ documentId, type, data, user, requestMetadata, -}: CreateDocumentAuditLogDataOptions): CreateDocumentAuditLogDataResponse => { +}: CreateDocumentAuditLogDataOptions): CreateDocumentAuditLogDataResponse => { return { type, data, @@ -68,6 +69,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument // Handle any required migrations here. if (!data.success) { + // Todo: Alert us. console.error(data.error); throw new Error('Migration required'); } @@ -75,7 +77,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument return data.data; }; -type PartialRecipient = Pick; +type PartialRecipient = Pick; export const diffRecipientChanges = ( oldRecipient: PartialRecipient, @@ -83,6 +85,32 @@ export const diffRecipientChanges = ( ): TDocumentAuditLogRecipientDiffSchema[] => { const diffs: TDocumentAuditLogRecipientDiffSchema[] = []; + const oldAuthOptions = ZRecipientAuthOptionsSchema.parse(oldRecipient.authOptions); + const oldAccessAuth = oldAuthOptions.accessAuth; + const oldActionAuth = oldAuthOptions.actionAuth; + + const newAuthOptions = ZRecipientAuthOptionsSchema.parse(newRecipient.authOptions); + const newAccessAuth = + newAuthOptions?.accessAuth === undefined ? oldAccessAuth : newAuthOptions.accessAuth; + const newActionAuth = + newAuthOptions?.actionAuth === undefined ? oldActionAuth : newAuthOptions.actionAuth; + + if (oldAccessAuth !== newAccessAuth) { + diffs.push({ + type: RECIPIENT_DIFF_TYPE.ACCESS_AUTH, + from: oldAccessAuth ?? '', + to: newAccessAuth ?? '', + }); + } + + if (oldActionAuth !== newActionAuth) { + diffs.push({ + type: RECIPIENT_DIFF_TYPE.ACTION_AUTH, + from: oldActionAuth ?? '', + to: newActionAuth ?? '', + }); + } + if (oldRecipient.email !== newRecipient.email) { diffs.push({ type: RECIPIENT_DIFF_TYPE.EMAIL, @@ -166,7 +194,13 @@ export const diffDocumentMetaChanges = ( const oldPassword = oldData?.password ?? null; const oldRedirectUrl = oldData?.redirectUrl ?? ''; - if (oldDateFormat !== newData.dateFormat) { + const newDateFormat = newData?.dateFormat ?? ''; + const newMessage = newData?.message ?? ''; + const newSubject = newData?.subject ?? ''; + const newTimezone = newData?.timezone ?? ''; + const newRedirectUrl = newData?.redirectUrl ?? ''; + + if (oldDateFormat !== newDateFormat) { diffs.push({ type: DOCUMENT_META_DIFF_TYPE.DATE_FORMAT, from: oldData?.dateFormat ?? '', @@ -174,35 +208,35 @@ export const diffDocumentMetaChanges = ( }); } - if (oldMessage !== newData.message) { + if (oldMessage !== newMessage) { diffs.push({ type: DOCUMENT_META_DIFF_TYPE.MESSAGE, from: oldMessage, - to: newData.message, + to: newMessage, }); } - if (oldSubject !== newData.subject) { + if (oldSubject !== newSubject) { diffs.push({ type: DOCUMENT_META_DIFF_TYPE.SUBJECT, from: oldSubject, - to: newData.subject, + to: newSubject, }); } - if (oldTimezone !== newData.timezone) { + if (oldTimezone !== newTimezone) { diffs.push({ type: DOCUMENT_META_DIFF_TYPE.TIMEZONE, from: oldTimezone, - to: newData.timezone, + to: newTimezone, }); } - if (oldRedirectUrl !== newData.redirectUrl) { + if (oldRedirectUrl !== newRedirectUrl) { diffs.push({ type: DOCUMENT_META_DIFF_TYPE.REDIRECT_URL, from: oldRedirectUrl, - to: newData.redirectUrl, + to: newRedirectUrl, }); } @@ -278,6 +312,14 @@ export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId anonymous: 'Field unsigned', identified: 'unsigned a field', })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({ + anonymous: 'Document access auth updated', + identified: 'updated the document access auth requirements', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({ + anonymous: 'Document signing auth updated', + identified: 'updated the document signing auth requirements', + })) .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({ anonymous: 'Document updated', identified: 'updated the document', diff --git a/packages/lib/utils/document-auth.ts b/packages/lib/utils/document-auth.ts new file mode 100644 index 000000000..e1e536fc8 --- /dev/null +++ b/packages/lib/utils/document-auth.ts @@ -0,0 +1,72 @@ +import type { Document, Recipient } from '@documenso/prisma/client'; + +import type { + TDocumentAuthOptions, + TRecipientAccessAuthTypes, + TRecipientActionAuthTypes, + TRecipientAuthOptions, +} from '../types/document-auth'; +import { DocumentAuth } from '../types/document-auth'; +import { ZDocumentAuthOptionsSchema, ZRecipientAuthOptionsSchema } from '../types/document-auth'; + +type ExtractDocumentAuthMethodsOptions = { + documentAuth: Document['authOptions']; + recipientAuth?: Recipient['authOptions']; +}; + +/** + * Parses and extracts the document and recipient authentication values. + * + * Will combine the recipient and document auth values to derive the final + * auth values for a recipient if possible. + */ +export const extractDocumentAuthMethods = ({ + documentAuth, + recipientAuth, +}: ExtractDocumentAuthMethodsOptions) => { + const documentAuthOption = ZDocumentAuthOptionsSchema.parse(documentAuth); + const recipientAuthOption = ZRecipientAuthOptionsSchema.parse(recipientAuth); + + const derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null = + recipientAuthOption.accessAuth || documentAuthOption.globalAccessAuth; + + const derivedRecipientActionAuth: TRecipientActionAuthTypes | null = + recipientAuthOption.actionAuth || documentAuthOption.globalActionAuth; + + const recipientAccessAuthRequired = derivedRecipientAccessAuth !== null; + + const recipientActionAuthRequired = + derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE && + derivedRecipientActionAuth !== null; + + return { + derivedRecipientAccessAuth, + derivedRecipientActionAuth, + recipientAccessAuthRequired, + recipientActionAuthRequired, + documentAuthOption, + recipientAuthOption, + }; +}; + +/** + * Create document auth options in a type safe way. + */ +export const createDocumentAuthOptions = (options: TDocumentAuthOptions): TDocumentAuthOptions => { + return { + globalAccessAuth: options?.globalAccessAuth ?? null, + globalActionAuth: options?.globalActionAuth ?? null, + }; +}; + +/** + * Create recipient auth options in a type safe way. + */ +export const createRecipientAuthOptions = ( + options: TRecipientAuthOptions, +): TRecipientAuthOptions => { + return { + accessAuth: options?.accessAuth ?? null, + actionAuth: options?.actionAuth ?? null, + }; +}; diff --git a/packages/prisma/migrations/20240311113243_add_document_auth/migration.sql b/packages/prisma/migrations/20240311113243_add_document_auth/migration.sql new file mode 100644 index 000000000..8cb96765e --- /dev/null +++ b/packages/prisma/migrations/20240311113243_add_document_auth/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "authOptions" JSONB; + +-- AlterTable +ALTER TABLE "Recipient" ADD COLUMN "authOptions" JSONB; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index aa161fa1f..d632ae60e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -255,6 +255,7 @@ model Document { id Int @id @default(autoincrement()) userId Int User User @relation(fields: [userId], references: [id], onDelete: Cascade) + authOptions Json? title String status DocumentStatus @default(DRAFT) Recipient Recipient[] @@ -352,6 +353,7 @@ model Recipient { token String expired DateTime? signedAt DateTime? + authOptions Json? role RecipientRole @default(SIGNER) readStatus ReadStatus @default(NOT_OPENED) signingStatus SigningStatus @default(NOT_SIGNED) diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts index 1f1f5cab8..1fceca900 100644 --- a/packages/prisma/seed/documents.ts +++ b/packages/prisma/seed/documents.ts @@ -1,4 +1,4 @@ -import type { User } from '@prisma/client'; +import type { Document, User } from '@prisma/client'; import { nanoid } from 'nanoid'; import fs from 'node:fs'; import path from 'node:path'; @@ -33,19 +33,19 @@ export const seedDocuments = async (documents: DocumentToSeed[]) => { documents.map(async (document, i) => match(document.type) .with(DocumentStatus.DRAFT, async () => - createDraftDocument(document.sender, document.recipients, { + seedDraftDocument(document.sender, document.recipients, { key: i, createDocumentOptions: document.documentOptions, }), ) .with(DocumentStatus.PENDING, async () => - createPendingDocument(document.sender, document.recipients, { + seedPendingDocument(document.sender, document.recipients, { key: i, createDocumentOptions: document.documentOptions, }), ) .with(DocumentStatus.COMPLETED, async () => - createCompletedDocument(document.sender, document.recipients, { + seedCompletedDocument(document.sender, document.recipients, { key: i, createDocumentOptions: document.documentOptions, }), @@ -55,7 +55,37 @@ export const seedDocuments = async (documents: DocumentToSeed[]) => { ); }; -const createDraftDocument = async ( +export const seedBlankDocument = async (owner: User, options: CreateDocumentOptions = {}) => { + const { key, createDocumentOptions = {} } = options; + + const documentData = await prisma.documentData.create({ + data: { + type: DocumentDataType.BYTES_64, + data: examplePdf, + initialData: examplePdf, + }, + }); + + return await prisma.document.create({ + data: { + title: `[TEST] Document ${key} - Draft`, + status: DocumentStatus.DRAFT, + documentDataId: documentData.id, + userId: owner.id, + ...createDocumentOptions, + }, + }); +}; + +export const unseedDocument = async (documentId: number) => { + await prisma.document.delete({ + where: { + id: documentId, + }, + }); +}; + +export const seedDraftDocument = async ( sender: User, recipients: (User | string)[], options: CreateDocumentOptions = {}, @@ -114,6 +144,8 @@ const createDraftDocument = async ( }, }); } + + return document; }; type CreateDocumentOptions = { @@ -121,7 +153,7 @@ type CreateDocumentOptions = { createDocumentOptions?: Partial; }; -const createPendingDocument = async ( +export const seedPendingDocument = async ( sender: User, recipients: (User | string)[], options: CreateDocumentOptions = {}, @@ -180,9 +212,145 @@ const createPendingDocument = async ( }, }); } + + return document; }; -const createCompletedDocument = async ( +export const seedPendingDocumentNoFields = async ({ + owner, + recipients, + updateDocumentOptions, +}: { + owner: User; + recipients: (User | string)[]; + updateDocumentOptions?: Partial; +}) => { + const document: Document = await seedBlankDocument(owner); + + for (const recipient of recipients) { + const email = typeof recipient === 'string' ? recipient : recipient.email; + const name = typeof recipient === 'string' ? recipient : recipient.name ?? ''; + + await prisma.recipient.create({ + data: { + email, + name, + token: nanoid(), + readStatus: ReadStatus.OPENED, + sendStatus: SendStatus.SENT, + signingStatus: SigningStatus.NOT_SIGNED, + signedAt: new Date(), + Document: { + connect: { + id: document.id, + }, + }, + }, + }); + } + + const createdRecipients = await prisma.recipient.findMany({ + where: { + documentId: document.id, + }, + include: { + Field: true, + }, + }); + + const latestDocument = updateDocumentOptions + ? await prisma.document.update({ + where: { + id: document.id, + }, + data: updateDocumentOptions, + }) + : document; + + return { + document: latestDocument, + recipients: createdRecipients, + }; +}; + +export const seedPendingDocumentWithFullFields = async ({ + owner, + recipients, + recipientsCreateOptions, + updateDocumentOptions, + fields = [FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.SIGNATURE, FieldType.TEXT], +}: { + owner: User; + recipients: (User | string)[]; + recipientsCreateOptions?: Partial[]; + updateDocumentOptions?: Partial; + fields?: FieldType[]; +}) => { + const document: Document = await seedBlankDocument(owner); + + for (const [recipientIndex, recipient] of recipients.entries()) { + const email = typeof recipient === 'string' ? recipient : recipient.email; + const name = typeof recipient === 'string' ? recipient : recipient.name ?? ''; + + await prisma.recipient.create({ + data: { + email, + name, + token: nanoid(), + readStatus: ReadStatus.OPENED, + sendStatus: SendStatus.SENT, + signingStatus: SigningStatus.NOT_SIGNED, + signedAt: new Date(), + Document: { + connect: { + id: document.id, + }, + }, + Field: { + createMany: { + data: fields.map((fieldType, fieldIndex) => ({ + page: 1, + type: fieldType, + inserted: false, + customText: name, + positionX: new Prisma.Decimal((recipientIndex + 1) * 5), + positionY: new Prisma.Decimal((fieldIndex + 1) * 5), + width: new Prisma.Decimal(5), + height: new Prisma.Decimal(5), + documentId: document.id, + })), + }, + }, + ...(recipientsCreateOptions?.[recipientIndex] ?? {}), + }, + }); + } + + const createdRecipients = await prisma.recipient.findMany({ + where: { + documentId: document.id, + }, + include: { + Field: true, + }, + }); + + const latestDocument = updateDocumentOptions + ? await prisma.document.update({ + where: { + id: document.id, + }, + data: updateDocumentOptions, + }) + : document; + + return { + document: latestDocument, + recipients: createdRecipients, + }; +}; + +export const seedCompletedDocument = async ( sender: User, recipients: (User | string)[], options: CreateDocumentOptions = {}, @@ -241,6 +409,8 @@ const createCompletedDocument = async ( }, }); } + + return document; }; /** diff --git a/packages/prisma/seed/pr-718-add-stepper-component.ts b/packages/prisma/seed/pr-718-add-stepper-component.ts deleted file mode 100644 index d436a97b1..000000000 --- a/packages/prisma/seed/pr-718-add-stepper-component.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { hashSync } from '@documenso/lib/server-only/auth/hash'; - -import { prisma } from '..'; - -// -// https://github.com/documenso/documenso/pull/713 -// - -const PULL_REQUEST_NUMBER = 718; - -const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`; - -export const TEST_USER = { - name: 'User 1', - email: `user1@${EMAIL_DOMAIN}`, - password: 'Password123', -} as const; - -export const seedDatabase = async () => { - await prisma.user.create({ - data: { - name: TEST_USER.name, - email: TEST_USER.email, - password: hashSync(TEST_USER.password), - emailVerified: new Date(), - url: TEST_USER.email, - }, - }); -}; diff --git a/packages/prisma/seed/subscriptions.ts b/packages/prisma/seed/subscriptions.ts new file mode 100644 index 000000000..8e237299f --- /dev/null +++ b/packages/prisma/seed/subscriptions.ts @@ -0,0 +1,19 @@ +import { prisma } from '..'; + +export const seedTestEmail = () => `user-${Date.now()}@test.documenso.com`; + +type SeedSubscriptionOptions = { + userId: number; + priceId: string; +}; + +export const seedUserSubscription = async ({ userId, priceId }: SeedSubscriptionOptions) => { + return await prisma.subscription.create({ + data: { + userId, + planId: Date.now().toString(), + priceId, + status: 'ACTIVE', + }, + }); +}; diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts index 353683a1d..fd8706fea 100644 --- a/packages/prisma/seed/users.ts +++ b/packages/prisma/seed/users.ts @@ -2,6 +2,8 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash'; import { prisma } from '..'; +export const seedTestEmail = () => `user-${Date.now()}@test.documenso.com`; + type SeedUserOptions = { name?: string; email?: string; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 70cf15291..4a6f11e60 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -13,6 +13,7 @@ import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/ import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; +import { updateDocumentSettings } from '@documenso/lib/server-only/document/update-document-settings'; import { updateTitle } from '@documenso/lib/server-only/document/update-title'; import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; @@ -29,6 +30,7 @@ import { ZSearchDocumentsMutationSchema, ZSendDocumentMutationSchema, ZSetPasswordForDocumentMutationSchema, + ZSetSettingsForDocumentMutationSchema, ZSetTitleForDocumentMutationSchema, } from './schema'; @@ -51,22 +53,25 @@ export const documentRouter = router({ } }), - getDocumentByToken: procedure.input(ZGetDocumentByTokenQuerySchema).query(async ({ input }) => { - try { - const { token } = input; + getDocumentByToken: procedure + .input(ZGetDocumentByTokenQuerySchema) + .query(async ({ input, ctx }) => { + try { + const { token } = input; - return await getDocumentAndSenderByToken({ - token, - }); - } catch (err) { - console.error(err); + return await getDocumentAndSenderByToken({ + token, + userId: ctx.user?.id, + }); + } catch (err) { + console.error(err); - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'We were unable to find this document. Please try again later.', - }); - } - }), + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to find this document. Please try again later.', + }); + } + }), getDocumentWithDetailsById: authenticatedProcedure .input(ZGetDocumentWithDetailsByIdQuerySchema) @@ -170,6 +175,46 @@ export const documentRouter = router({ } }), + // Todo: Add API + setSettingsForDocument: authenticatedProcedure + .input(ZSetSettingsForDocumentMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, teamId, data, meta } = input; + + const userId = ctx.user.id; + + const requestMetadata = extractNextApiRequestMetadata(ctx.req); + + if (meta.timezone || meta.dateFormat || meta.redirectUrl) { + await upsertDocumentMeta({ + documentId, + dateFormat: meta.dateFormat, + timezone: meta.timezone, + redirectUrl: meta.redirectUrl, + userId: ctx.user.id, + requestMetadata, + }); + } + + return await updateDocumentSettings({ + userId, + teamId, + documentId, + data, + requestMetadata, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'We were unable to update the settings for this document. Please try again later.', + }); + } + }), + setTitleForDocument: authenticatedProcedure .input(ZSetTitleForDocumentMutationSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 065552ee2..6ed6fcc4d 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -1,6 +1,10 @@ import { z } from 'zod'; import { URL_REGEX } from '@documenso/lib/constants/url-regex'; +import { + ZDocumentAccessAuthTypesSchema, + ZDocumentActionAuthTypesSchema, +} from '@documenso/lib/types/document-auth'; import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; import { FieldType, RecipientRole } from '@documenso/prisma/client'; @@ -46,6 +50,30 @@ export const ZCreateDocumentMutationSchema = z.object({ export type TCreateDocumentMutationSchema = z.infer; +export const ZSetSettingsForDocumentMutationSchema = z.object({ + documentId: z.number(), + teamId: z.number().min(1).optional(), + data: z.object({ + title: z.string().min(1).optional(), + globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(), + globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(), + }), + meta: z.object({ + timezone: z.string(), + dateFormat: z.string(), + redirectUrl: z + .string() + .optional() + .refine((value) => value === undefined || value === '' || URL_REGEX.test(value), { + message: 'Please enter a valid URL', + }), + }), +}); + +export type TSetGeneralSettingsForDocumentMutationSchema = z.infer< + typeof ZSetSettingsForDocumentMutationSchema +>; + export const ZSetTitleForDocumentMutationSchema = z.object({ documentId: z.number(), teamId: z.number().min(1).optional(), @@ -97,8 +125,8 @@ export const ZSendDocumentMutationSchema = z.object({ meta: z.object({ subject: z.string(), message: z.string(), - timezone: z.string(), - dateFormat: z.string(), + timezone: z.string().optional(), + dateFormat: z.string().optional(), redirectUrl: z .string() .optional() diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 4df1b1ddc..4b299b6a1 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server'; +import { AppError } from '@documenso/lib/errors/app-error'; import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token'; import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template'; @@ -71,22 +72,21 @@ export const fieldRouter = router({ .input(ZSignFieldWithTokenMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { token, fieldId, value, isBase64 } = input; + const { token, fieldId, value, isBase64, authOptions } = input; return await signFieldWithToken({ token, fieldId, value, isBase64, + userId: ctx.user?.id, + authOptions, requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'We were unable to sign this field. Please try again later.', - }); + throw AppError.parseErrorToTRPCError(err); } }), diff --git a/packages/trpc/server/field-router/schema.ts b/packages/trpc/server/field-router/schema.ts index 9bd576667..eaf5d5bc8 100644 --- a/packages/trpc/server/field-router/schema.ts +++ b/packages/trpc/server/field-router/schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { ZRecipientActionAuthSchema } from '@documenso/lib/types/document-auth'; import { FieldType } from '@documenso/prisma/client'; export const ZAddFieldsMutationSchema = z.object({ @@ -45,6 +46,7 @@ export const ZSignFieldWithTokenMutationSchema = z.object({ fieldId: z.number(), value: z.string().trim(), isBase64: z.boolean().optional(), + authOptions: ZRecipientActionAuthSchema.optional(), }); export type TSignFieldWithTokenMutationSchema = z.infer; diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index ac040f4f5..61740e9a0 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -28,6 +28,7 @@ export const recipientRouter = router({ email: signer.email, name: signer.name, role: signer.role, + actionAuth: signer.actionAuth, })), requestMetadata: extractNextApiRequestMetadata(ctx.req), }); @@ -71,11 +72,13 @@ export const recipientRouter = router({ .input(ZCompleteDocumentWithTokenMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { token, documentId } = input; + const { token, documentId, authOptions } = input; return await completeDocumentWithToken({ token, documentId, + authOptions, + userId: ctx.user?.id, requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index 6825137c4..4b5522150 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; +import { + ZRecipientActionAuthSchema, + ZRecipientActionAuthTypesSchema, +} from '@documenso/lib/types/document-auth'; import { RecipientRole } from '@documenso/prisma/client'; export const ZAddSignersMutationSchema = z @@ -12,6 +16,7 @@ export const ZAddSignersMutationSchema = z email: z.string().email().min(1), name: z.string(), role: z.nativeEnum(RecipientRole), + actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), }), ), }) @@ -54,6 +59,7 @@ export type TAddTemplateSignersMutationSchema = z.infer { return await next({ ctx: { ...ctx, - user: ctx.user, session: ctx.session, }, diff --git a/packages/ui/components/animate/animate-generic-fade-in-out.tsx b/packages/ui/components/animate/animate-generic-fade-in-out.tsx index 5f57c96df..78487b953 100644 --- a/packages/ui/components/animate/animate-generic-fade-in-out.tsx +++ b/packages/ui/components/animate/animate-generic-fade-in-out.tsx @@ -5,11 +5,17 @@ import { motion } from 'framer-motion'; type AnimateGenericFadeInOutProps = { children: React.ReactNode; className?: string; + motionKey?: string; }; -export const AnimateGenericFadeInOut = ({ children, className }: AnimateGenericFadeInOutProps) => { +export const AnimateGenericFadeInOut = ({ + children, + className, + motionKey, +}: AnimateGenericFadeInOutProps) => { return ( - + {!isLoading && } Download ); diff --git a/packages/ui/primitives/checkbox.tsx b/packages/ui/primitives/checkbox.tsx index 5acf35f9d..18ff53d47 100644 --- a/packages/ui/primitives/checkbox.tsx +++ b/packages/ui/primitives/checkbox.tsx @@ -16,7 +16,7 @@ const Checkbox = React.forwardRef< void; +}; + +export const AddSettingsFormPartial = ({ + documentFlow, + recipients, + fields, + isDocumentEnterprise, + isDocumentPdfLoaded, + document, + onSubmit, +}: AddSettingsFormProps) => { + const { documentAuthOption } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + }); + + const form = useForm({ + resolver: zodResolver(ZAddSettingsFormSchema), + defaultValues: { + title: document.title, + globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined, + globalActionAuth: documentAuthOption?.globalActionAuth || undefined, + meta: { + timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, + dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, + redirectUrl: document.documentMeta?.redirectUrl ?? '', + }, + }, + }); + + const { stepIndex, currentStep, totalSteps, previousStep } = useStep(); + + const documentHasBeenSent = recipients.some( + (recipient) => recipient.sendStatus === SendStatus.SENT, + ); + + // We almost always want to set the timezone to the user's local timezone to avoid confusion + // when the document is signed. + useEffect(() => { + if (!form.formState.touchedFields.meta?.timezone && !documentHasBeenSent) { + form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone); + } + }, [documentHasBeenSent, form, form.setValue, form.formState.touchedFields.meta?.timezone]); + + return ( + <> + + + + {isDocumentPdfLoaded && + fields.map((field, index) => ( + + ))} + +
+
+ ( + + Title + + + + + + + )} + /> + + ( + + + Document access + + + + + + +

+ Document access +

+ +

The authentication required for recipients to view the document.

+ +
    +
  • + Require account - The recipient must be signed in to + view the document +
  • +
  • + None - The document can be accessed directly by the URL + sent to the recipient +
  • +
+
+
+
+ + + + +
+ )} + /> + + {isDocumentEnterprise && ( + ( + + + Recipient action authentication + + + + + + +

+ Global recipient action authentication +

+ +

+ The authentication required for recipients to sign the signature field. +

+ +

+ This can be overriden by setting the authentication requirements + directly on each recipient in the next step. +

+ +
    +
  • + Require account - The recipient must be signed in +
  • +
  • + None - No authentication required +
  • +
+
+
+
+ + + + +
+ )} + /> + )} + + + + + Advanced Options + + + +
+ ( + + Date Format + + + + + + + + )} + /> + + ( + + Time Zone + + + value && field.onChange(value)} + disabled={documentHasBeenSent} + /> + + + + + )} + /> + + ( + + + Redirect URL{' '} + + + + + + + Add a URL to redirect the user to once the document is signed + + + + + + + + + + + )} + /> +
+
+
+
+
+
+
+ + + + + + + + ); +}; diff --git a/packages/ui/primitives/document-flow/add-settings.types.ts b/packages/ui/primitives/document-flow/add-settings.types.ts new file mode 100644 index 000000000..fb669999b --- /dev/null +++ b/packages/ui/primitives/document-flow/add-settings.types.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import { URL_REGEX } from '@documenso/lib/constants/url-regex'; +import { + ZDocumentAccessAuthTypesSchema, + ZDocumentActionAuthTypesSchema, +} from '@documenso/lib/types/document-auth'; + +export const ZMapNegativeOneToUndefinedSchema = z + .string() + .optional() + .transform((val) => { + if (val === '-1') { + return undefined; + } + + return val; + }); + +export const ZAddSettingsFormSchema = z.object({ + title: z.string().trim().min(1, { message: "Title can't be empty" }), + globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe( + ZDocumentAccessAuthTypesSchema.optional(), + ), + globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe( + ZDocumentActionAuthTypesSchema.optional(), + ), + meta: z.object({ + timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE), + dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT), + redirectUrl: z + .string() + .optional() + .refine((value) => value === undefined || value === '' || URL_REGEX.test(value), { + message: 'Please enter a valid URL', + }), + }), +}); + +export type TAddSettingsFormSchema = z.infer; diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 95f2c7983..3d1263914 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -1,25 +1,33 @@ 'use client'; -import React, { useId } from 'react'; +import React, { useId, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { AnimatePresence, motion } from 'framer-motion'; -import { Plus, Trash } from 'lucide-react'; -import { Controller, useFieldArray, useForm } from 'react-hook-form'; +import { motion } from 'framer-motion'; +import { InfoIcon, Plus, Trash } from 'lucide-react'; +import { useFieldArray, useForm } from 'react-hook-form'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; +import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; +import { + RecipientActionAuth, + ZRecipientAuthOptionsSchema, +} from '@documenso/lib/types/document-auth'; import { nanoid } from '@documenso/lib/universal/id'; import type { Field, Recipient } from '@documenso/prisma/client'; -import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client'; -import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; +import { RecipientRole, SendStatus } from '@documenso/prisma/client'; +import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; +import { cn } from '@documenso/ui/lib/utils'; import { Button } from '../button'; +import { Checkbox } from '../checkbox'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; import { FormErrorMessage } from '../form/form-error-message'; import { Input } from '../input'; -import { Label } from '../label'; import { ROLE_ICONS } from '../recipient-role-icons'; -import { Select, SelectContent, SelectItem, SelectTrigger } from '../select'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select'; import { useStep } from '../stepper'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; import { useToast } from '../use-toast'; import type { TAddSignersFormSchema } from './add-signers.types'; import { ZAddSignersFormSchema } from './add-signers.types'; @@ -37,7 +45,7 @@ export type AddSignersFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; fields: Field[]; - document: DocumentWithData; + isDocumentEnterprise: boolean; onSubmit: (_data: TAddSignersFormSchema) => void; isDocumentPdfLoaded: boolean; }; @@ -45,8 +53,8 @@ export type AddSignersFormProps = { export const AddSignersFormPartial = ({ documentFlow, recipients, - document, fields, + isDocumentEnterprise, onSubmit, isDocumentPdfLoaded, }: AddSignersFormProps) => { @@ -57,11 +65,7 @@ export const AddSignersFormPartial = ({ const { currentStep, totalSteps, previousStep } = useStep(); - const { - control, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ resolver: zodResolver(ZAddSignersFormSchema), defaultValues: { signers: @@ -72,6 +76,8 @@ export const AddSignersFormPartial = ({ name: recipient.name, email: recipient.email, role: recipient.role, + actionAuth: + ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, })) : [ { @@ -79,12 +85,33 @@ export const AddSignersFormPartial = ({ name: '', email: '', role: RecipientRole.SIGNER, + actionAuth: undefined, }, ], }, }); - const onFormSubmit = handleSubmit(onSubmit); + // Always show advanced settings if any recipient has auth options. + const alwaysShowAdvancedSettings = useMemo(() => { + const recipientHasAuthOptions = recipients.find((recipient) => { + const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); + + return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth; + }); + + const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth); + + return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined; + }, [recipients, form]); + + const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings); + + const { + formState: { errors, isSubmitting }, + control, + } = form; + + const onFormSubmit = form.handleSubmit(onSubmit); const { append: appendSigner, @@ -114,6 +141,7 @@ export const AddSignersFormPartial = ({ name: '', email: '', role: RecipientRole.SIGNER, + actionAuth: undefined, }); }; @@ -146,111 +174,201 @@ export const AddSignersFormPartial = ({ description={documentFlow.description} /> -
- {isDocumentPdfLoaded && - fields.map((field, index) => ( - - ))} + {isDocumentPdfLoaded && + fields.map((field, index) => ( + + ))} - - {signers.map((signer, index) => ( - -
- - - +
+
+ {signers.map((signer, index) => ( + + ( - + + {!showAdvancedSettings && index === 0 && ( + Email + )} + + + + + + + )} /> -
-
- - - ( - - )} - /> -
- -
- ( - + - -
- {ROLE_ICONS[RecipientRole.CC]} - Receives copy -
-
- - -
- {ROLE_ICONS[RecipientRole.APPROVER]} - Approver -
-
- - -
- {ROLE_ICONS[RecipientRole.VIEWER]} - Viewer -
-
- - + + + )} + /> + + {showAdvancedSettings && isDocumentEnterprise && ( + ( + + + + + + + + )} + /> + )} + + ( + + + + + + + )} /> -
-
+ + ))} +
+ + + +
+ + + {!alwaysShowAdvancedSettings && isDocumentEnterprise && ( +
+ setShowAdvancedSettings(Boolean(value))} + /> + +
- -
- - -
- - ))} - -
- - - -
- -
+ )} +
+ + @@ -297,7 +433,6 @@ export const AddSignersFormPartial = ({ /> { const { - control, register, handleSubmit, - formState: { errors, isSubmitting, touchedFields }, - setValue, + formState: { errors, isSubmitting }, } = useForm({ defaultValues: { meta: { subject: document.documentMeta?.subject ?? '', message: document.documentMeta?.message ?? '', - timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, - dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, - redirectUrl: document.documentMeta?.redirectUrl ?? '', }, }, resolver: zodResolver(ZAddSubjectFormSchema), @@ -83,20 +57,6 @@ export const AddSubjectFormPartial = ({ const onFormSubmit = handleSubmit(onSubmit); const { currentStep, totalSteps, previousStep } = useStep(); - const hasDateField = fields.find((field) => field.type === 'DATE'); - - const documentHasBeenSent = recipients.some( - (recipient) => recipient.sendStatus === SendStatus.SENT, - ); - - // We almost always want to set the timezone to the user's local timezone to avoid confusion - // when the document is signed. - useEffect(() => { - if (!touchedFields.meta?.timezone && !documentHasBeenSent) { - setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone); - } - }, [documentHasBeenSent, setValue, touchedFields.meta?.timezone]); - return ( <>
- - - - - Advanced Options - - - - {hasDateField && ( - <> -
- - - ( - - )} - /> -
- -
- - - ( - value && onChange(value)} - disabled={documentHasBeenSent} - /> - )} - /> -
- - )} - -
-
-
- - - - - -
-
-
-
-
-
diff --git a/packages/ui/primitives/document-flow/add-subject.types.ts b/packages/ui/primitives/document-flow/add-subject.types.ts index c9027c2a3..020e3c04b 100644 --- a/packages/ui/primitives/document-flow/add-subject.types.ts +++ b/packages/ui/primitives/document-flow/add-subject.types.ts @@ -1,21 +1,9 @@ import { z } from 'zod'; -import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; -import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; -import { URL_REGEX } from '@documenso/lib/constants/url-regex'; - export const ZAddSubjectFormSchema = z.object({ meta: z.object({ subject: z.string(), message: z.string(), - timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE), - dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT), - redirectUrl: z - .string() - .optional() - .refine((value) => value === undefined || value === '' || URL_REGEX.test(value), { - message: 'Please enter a valid URL', - }), }), }); diff --git a/packages/ui/primitives/document-flow/add-title.tsx b/packages/ui/primitives/document-flow/add-title.tsx deleted file mode 100644 index 5abe44003..000000000 --- a/packages/ui/primitives/document-flow/add-title.tsx +++ /dev/null @@ -1,106 +0,0 @@ -'use client'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm } from 'react-hook-form'; - -import type { Field, Recipient } from '@documenso/prisma/client'; -import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; - -import { FormErrorMessage } from '../form/form-error-message'; -import { Input } from '../input'; -import { Label } from '../label'; -import { useStep } from '../stepper'; -import type { TAddTitleFormSchema } from './add-title.types'; -import { ZAddTitleFormSchema } from './add-title.types'; -import { - DocumentFlowFormContainerActions, - DocumentFlowFormContainerContent, - DocumentFlowFormContainerFooter, - DocumentFlowFormContainerHeader, - DocumentFlowFormContainerStep, -} from './document-flow-root'; -import { ShowFieldItem } from './show-field-item'; -import type { DocumentFlowStep } from './types'; - -export type AddTitleFormProps = { - documentFlow: DocumentFlowStep; - recipients: Recipient[]; - fields: Field[]; - document: DocumentWithData; - onSubmit: (_data: TAddTitleFormSchema) => void; - isDocumentPdfLoaded: boolean; -}; - -export const AddTitleFormPartial = ({ - documentFlow, - recipients, - fields, - document, - onSubmit, - isDocumentPdfLoaded, -}: AddTitleFormProps) => { - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(ZAddTitleFormSchema), - defaultValues: { - title: document.title, - }, - }); - - const onFormSubmit = handleSubmit(onSubmit); - - const { stepIndex, currentStep, totalSteps, previousStep } = useStep(); - - return ( - <> - - - {isDocumentPdfLoaded && - fields.map((field, index) => ( - - ))} - -
-
-
- - - - - -
-
-
-
- - - - - void onFormSubmit()} - /> - - - ); -}; diff --git a/packages/ui/primitives/document-flow/add-title.types.ts b/packages/ui/primitives/document-flow/add-title.types.ts deleted file mode 100644 index b910c060a..000000000 --- a/packages/ui/primitives/document-flow/add-title.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod'; - -export const ZAddTitleFormSchema = z.object({ - title: z.string().trim().min(1, { message: "Title can't be empty" }), -}); - -export type TAddTitleFormSchema = z.infer; diff --git a/packages/ui/primitives/input.tsx b/packages/ui/primitives/input.tsx index 71b3cb521..f776c94c2 100644 --- a/packages/ui/primitives/input.tsx +++ b/packages/ui/primitives/input.tsx @@ -10,7 +10,7 @@ const Input = React.forwardRef(