From 35db8182f0f192537e5ca1c08860568eeba877e7 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Wed, 23 Apr 2025 08:26:52 +0000 Subject: [PATCH] feat: complete document 2fa (wip) --- .../document-signing-auth-dialog.tsx | 3 +- .../document-signing-auth-provider.tsx | 16 +++- .../document-signing-form.tsx | 32 ++++++-- .../_recipient+/sign.$token+/_index.tsx | 9 +++ .../server-only/document/update-document.ts | 26 +++---- .../field/sign-field-with-token.ts | 33 +++++--- packages/lib/types/document-auth.ts | 14 ++++ .../document-global-auth-action-select.tsx | 77 +++++++++++-------- .../primitives/document-flow/add-settings.tsx | 45 ++++++----- 9 files changed, 171 insertions(+), 84 deletions(-) diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx index de4cf3f68..f7d669f3f 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx @@ -27,7 +27,7 @@ export type DocumentSigningAuthDialogProps = { actionTarget: FieldType | 'DOCUMENT'; open: boolean; onOpenChange: (value: boolean) => void; - + isEnterprise: boolean; /** * The callback to run when the reauth form is filled out. */ @@ -41,6 +41,7 @@ export const DocumentSigningAuthDialog = ({ open, onOpenChange, onReauthFormSubmit, + isEnterprise, }: DocumentSigningAuthDialogProps) => { const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext(); diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx index 8f0af0d30..5f03af3a1 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx @@ -66,6 +66,7 @@ export interface DocumentSigningAuthProviderProps { recipient: Recipient; user?: SessionUser | null; children: React.ReactNode; + isEnterprise: boolean; } export const DocumentSigningAuthProvider = ({ @@ -73,6 +74,7 @@ export const DocumentSigningAuthProvider = ({ recipient: initialRecipient, user, children, + isEnterprise, }: DocumentSigningAuthProviderProps) => { const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions); const [recipient, setRecipient] = useState(initialRecipient); @@ -138,8 +140,13 @@ export const DocumentSigningAuthProvider = ({ .exhaustive(); const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { - // Directly run callback if no auth required. - if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) { + // Determine if authentication is required based on enterprise status and action target. + const requiresAuthTrigger = isEnterprise + ? derivedRecipientActionAuth && options.actionTarget === FieldType.SIGNATURE + : derivedRecipientActionAuth && options.actionTarget === 'DOCUMENT'; + + // Directly run callback if no auth trigger is needed. + if (!requiresAuthTrigger) { await options.onReauthFormSubmit(); return; } @@ -209,6 +216,7 @@ export const DocumentSigningAuthProvider = ({ onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit} actionTarget={documentAuthDialogPayload.actionTarget} documentAuthType={derivedRecipientActionAuth} + isEnterprise={isEnterprise} /> )} @@ -218,6 +226,8 @@ export const DocumentSigningAuthProvider = ({ type ExecuteActionAuthProcedureOptions = Omit< DocumentSigningAuthDialogProps, 'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' ->; +> & { + actionTarget: FieldType | 'DOCUMENT'; +}; DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider'; diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx index 967c5d725..4e8eb6a0a 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-form.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -28,6 +28,7 @@ import { AssistantConfirmationDialog, type NextSigner, } from '../../dialogs/assistant-confirmation-dialog'; +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; import { useRequiredDocumentSigningContext } from './document-signing-provider'; @@ -39,6 +40,7 @@ export type DocumentSigningFormProps = { isRecipientsTurn: boolean; allRecipients?: RecipientWithFields[]; setSelectedSignerId?: (id: number | null) => void; + isEnterprise: boolean; }; export const DocumentSigningForm = ({ @@ -49,6 +51,7 @@ export const DocumentSigningForm = ({ isRecipientsTurn, allRecipients = [], setSelectedSignerId, + isEnterprise, }: DocumentSigningFormProps) => { const { sessionData } = useOptionalSession(); const user = sessionData?.user; @@ -62,6 +65,7 @@ export const DocumentSigningForm = ({ const assistantSignersId = useId(); const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext(); + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false); @@ -114,11 +118,17 @@ export const DocumentSigningForm = ({ setIsAssistantSubmitting(true); try { - await completeDocument(undefined, nextSigner); + await executeActionAuthProcedure({ + actionTarget: 'DOCUMENT', + isEnterprise, + onReauthFormSubmit: async (authOptions) => { + await completeDocument(authOptions, nextSigner); + }, + }); } catch (err) { toast({ - title: 'Error', - description: 'An error occurred while completing the document. Please try again.', + title: _(msg`Error`), + description: _(msg`An error occurred while completing the document. Please try again.`), variant: 'destructive', }); @@ -229,7 +239,13 @@ export const DocumentSigningForm = ({ fields={fields} fieldsValidated={fieldsValidated} onSignatureComplete={async (nextSigner) => { - await completeDocument(undefined, nextSigner); + await executeActionAuthProcedure({ + actionTarget: 'DOCUMENT', + isEnterprise, + onReauthFormSubmit: async (authOptions) => { + await completeDocument(authOptions, nextSigner); + }, + }); }} role={recipient.role} allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner} @@ -409,7 +425,13 @@ export const DocumentSigningForm = ({ fieldsValidated={fieldsValidated} disabled={!isRecipientsTurn} onSignatureComplete={async (nextSigner) => { - await completeDocument(undefined, nextSigner); + await executeActionAuthProcedure({ + actionTarget: 'DOCUMENT', + isEnterprise, + onReauthFormSubmit: async (authOptions) => { + await completeDocument(authOptions, nextSigner); + }, + }); }} role={recipient.role} allowDictateNextSigner={ diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx index 33ecc05df..6dbee7f42 100644 --- a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx @@ -6,6 +6,7 @@ import { getOptionalLoaderContext } from 'server/utils/get-loader-session'; import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized'; @@ -60,6 +61,10 @@ export async function loader({ params, request }: Route.LoaderArgs) { throw new Response('Not Found', { status: 404 }); } + const isEnterprise = user?.id + ? await isUserEnterprise({ userId: user.id }).catch(() => false) + : false; + const recipientWithFields = { ...recipient, fields }; const isRecipientsTurn = await getIsRecipientsTurnToSign({ token }); @@ -115,6 +120,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { isDocumentAccessValid: false, recipientEmail: recipient.email, recipientHasAccount, + isEnterprise, } as const); } @@ -149,6 +155,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { completedFields, recipientSignature, isRecipientsTurn, + isEnterprise, } as const); } @@ -176,6 +183,7 @@ export default function SigningPage() { isRecipientsTurn, allRecipients, recipientWithFields, + isEnterprise, } = data; if (document.deletedAt || document.status === DocumentStatus.REJECTED) { @@ -241,6 +249,7 @@ export default function SigningPage() { documentAuthOptions={document.authOptions} recipient={recipient} user={user} + isEnterprise={isEnterprise} > ; export type TDocumentAccessAuthTypes = z.infer; export type TDocumentActionAuth = z.infer; export type TDocumentActionAuthTypes = z.infer; +export type TNonEnterpriseDocumentActionAuthTypes = z.infer< + typeof ZNonEnterpriseDocumentActionAuthTypesSchema +>; export type TRecipientAccessAuth = z.infer; export type TRecipientAccessAuthTypes = z.infer; export type TRecipientActionAuth = z.infer; diff --git a/packages/ui/components/document/document-global-auth-action-select.tsx b/packages/ui/components/document/document-global-auth-action-select.tsx index 956028339..76f2d5580 100644 --- a/packages/ui/components/document/document-global-auth-action-select.tsx +++ b/packages/ui/components/document/document-global-auth-action-select.tsx @@ -7,7 +7,11 @@ import type { SelectProps } from '@radix-ui/react-select'; import { InfoIcon } from 'lucide-react'; import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; -import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth'; +import { + DocumentActionAuth, + DocumentAuth, + NonEnterpriseDocumentActionAuth, +} from '@documenso/lib/types/document-auth'; import { Select, SelectContent, @@ -17,38 +21,51 @@ import { } from '@documenso/ui/primitives/select'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; -export const DocumentGlobalAuthActionSelect = forwardRef( - (props, ref) => { - const { _ } = useLingui(); +interface DocumentGlobalAuthActionSelectProps extends SelectProps { + isDocumentEnterprise?: boolean; +} - return ( - + + + - {Object.values(DocumentActionAuth) - .filter((auth) => auth !== DocumentAuth.ACCOUNT) - .map((authType) => ( - - {DOCUMENT_AUTH_TYPES[authType].value} - - ))} - - - ); - }, -); + + {/* Note: -1 is remapped in the Zod schema to the required value. */} + + No restrictions + + + {isDocumentEnterprise + ? Object.values(DocumentActionAuth) + .filter((auth) => auth !== DocumentAuth.ACCOUNT) + .map((authType) => ( + + {DOCUMENT_AUTH_TYPES[authType].value} + + )) + : Object.values(NonEnterpriseDocumentActionAuth) + .filter((auth) => auth !== DocumentAuth.EXPLICIT_NONE) + .map((authType) => ( + + {DOCUMENT_AUTH_TYPES[authType].value} + + ))} + + + ); +}); DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect'; diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index 2f1534b7e..aee78d248 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -1,10 +1,15 @@ import { useEffect } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans } from '@lingui/react/macro'; -import { useLingui } from '@lingui/react/macro'; -import { DocumentVisibility, TeamMemberRole } from '@prisma/client'; -import { DocumentStatus, type Field, type Recipient, SendStatus } from '@prisma/client'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { + DocumentStatus, + DocumentVisibility, + type Field, + type Recipient, + SendStatus, + TeamMemberRole, +} from '@prisma/client'; import { InfoIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; @@ -268,24 +273,22 @@ export const AddSettingsFormPartial = ({ /> )} - {isDocumentEnterprise && ( - ( - - - Recipient action authentication - - + ( + + + Recipient action authentication + + - - - - - )} - /> - )} + + + + + )} + />