diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index a8e0a0c63..3295e8ab9 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 5d9fe78aa..a0342b935 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -7,7 +7,6 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { type DocumentData, type DocumentMeta, - DocumentStatus, type Field, type Recipient, type User, @@ -18,12 +17,12 @@ 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'; @@ -43,8 +42,8 @@ export type EditDocumentFormProps = { documentRootPath: string; }; -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, @@ -62,7 +61,8 @@ export const EditDocumentForm = ({ const searchParams = useSearchParams(); const team = useOptionalCurrentTeam(); - const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation(); + const { mutateAsync: setSettingsForDocument } = + trpc.document.setSettingsForDocument.useMutation(); const { mutateAsync: addFields } = trpc.field.addFields.useMutation(); const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation(); const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation(); @@ -70,9 +70,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: { @@ -96,8 +96,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 && @@ -110,13 +109,23 @@ export const EditDocumentForm = ({ return initialStep; }); - const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => { + const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => { try { - // Custom invocation server action - 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(); @@ -127,7 +136,7 @@ export const EditDocumentForm = ({ toast({ title: 'Error', - description: 'An error occurred while updating title.', + description: 'An error occurred while updating the general settings.', variant: 'destructive', }); } @@ -139,7 +148,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(); @@ -177,7 +190,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({ @@ -186,9 +199,6 @@ export const EditDocumentForm = ({ meta: { subject, message, - dateFormat, - timezone, - redirectUrl, }, }); @@ -245,23 +255,23 @@ export const EditDocumentForm = ({ currentStep={currentDocumentFlow.stepIndex} setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])} > - + + 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 ce34a55fd..a8f9e1f4f 100644 --- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx @@ -11,6 +11,8 @@ import { convertToLocalSystemFormat, } from '@documenso/lib/constants/date-formats'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +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'; @@ -53,16 +55,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..a904eb062 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx @@ -0,0 +1,248 @@ +/** + * 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 { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { + DocumentAuth, + type TRecipientActionAuth, + type TRecipientActionAuthTypes, +} from '@documenso/lib/types/document-auth'; +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?: 'FIELD' | '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, + actionTarget = 'FIELD', + // 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]); + + const defaultRecipientActionVerb = RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb; + + return ( + + + + + {title || `${defaultRecipientActionVerb} ${actionTarget.toLowerCase()}`} + + + + {description || + `Reauthentication is required to ${defaultRecipientActionVerb.toLowerCase()} the ${actionTarget.toLowerCase()}`} + + + + {match(documentAuthType) + .with(DocumentAuth.ACCOUNT, () => ( +
+ + + To {defaultRecipientActionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, + 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..986cfc12e --- /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, Recipient, 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) { + await options.onReauthFormSubmit(); + return; + } + + // Run callback with precalculated auth options if avaliable. + 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 4d52ca50a..b83aea355 100644 --- a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx @@ -6,6 +6,8 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; +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'; @@ -38,17 +40,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..e0c477754 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'; @@ -19,6 +20,7 @@ import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredSigningContext } from './provider'; import { SignDialog } from './sign-dialog'; @@ -35,6 +37,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin const { data: session } = useSession(); const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext(); + const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); @@ -64,9 +67,17 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin return; } + 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 44de2fc36..554460acb 100644 --- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx @@ -6,6 +6,8 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; +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'; @@ -15,6 +17,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'; @@ -31,6 +34,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 } = @@ -46,9 +51,32 @@ 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), + }); + }; + + const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => { try { - if (!providedFullName && !localFullName) { + const value = name || providedFullName; + + if (!value) { setShowFullNameModal(true); return; } @@ -56,18 +84,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({ @@ -98,7 +127,13 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { }; return ( - + {isLoading && (
@@ -147,10 +182,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..cb1f5b52c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -12,6 +12,8 @@ import { import { truncateTitle } from '~/helpers/truncate-title'; +import { useRequiredDocumentAuthContext } from './document-auth-provider'; + export type SignDialogProps = { isSubmitting: boolean; document: Document; @@ -29,12 +31,33 @@ export const SignDialog = ({ onSignatureComplete, role, }: SignDialogProps) => { + const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext(); + const [showDialog, setShowDialog] = useState(false); const truncatedTitle = truncateTitle(document.title); const isComplete = fields.every((field) => field.inserted); + const handleOpenChange = async (open: boolean) => { + if (isSubmitting || !isComplete) { + return; + } + + 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..78a591505 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,37 @@ import React from 'react'; +import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; 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 avaliable. + */ + onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise | void; onRemove?: () => Promise | void; type?: 'Date' | 'Email' | 'Name' | 'Signature'; tooltipText?: string | null; @@ -19,18 +41,42 @@ 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?.(); + if (isAuthRedirectRequired) { + await executeActionAuthProcedure({ + onReauthFormSubmit: () => { + // Do nothing since the user should be redirected. + }, + }); + + return; + } + + // Handle any presign requirements, and halt if required. + if (onPreSign) { + const preSignResult = await onPreSign(); + + if (preSignResult === false) { + return; + } + } + + await executeActionAuthProcedure({ + onReauthFormSubmit: onSign, + }); }; const onRemoveSignedFieldClick = async () => { @@ -47,7 +93,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/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..36b74408b 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -7,13 +7,19 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; +import { AppError, AppErrorCode } from '../../errors/app-error'; +import type { TRecipientActionAuth } from '../../types/document-auth'; +import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; +import { isRecipientAuthorized } from './is-recipient-authorized'; import { sealDocument } from './seal-document'; import { sendPendingEmail } from './send-pending-email'; export type CompleteDocumentWithTokenOptions = { token: string; documentId: number; + userId?: number; + authOptions?: TRecipientActionAuth; requestMetadata?: RequestMetadata; }; @@ -40,6 +46,8 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio export const completeDocumentWithToken = async ({ token, documentId, + userId, + authOptions, requestMetadata, }: CompleteDocumentWithTokenOptions) => { 'use server'; @@ -71,32 +79,52 @@ 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(), - }, + const { derivedRecipientActionAuth } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, }); - 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 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 d242e72fd..b8aa1c465 100644 --- a/packages/lib/server-only/document/get-document-by-token.ts +++ b/packages/lib/server-only/document/get-document-by-token.ts @@ -1,16 +1,43 @@ 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 DocumentAndSender = Awaited>; + export const getDocumentAndSenderByToken = async ({ token, + userId, + accessAuth, + requireAccessAuth = true, }: GetDocumentAndSenderByTokenOptions) => { if (!token) { throw new Error('Missing token'); @@ -28,12 +55,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, @@ -45,6 +100,9 @@ export const getDocumentAndSenderByToken = async ({ */ export const getDocumentAndRecipientByToken = async ({ token, + userId, + accessAuth, + requireAccessAuth = true, }: GetDocumentAndRecipientByTokenOptions): Promise => { if (!token) { throw new Error('Missing token'); @@ -68,6 +126,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..bf595fa9b --- /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 getRecipient = 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 getRecipient(recipient.email); + + if (!recipientUser) { + return false; + } + + return recipientUser.id === userId; + }) + .exhaustive(); +}; 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..ab5943814 --- /dev/null +++ b/packages/lib/server-only/document/update-document-settings.ts @@ -0,0 +1,162 @@ +'use server'; + +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; + + 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..198ea8752 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,23 @@ export const signFieldWithToken = async ({ throw new Error(`Field ${fieldId} has no recipientId`); } + 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'); + } + const documentMeta = await prisma.documentMeta.findFirst({ where: { documentId: document.id, @@ -158,9 +183,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 2505e5261..5b4540e2d 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -1,10 +1,15 @@ 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 { RecipientRole } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client'; @@ -18,6 +23,7 @@ export interface SetRecipientsForDocumentOptions { email: string; name: string; role: RecipientRole; + actionAuth?: TRecipientActionAuthTypes | null; }[]; requestMetadata?: RequestMetadata; } @@ -111,6 +117,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, @@ -124,6 +139,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, @@ -134,6 +150,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, }, }); @@ -187,7 +204,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/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 b1bf9f985..308967bbf 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -226,6 +226,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[] @@ -323,6 +324,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/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 26b547ac9..2e5e3cd0e 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -12,6 +12,7 @@ import { getDocumentAndSenderByToken } 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'; @@ -27,6 +28,7 @@ import { ZSearchDocumentsMutationSchema, ZSendDocumentMutationSchema, ZSetPasswordForDocumentMutationSchema, + ZSetSettingsForDocumentMutationSchema, ZSetTitleForDocumentMutationSchema, } from './schema'; @@ -49,22 +51,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.', + }); + } + }), createDocument: authenticatedProcedure .input(ZCreateDocumentMutationSchema) @@ -150,6 +155,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 34ddf1a5c..ddc27197b 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'; @@ -37,6 +41,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(), @@ -88,8 +116,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..5478eaa50 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; + key?: string; }; -export const AnimateGenericFadeInOut = ({ children, className }: AnimateGenericFadeInOutProps) => { +export const AnimateGenericFadeInOut = ({ + children, + className, + key, +}: 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, + 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 ( + <> + + + + {fields.map((field, index) => ( + + ))} + +
+
+ ( + + Title + + + + + + + )} + /> + + ( + + + Document access + + + + + + +

The authentication requirement for recipients to view the document.

+ +
    +
  • + Require account - The recipient must have an account, + and be signed in to view the document +
  • +
  • + None - The document can be accessed directly by the URL + sent to the recipient +
  • +
+
+
+
+ + + + +
+ )} + /> + + ( + + + Recipient signing authentication + + + + + + +

The authentication requirement for recipients to sign fields.

+ +

+ You can also override this global setting by setting the authentication + requirements directly on each recipient in the next step. +

+ +
    +
  • + Require account - The recipient must have an account, + and be signed in to sign fields +
  • +
  • + None - The recipient does not need any authentication + to sign fields +
  • +
+
+
+
+ + + + +
+ )} + /> + + + + + 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 b13e220f3..be5c6d429 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,14 +45,12 @@ export type AddSignersFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; fields: Field[]; - document: DocumentWithData; onSubmit: (_data: TAddSignersFormSchema) => void; }; export const AddSignersFormPartial = ({ documentFlow, recipients, - document, fields, onSubmit, }: AddSignersFormProps) => { @@ -55,11 +61,7 @@ export const AddSignersFormPartial = ({ const { currentStep, totalSteps, previousStep } = useStep(); - const { - control, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ resolver: zodResolver(ZAddSignersFormSchema), defaultValues: { signers: @@ -70,6 +72,8 @@ export const AddSignersFormPartial = ({ name: recipient.name, email: recipient.email, role: recipient.role, + actionAuth: + ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, })) : [ { @@ -77,12 +81,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, @@ -112,6 +137,7 @@ export const AddSignersFormPartial = ({ name: '', email: '', role: RecipientRole.SIGNER, + actionAuth: undefined, }); }; @@ -144,105 +170,190 @@ export const AddSignersFormPartial = ({ description={documentFlow.description} /> -
- {fields.map((field, index) => ( - - ))} + {fields.map((field, index) => ( + + ))} - - {signers.map((signer, index) => ( - -
- - - +
+
+ {signers.map((signer, index) => ( + + ( - + + {!showAdvancedSettings && index === 0 && ( + Email + )} + + + + + + + )} /> -
-
- - - ( - + + {!showAdvancedSettings && index === 0 && ( + Name + )} + + + + + + + )} /> -
-
- ( + + + + + + + + )} + /> + )} + + ( - + + {/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */} + {ROLE_ICONS[field.value as RecipientRole]} + - - -
- {ROLE_ICONS[RecipientRole.SIGNER]} - Signer -
-
+ + +
+ {ROLE_ICONS[RecipientRole.SIGNER]} + Signer +
+
- -
- {ROLE_ICONS[RecipientRole.CC]} - Receives copy -
-
+ +
+ {ROLE_ICONS[RecipientRole.CC]} + Receives copy +
+
- -
- {ROLE_ICONS[RecipientRole.APPROVER]} - Approver -
-
+ +
+ {ROLE_ICONS[RecipientRole.APPROVER]} + Approver +
+
- -
- {ROLE_ICONS[RecipientRole.VIEWER]} - Viewer -
-
-
- + +
+ {ROLE_ICONS[RecipientRole.VIEWER]} + Viewer +
+
+
+ + + + + )} /> -
-
+ + ))} +
+ + + +
+ + + {!alwaysShowAdvancedSettings && ( +
+ setShowAdvancedSettings(Boolean(value))} + /> + +
- -
- - -
- - ))} - -
- - - -
- -
+ )} +
+ + @@ -289,7 +418,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), @@ -81,20 +55,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 a6390fd3a..000000000 --- a/packages/ui/primitives/document-flow/add-title.tsx +++ /dev/null @@ -1,103 +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; -}; - -export const AddTitleFormPartial = ({ - documentFlow, - recipients, - fields, - document, - onSubmit, -}: 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 ( - <> - - - {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(