From 55c863262059879c62963a55bd08717555134201 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Sat, 7 Jun 2025 00:27:19 +1000 Subject: [PATCH] feat: password reauthentication for documents and recipients (#1827) Adds password reauthentication to our existing reauth providers, additionally swaps from an exclusive provider to an inclusive type where multiple methods can be selected to offer a this or that experience. --- .../direct-template-configure-form.tsx | 2 +- .../document-signing-auth-dialog.tsx | 159 ++++++++++++--- .../document-signing-auth-password.tsx | 148 ++++++++++++++ .../document-signing-auth-provider.tsx | 43 +++-- .../document-signing-auto-sign.tsx | 11 +- .../document-signing-complete-dialog.tsx | 2 - .../general/document/document-edit-form.tsx | 6 +- .../document/document-history-sheet.tsx | 18 +- .../general/template/template-edit-form.tsx | 4 +- .../_internal+/[__htmltopdf]+/certificate.tsx | 21 +- .../routes/_recipient+/d.$token+/_index.tsx | 4 +- .../app/routes/embed+/_v0+/direct.$url.tsx | 6 +- .../app/routes/embed+/_v0+/sign.$url.tsx | 6 +- .../routes/embed+/v1+/multisign+/_index.tsx | 2 - packages/api/v1/implementation.ts | 4 +- packages/api/v1/schema.ts | 14 +- .../e2e/document-auth/access-auth.spec.ts | 4 +- .../e2e/document-auth/action-auth.spec.ts | 44 ++--- .../e2e/document-flow/settings-step.spec.ts | 6 +- .../template-settings-step.spec.ts | 6 +- .../template-signers-step.spec.ts | 4 +- .../create-document-from-template.spec.ts | 48 ++--- .../e2e/templates/direct-templates.spec.ts | 4 +- packages/lib/constants/document-auth.ts | 4 + .../lib/server-only/2fa/verify-password.ts | 20 ++ .../document/complete-document-with-token.ts | 7 + .../document/create-document-v2.ts | 18 +- .../get-document-certificate-audit-logs.ts | 4 + .../document/is-recipient-authorized.ts | 23 ++- .../server-only/document/update-document.ts | 7 +- .../document/validate-field-auth.ts | 10 +- .../server-only/document/viewed-document.ts | 4 +- .../create-embedding-presign-token.ts | 1 - .../recipient/create-document-recipients.ts | 16 +- .../recipient/create-template-recipients.ts | 12 +- .../recipient/set-document-recipients.ts | 21 +- .../recipient/set-template-recipients.ts | 6 +- .../recipient/update-document-recipients.ts | 13 +- .../server-only/recipient/update-recipient.ts | 6 +- .../recipient/update-template-recipients.ts | 8 +- .../create-document-from-direct-template.ts | 9 +- .../server-only/template/update-template.ts | 6 +- packages/lib/types/document-audit-logs.ts | 40 +++- packages/lib/types/document-auth.ts | 75 ++++++-- packages/lib/utils/document-audit-logs.ts | 5 +- packages/lib/utils/document-auth.ts | 26 +-- .../trpc/server/document-router/schema.ts | 4 +- .../document-router/update-document.types.ts | 4 +- .../trpc/server/recipient-router/schema.ts | 12 +- .../trpc/server/template-router/schema.ts | 4 +- .../document-global-auth-access-select.tsx | 100 ++++++---- .../document-global-auth-action-select.tsx | 118 +++++++----- .../recipient-action-auth-select.tsx | 181 ++++++++++-------- .../primitives/document-flow/add-settings.tsx | 34 +++- .../document-flow/add-settings.types.ts | 19 +- .../primitives/document-flow/add-signers.tsx | 14 +- .../document-flow/add-signers.types.ts | 6 +- packages/ui/primitives/multiselect.tsx | 4 + .../add-template-placeholder-recipients.tsx | 12 +- ...d-template-placeholder-recipients.types.ts | 6 +- .../template-flow/add-template-settings.tsx | 16 +- .../add-template-settings.types.tsx | 10 +- 62 files changed, 985 insertions(+), 466 deletions(-) create mode 100644 apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx create mode 100644 packages/lib/server-only/2fa/verify-password.ts diff --git a/apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx index 39272b3b1..47e983003 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx @@ -130,7 +130,7 @@ export const DirectTemplateConfigureForm = ({ {...field} disabled={ field.disabled || - derivedRecipientAccessAuth !== null || + derivedRecipientAccessAuth.length > 0 || user?.email !== undefined } placeholder="recipient@documenso.com" 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..706daf686 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 @@ -1,5 +1,8 @@ +import { useState } from 'react'; + import { Trans } from '@lingui/react/macro'; import type { FieldType } from '@prisma/client'; +import { ChevronLeftIcon } from 'lucide-react'; import { P, match } from 'ts-pattern'; import { @@ -7,6 +10,7 @@ import { type TRecipientActionAuth, type TRecipientActionAuthTypes, } from '@documenso/lib/types/document-auth'; +import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, @@ -18,11 +22,12 @@ import { import { DocumentSigningAuth2FA } from './document-signing-auth-2fa'; import { DocumentSigningAuthAccount } from './document-signing-auth-account'; import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey'; +import { DocumentSigningAuthPassword } from './document-signing-auth-password'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; export type DocumentSigningAuthDialogProps = { title?: string; - documentAuthType: TRecipientActionAuthTypes; + availableAuthTypes: TRecipientActionAuthTypes[]; description?: string; actionTarget: FieldType | 'DOCUMENT'; open: boolean; @@ -37,54 +42,158 @@ export type DocumentSigningAuthDialogProps = { export const DocumentSigningAuthDialog = ({ title, description, - documentAuthType, + availableAuthTypes, open, onOpenChange, onReauthFormSubmit, }: DocumentSigningAuthDialogProps) => { const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext(); + // Filter out EXPLICIT_NONE from available auth types for the chooser + const validAuthTypes = availableAuthTypes.filter( + (authType) => authType !== DocumentAuth.EXPLICIT_NONE, + ); + + const [selectedAuthType, setSelectedAuthType] = useState(() => { + // Auto-select if there's only one valid option + if (validAuthTypes.length === 1) { + return validAuthTypes[0]; + } + // Return null if multiple options - show chooser + return null; + }); + const handleOnOpenChange = (value: boolean) => { if (isCurrentlyAuthenticating) { return; } + // Reset selected auth type when dialog closes + if (!value) { + setSelectedAuthType(() => { + if (validAuthTypes.length === 1) { + return validAuthTypes[0]; + } + + return null; + }); + } + onOpenChange(value); }; + const handleBackToChooser = () => { + setSelectedAuthType(null); + }; + + // If no valid auth types available, don't render anything + if (validAuthTypes.length === 0) { + return null; + } + return ( - {title || Sign field} + + {selectedAuthType && validAuthTypes.length > 1 && ( +
+ + {title || Sign field} +
+ )} + {(!selectedAuthType || validAuthTypes.length === 1) && + (title || Sign field)} +
{description || Reauthentication is required to sign this field}
- {match({ documentAuthType, user }) - .with( - { documentAuthType: DocumentAuth.ACCOUNT }, - { user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in. - () => , - ) - .with({ documentAuthType: DocumentAuth.PASSKEY }, () => ( - - )) - .with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => ( - - )) - .with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null) - .exhaustive()} + {/* Show chooser if no auth type is selected and there are multiple options */} + {!selectedAuthType && validAuthTypes.length > 1 && ( +
+

+ Choose your preferred authentication method: +

+
+ {validAuthTypes.map((authType) => ( + + ))} +
+
+ )} + + {/* Show the selected auth component */} + {selectedAuthType && + match({ documentAuthType: selectedAuthType, user }) + .with( + { documentAuthType: DocumentAuth.ACCOUNT }, + { user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in. + () => , + ) + .with({ documentAuthType: DocumentAuth.PASSKEY }, () => ( + + )) + .with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => ( + + )) + .with({ documentAuthType: DocumentAuth.PASSWORD }, () => ( + + )) + .with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null) + .exhaustive()}
); diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx new file mode 100644 index 000000000..6446a1e59 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans } from '@lingui/react/macro'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DialogFooter } from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; + +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; + +export type DocumentSigningAuthPasswordProps = { + actionTarget?: 'FIELD' | 'DOCUMENT'; + actionVerb?: string; + open: boolean; + onOpenChange: (value: boolean) => void; + onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; +}; + +const ZPasswordAuthFormSchema = z.object({ + password: z + .string() + .min(1, { message: 'Password is required' }) + .max(72, { message: 'Password must be at most 72 characters long' }), +}); + +type TPasswordAuthFormSchema = z.infer; + +export const DocumentSigningAuthPassword = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onReauthFormSubmit, + open, + onOpenChange, +}: DocumentSigningAuthPasswordProps) => { + const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = + useRequiredDocumentSigningAuthContext(); + + const form = useForm({ + resolver: zodResolver(ZPasswordAuthFormSchema), + defaultValues: { + password: '', + }, + }); + + const [formErrorCode, setFormErrorCode] = useState(null); + + const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => { + try { + setIsCurrentlyAuthenticating(true); + + await onReauthFormSubmit({ + type: DocumentAuth.PASSWORD, + password, + }); + + setIsCurrentlyAuthenticating(false); + + onOpenChange(false); + } catch (err) { + setIsCurrentlyAuthenticating(false); + + const error = AppError.parseError(err); + setFormErrorCode(error.code); + + // Todo: Alert. + } + }; + + useEffect(() => { + form.reset({ + password: '', + }); + + setFormErrorCode(null); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + return ( +
+ +
+
+ {formErrorCode && ( + + + Unauthorized + + + + We were unable to verify your details. Please try again or contact support + + + + )} + + ( + + + Password + + + + + + + + + )} + /> + + + + + + +
+
+
+ + ); +}; 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..42e5ffd5b 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 @@ -1,7 +1,6 @@ import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { type Document, FieldType, type Passkey, type Recipient } from '@prisma/client'; -import { match } from 'ts-pattern'; import type { SessionUser } from '@documenso/auth/server/lib/session/session'; import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth'; @@ -33,8 +32,8 @@ export type DocumentSigningAuthContextValue = { recipient: Recipient; recipientAuthOption: TRecipientAuthOptions; setRecipient: (_value: Recipient) => void; - derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null; - derivedRecipientActionAuth: TRecipientActionAuthTypes | null; + derivedRecipientAccessAuth: TRecipientAccessAuthTypes[]; + derivedRecipientActionAuth: TRecipientActionAuthTypes[]; isAuthRedirectRequired: boolean; isCurrentlyAuthenticating: boolean; setIsCurrentlyAuthenticating: (_value: boolean) => void; @@ -100,7 +99,7 @@ export const DocumentSigningAuthProvider = ({ }, { placeholderData: (previousData) => previousData, - enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY, + enabled: derivedRecipientActionAuth?.includes(DocumentAuth.PASSKEY) ?? false, }, ); @@ -121,21 +120,28 @@ export const DocumentSigningAuthProvider = ({ * 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; - } + const preCalculatedActionAuthOptions = useMemo(() => { + if ( + !derivedRecipientActionAuth || + derivedRecipientActionAuth.length === 0 || + derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) + ) { + return { + type: DocumentAuth.EXPLICIT_NONE, + }; + } + if ( + derivedRecipientActionAuth.includes(DocumentAuth.ACCOUNT) && + user?.email == recipient.email + ) { return { type: DocumentAuth.ACCOUNT, }; - }) - .with(DocumentAuth.EXPLICIT_NONE, () => ({ - type: DocumentAuth.EXPLICIT_NONE, - })) - .with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null) - .exhaustive(); + } + + return null; + }, [derivedRecipientActionAuth, user, recipient]); const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { // Directly run callback if no auth required. @@ -170,7 +176,8 @@ export const DocumentSigningAuthProvider = ({ // Assume that a user must be logged in for any auth requirements. const isAuthRedirectRequired = Boolean( derivedRecipientActionAuth && - derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE && + derivedRecipientActionAuth.length > 0 && + !derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) && user?.email !== recipient.email, ); @@ -208,7 +215,7 @@ export const DocumentSigningAuthProvider = ({ onOpenChange={() => setDocumentAuthDialogPayload(null)} onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit} actionTarget={documentAuthDialogPayload.actionTarget} - documentAuthType={derivedRecipientActionAuth} + availableAuthTypes={derivedRecipientActionAuth} /> )} @@ -217,7 +224,7 @@ export const DocumentSigningAuthProvider = ({ type ExecuteActionAuthProcedureOptions = Omit< DocumentSigningAuthDialogProps, - 'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' + 'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' | 'availableAuthTypes' >; DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider'; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx b/apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx index cb90525a7..4a900a230 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx @@ -44,6 +44,7 @@ const AUTO_SIGNABLE_FIELD_TYPES: string[] = [ // other field types. const NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES: string[] = [ DocumentAuth.PASSKEY, + DocumentAuth.PASSWORD, DocumentAuth.TWO_FACTOR_AUTH, ]; @@ -96,8 +97,8 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu return true; }); - const actionAuthAllowsAutoSign = !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes( - derivedRecipientActionAuth ?? '', + const actionAuthAllowsAutoSign = derivedRecipientActionAuth.every( + (actionAuth) => !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(actionAuth), ); const onSubmit = async () => { @@ -110,16 +111,16 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu .with(FieldType.DATE, () => new Date().toISOString()) .otherwise(() => ''); - const authOptions = match(derivedRecipientActionAuth) + const authOptions = match(derivedRecipientActionAuth.at(0)) .with(DocumentAuth.ACCOUNT, () => ({ type: DocumentAuth.ACCOUNT, })) .with(DocumentAuth.EXPLICIT_NONE, () => ({ type: DocumentAuth.EXPLICIT_NONE, })) - .with(null, () => undefined) + .with(undefined, () => undefined) .with( - P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH), + P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD), // This is a bit dirty, but the sentinel value used here is incredibly short-lived. () => 'NOT_SUPPORTED' as const, ) diff --git a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx index a14f9719c..65503b965 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -92,8 +92,6 @@ export const DocumentSigningCompleteDialog = ({ }; const onFormSubmit = async (data: TNextSignerFormSchema) => { - console.log('data', data); - console.log('form.formState.errors', form.formState.errors); try { if (allowDictateNextSigner && data.name && data.email) { await onSignatureComplete({ name: data.name, email: data.email }); diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index e2b03f053..7a0d0c875 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -183,8 +183,8 @@ export const DocumentEditForm = ({ title: data.title, externalId: data.externalId || null, visibility: data.visibility, - globalAccessAuth: data.globalAccessAuth ?? null, - globalActionAuth: data.globalActionAuth ?? null, + globalAccessAuth: data.globalAccessAuth ?? [], + globalActionAuth: data.globalActionAuth ?? [], }, meta: { timezone, @@ -229,7 +229,7 @@ export const DocumentEditForm = ({ recipients: data.signers.map((signer) => ({ ...signer, // Explicitly set to null to indicate we want to remove auth if required. - actionAuth: signer.actionAuth || null, + actionAuth: signer.actionAuth ?? [], })), }), ]); diff --git a/apps/remix/app/components/general/document/document-history-sheet.tsx b/apps/remix/app/components/general/document/document-history-sheet.tsx index ef73c1c8f..f7c70bc84 100644 --- a/apps/remix/app/components/general/document/document-history-sheet.tsx +++ b/apps/remix/app/components/general/document/document-history-sheet.tsx @@ -81,11 +81,15 @@ export const DocumentHistorySheet = ({ * @param text The text to format * @returns The formatted text */ - const formatGenericText = (text?: string | null) => { + const formatGenericText = (text?: string | string[] | null): string => { if (!text) { return ''; } + if (Array.isArray(text)) { + return text.map((t) => formatGenericText(t)).join(', '); + } + return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' '); }; @@ -245,11 +249,19 @@ export const DocumentHistorySheet = ({ values={[ { key: 'Old', - value: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None', + value: Array.isArray(data.from) + ? data.from + .map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None') + .join(', ') + : DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None', }, { key: 'New', - value: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None', + value: Array.isArray(data.to) + ? data.to + .map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None') + .join(', ') + : DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None', }, ]} /> diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx index 98449958f..fd120fc87 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -134,8 +134,8 @@ export const TemplateEditForm = ({ title: data.title, externalId: data.externalId || null, visibility: data.visibility, - globalAccessAuth: data.globalAccessAuth ?? null, - globalActionAuth: data.globalActionAuth ?? null, + globalAccessAuth: data.globalAccessAuth ?? [], + globalActionAuth: data.globalActionAuth ?? [], }, meta: { ...data.meta, diff --git a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx index a8b58bd78..2c9bbd80b 100644 --- a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx +++ b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx @@ -3,6 +3,7 @@ import { useLingui } from '@lingui/react'; import { FieldType, SigningStatus } from '@prisma/client'; import { DateTime } from 'luxon'; import { redirect } from 'react-router'; +import { prop, sortBy } from 'remeda'; import { match } from 'ts-pattern'; import { UAParser } from 'ua-parser-js'; import { renderSVG } from 'uqr'; @@ -133,18 +134,30 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps) recipientAuth: recipient.authOptions, }); - let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth) + const insertedAuditLogsWithFieldAuth = sortBy( + auditLogs.DOCUMENT_FIELD_INSERTED.filter( + (log) => log.data.recipientId === recipient.id && log.data.fieldSecurity, + ), + [prop('createdAt'), 'desc'], + ); + + const actionAuthMethod = insertedAuditLogsWithFieldAuth.at(0)?.data?.fieldSecurity?.type; + + let authLevel = match(actionAuthMethod) .with('ACCOUNT', () => _(msg`Account Re-Authentication`)) .with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`)) + .with('PASSWORD', () => _(msg`Password Re-Authentication`)) .with('PASSKEY', () => _(msg`Passkey Re-Authentication`)) .with('EXPLICIT_NONE', () => _(msg`Email`)) - .with(null, () => null) + .with(undefined, () => null) .exhaustive(); if (!authLevel) { - authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth) + const accessAuthMethod = extractedAuthMethods.derivedRecipientAccessAuth.at(0); + + authLevel = match(accessAuthMethod) .with('ACCOUNT', () => _(msg`Account Authentication`)) - .with(null, () => _(msg`Email`)) + .with(undefined, () => _(msg`Email`)) .exhaustive(); } diff --git a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx index 21bfab597..ee37e5d0b 100644 --- a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx @@ -47,9 +47,9 @@ export async function loader({ params, request }: Route.LoaderArgs) { }); // Ensure typesafety when we add more options. - const isAccessAuthValid = match(derivedRecipientAccessAuth) + const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0)) .with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user)) - .with(null, () => true) + .with(undefined, () => true) .exhaustive(); if (!isAccessAuthValid) { diff --git a/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx b/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx index d8290f852..45df8a8a8 100644 --- a/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx +++ b/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx @@ -68,9 +68,9 @@ export async function loader({ params, request }: Route.LoaderArgs) { }), ]); - const isAccessAuthValid = match(derivedRecipientAccessAuth) - .with(DocumentAccessAuth.ACCOUNT, () => user !== null) - .with(null, () => true) + const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0)) + .with(DocumentAccessAuth.ACCOUNT, () => !!user) + .with(undefined, () => true) .exhaustive(); if (!isAccessAuthValid) { diff --git a/apps/remix/app/routes/embed+/_v0+/sign.$url.tsx b/apps/remix/app/routes/embed+/_v0+/sign.$url.tsx index 7c16e3beb..5f51c1686 100644 --- a/apps/remix/app/routes/embed+/_v0+/sign.$url.tsx +++ b/apps/remix/app/routes/embed+/_v0+/sign.$url.tsx @@ -81,9 +81,9 @@ export async function loader({ params, request }: Route.LoaderArgs) { documentAuth: document.authOptions, }); - const isAccessAuthValid = match(derivedRecipientAccessAuth) - .with(DocumentAccessAuth.ACCOUNT, () => user !== null) - .with(null, () => true) + const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0)) + .with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email) + .with(undefined, () => true) .exhaustive(); if (!isAccessAuthValid) { diff --git a/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx b/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx index 16ea5448d..ac2a9c7e1 100644 --- a/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx +++ b/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx @@ -38,8 +38,6 @@ export async function loader({ request }: Route.LoaderArgs) { const recipient = await getRecipientByToken({ token }); - console.log('document', document.id); - return { document, recipient }; }), ); diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 28c33b3a7..63058a043 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -821,7 +821,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { name, role, signingOrder, - actionAuth: authOptions?.actionAuth ?? null, + actionAuth: authOptions?.actionAuth ?? [], }, ], requestMetadata: metadata, @@ -888,7 +888,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { name, role, signingOrder, - actionAuth: authOptions?.actionAuth, + actionAuth: authOptions?.actionAuth ?? [], requestMetadata: metadata.requestMetadata, }).catch(() => null); diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index be8675808..0b96c55ef 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -177,8 +177,8 @@ export const ZCreateDocumentMutationSchema = z.object({ .default({}), authOptions: z .object({ - globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(), - globalActionAuth: ZDocumentActionAuthTypesSchema.optional(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]), }) .optional() .openapi({ @@ -237,8 +237,8 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({ .optional(), authOptions: z .object({ - globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(), - globalActionAuth: ZDocumentActionAuthTypesSchema.optional(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]), }) .optional(), formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), @@ -310,8 +310,8 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({ .optional(), authOptions: z .object({ - globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(), - globalActionAuth: ZDocumentActionAuthTypesSchema.optional(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]), }) .optional(), formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), @@ -350,7 +350,7 @@ export const ZCreateRecipientMutationSchema = z.object({ signingOrder: z.number().nullish(), authOptions: z .object({ - actionAuth: ZRecipientActionAuthTypesSchema.optional(), + actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }) .optional() .openapi({ diff --git a/packages/app-tests/e2e/document-auth/access-auth.spec.ts b/packages/app-tests/e2e/document-auth/access-auth.spec.ts index a84eacf58..8c96a9c4b 100644 --- a/packages/app-tests/e2e/document-auth/access-auth.spec.ts +++ b/packages/app-tests/e2e/document-auth/access-auth.spec.ts @@ -42,8 +42,8 @@ test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page { createDocumentOptions: { authOptions: createDocumentAuthOptions({ - globalAccessAuth: 'ACCOUNT', - globalActionAuth: null, + globalAccessAuth: ['ACCOUNT'], + globalActionAuth: [], }), }, }, diff --git a/packages/app-tests/e2e/document-auth/action-auth.spec.ts b/packages/app-tests/e2e/document-auth/action-auth.spec.ts index 28a4b1087..c93c3f88e 100644 --- a/packages/app-tests/e2e/document-auth/action-auth.spec.ts +++ b/packages/app-tests/e2e/document-auth/action-auth.spec.ts @@ -65,8 +65,8 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa recipients: [recipientWithAccount], updateDocumentOptions: { authOptions: createDocumentAuthOptions({ - globalAccessAuth: null, - globalActionAuth: 'ACCOUNT', + globalAccessAuth: [], + globalActionAuth: ['ACCOUNT'], }), }, }); @@ -116,8 +116,8 @@ test.skip('[DOCUMENT_AUTH]: should deny signing document when required for globa recipients: [recipientWithAccount], updateDocumentOptions: { authOptions: createDocumentAuthOptions({ - globalAccessAuth: null, - globalActionAuth: 'ACCOUNT', + globalAccessAuth: [], + globalActionAuth: ['ACCOUNT'], }), }, }); @@ -147,8 +147,8 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth' recipients: [recipientWithAccount, seedTestEmail()], updateDocumentOptions: { authOptions: createDocumentAuthOptions({ - globalAccessAuth: null, - globalActionAuth: 'ACCOUNT', + globalAccessAuth: [], + globalActionAuth: ['ACCOUNT'], }), }, }); @@ -193,20 +193,20 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au recipientsCreateOptions: [ { authOptions: createRecipientAuthOptions({ - accessAuth: null, - actionAuth: null, + accessAuth: [], + actionAuth: [], }), }, { authOptions: createRecipientAuthOptions({ - accessAuth: null, - actionAuth: 'EXPLICIT_NONE', + accessAuth: [], + actionAuth: ['EXPLICIT_NONE'], }), }, { authOptions: createRecipientAuthOptions({ - accessAuth: null, - actionAuth: 'ACCOUNT', + accessAuth: [], + actionAuth: ['ACCOUNT'], }), }, ], @@ -218,7 +218,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); // This document has no global action auth, so only account should require auth. - const isAuthRequired = actionAuth === 'ACCOUNT'; + const isAuthRequired = actionAuth.includes('ACCOUNT'); const signUrl = `/sign/${token}`; @@ -292,28 +292,28 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an recipientsCreateOptions: [ { authOptions: createRecipientAuthOptions({ - accessAuth: null, - actionAuth: null, + accessAuth: [], + actionAuth: [], }), }, { authOptions: createRecipientAuthOptions({ - accessAuth: null, - actionAuth: 'EXPLICIT_NONE', + accessAuth: [], + actionAuth: ['EXPLICIT_NONE'], }), }, { authOptions: createRecipientAuthOptions({ - accessAuth: null, - actionAuth: 'ACCOUNT', + accessAuth: [], + actionAuth: ['ACCOUNT'], }), }, ], fields: [FieldType.DATE, FieldType.SIGNATURE], updateDocumentOptions: { authOptions: createDocumentAuthOptions({ - globalAccessAuth: null, - globalActionAuth: 'ACCOUNT', + globalAccessAuth: [], + globalActionAuth: ['ACCOUNT'], }), }, }); @@ -323,7 +323,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); // This document HAS global action auth, so account and inherit should require auth. - const isAuthRequired = actionAuth === 'ACCOUNT' || actionAuth === null; + const isAuthRequired = actionAuth.includes('ACCOUNT') || actionAuth.length === 0; const signUrl = `/sign/${token}`; diff --git a/packages/app-tests/e2e/document-flow/settings-step.spec.ts b/packages/app-tests/e2e/document-flow/settings-step.spec.ts index e375f4d45..d3115e584 100644 --- a/packages/app-tests/e2e/document-flow/settings-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/settings-step.spec.ts @@ -40,7 +40,7 @@ test.describe('[EE_ONLY]', () => { // Set EE action auth. await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); // Save the settings by going to the next step. @@ -82,7 +82,7 @@ test.describe('[EE_ONLY]', () => { // Set EE action auth. await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); // Save the settings by going to the next step. @@ -143,7 +143,7 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => { // Set access auth. await page.getByTestId('documentAccessSelectValue').click(); - await page.getByLabel('Require account').getByText('Require account').click(); + await page.getByRole('option').filter({ hasText: 'Require account' }).click(); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); // Action auth should NOT be visible. diff --git a/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts index 6cd714082..3cdf197f8 100644 --- a/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts +++ b/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts @@ -37,7 +37,7 @@ test.describe('[EE_ONLY]', () => { // Set EE action auth. await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); // Save the settings by going to the next step. @@ -79,7 +79,7 @@ test.describe('[EE_ONLY]', () => { // Set EE action auth. await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); // Save the settings by going to the next step. @@ -140,7 +140,7 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => { // Set access auth. await page.getByTestId('documentAccessSelectValue').click(); - await page.getByLabel('Require account').getByText('Require account').click(); + await page.getByRole('option').filter({ hasText: 'Require account' }).click(); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); // Action auth should NOT be visible. diff --git a/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts index fe3efeb83..338d86e9d 100644 --- a/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts +++ b/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts @@ -58,8 +58,8 @@ test.describe('[EE_ONLY]', () => { // Add advanced settings for a single recipient. await page.getByLabel('Show advanced settings').check(); - await page.getByRole('combobox').first().click(); - await page.getByLabel('Require passkey').click(); + await page.getByTestId('documentActionSelectValue').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); // Navigate to the next step and back. await page.getByRole('button', { name: 'Continue' }).click(); diff --git a/packages/app-tests/e2e/templates/create-document-from-template.spec.ts b/packages/app-tests/e2e/templates/create-document-from-template.spec.ts index 8add68d7d..564b57d30 100644 --- a/packages/app-tests/e2e/templates/create-document-from-template.spec.ts +++ b/packages/app-tests/e2e/templates/create-document-from-template.spec.ts @@ -48,13 +48,13 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) => // Set template document access. await page.getByTestId('documentAccessSelectValue').click(); - await page.getByLabel('Require account').getByText('Require account').click(); + await page.getByRole('option').filter({ hasText: 'Require account' }).click(); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); // Set EE action auth. if (isBillingEnabled) { await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); } @@ -85,8 +85,8 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) => // Apply require passkey for Recipient 1. if (isBillingEnabled) { await page.getByLabel('Show advanced settings').check(); - await page.getByRole('combobox').first().click(); - await page.getByLabel('Require passkey').click(); + await page.getByTestId('documentActionSelectValue').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); } await page.getByRole('button', { name: 'Continue' }).click(); @@ -119,10 +119,12 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) => }); expect(document.title).toEqual('TEMPLATE_TITLE'); - expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT'); - expect(documentAuth.documentAuthOption.globalActionAuth).toEqual( - isBillingEnabled ? 'PASSKEY' : null, - ); + expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT'); + + if (isBillingEnabled) { + expect(documentAuth.documentAuthOption.globalActionAuth).toContain('PASSKEY'); + } + expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a'); expect(document.documentMeta?.message).toEqual('MESSAGE'); expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com'); @@ -143,11 +145,11 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) => }); if (isBillingEnabled) { - expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY'); + expect(recipientOneAuth.derivedRecipientActionAuth).toContain('PASSKEY'); } - expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); - expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); + expect(recipientOneAuth.derivedRecipientAccessAuth).toContain('ACCOUNT'); + expect(recipientTwoAuth.derivedRecipientAccessAuth).toContain('ACCOUNT'); }); /** @@ -183,13 +185,13 @@ test('[TEMPLATE]: should create a team document from a team template', async ({ // Set template document access. await page.getByTestId('documentAccessSelectValue').click(); - await page.getByLabel('Require account').getByText('Require account').click(); + await page.getByRole('option').filter({ hasText: 'Require account' }).click(); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); // Set EE action auth. if (isBillingEnabled) { await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); } @@ -220,8 +222,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({ // Apply require passkey for Recipient 1. if (isBillingEnabled) { await page.getByLabel('Show advanced settings').check(); - await page.getByRole('combobox').first().click(); - await page.getByLabel('Require passkey').click(); + await page.getByTestId('documentActionSelectValue').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); } await page.getByRole('button', { name: 'Continue' }).click(); @@ -256,10 +258,12 @@ test('[TEMPLATE]: should create a team document from a team template', async ({ }); expect(document.title).toEqual('TEMPLATE_TITLE'); - expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT'); - expect(documentAuth.documentAuthOption.globalActionAuth).toEqual( - isBillingEnabled ? 'PASSKEY' : null, - ); + expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT'); + + if (isBillingEnabled) { + expect(documentAuth.documentAuthOption.globalActionAuth).toContain('PASSKEY'); + } + expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a'); expect(document.documentMeta?.message).toEqual('MESSAGE'); expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com'); @@ -280,11 +284,11 @@ test('[TEMPLATE]: should create a team document from a team template', async ({ }); if (isBillingEnabled) { - expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY'); + expect(recipientOneAuth.derivedRecipientActionAuth).toContain('PASSKEY'); } - expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); - expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); + expect(recipientOneAuth.derivedRecipientAccessAuth).toContain('ACCOUNT'); + expect(recipientTwoAuth.derivedRecipientAccessAuth).toContain('ACCOUNT'); }); /** diff --git a/packages/app-tests/e2e/templates/direct-templates.spec.ts b/packages/app-tests/e2e/templates/direct-templates.spec.ts index aa4730395..de25e2adc 100644 --- a/packages/app-tests/e2e/templates/direct-templates.spec.ts +++ b/packages/app-tests/e2e/templates/direct-templates.spec.ts @@ -172,8 +172,8 @@ test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => userId: user.id, createTemplateOptions: { authOptions: createDocumentAuthOptions({ - globalAccessAuth: 'ACCOUNT', - globalActionAuth: null, + globalAccessAuth: ['ACCOUNT'], + globalActionAuth: [], }), }, }); diff --git a/packages/lib/constants/document-auth.ts b/packages/lib/constants/document-auth.ts index 77c8d7b58..3301a700c 100644 --- a/packages/lib/constants/document-auth.ts +++ b/packages/lib/constants/document-auth.ts @@ -19,6 +19,10 @@ export const DOCUMENT_AUTH_TYPES: Record = { key: DocumentAuth.TWO_FACTOR_AUTH, value: 'Require 2FA', }, + [DocumentAuth.PASSWORD]: { + key: DocumentAuth.PASSWORD, + value: 'Require password', + }, [DocumentAuth.EXPLICIT_NONE]: { key: DocumentAuth.EXPLICIT_NONE, value: 'None (Overrides global settings)', diff --git a/packages/lib/server-only/2fa/verify-password.ts b/packages/lib/server-only/2fa/verify-password.ts new file mode 100644 index 000000000..3624919db --- /dev/null +++ b/packages/lib/server-only/2fa/verify-password.ts @@ -0,0 +1,20 @@ +import { compare } from '@node-rs/bcrypt'; + +import { prisma } from '@documenso/prisma'; + +type VerifyPasswordOptions = { + userId: number; + password: string; +}; + +export const verifyPassword = async ({ userId, password }: VerifyPasswordOptions) => { + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user || !user.password) { + return false; + } + + return await compare(password, user.password); +}; 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 d77daa174..92d304c2f 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -23,6 +23,7 @@ import { ZWebhookDocumentSchema, mapDocumentToWebhookDocumentPayload, } from '../../types/webhook-payload'; +import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { sendPendingEmail } from './send-pending-email'; @@ -140,6 +141,11 @@ export const completeDocumentWithToken = async ({ }, }); + const authOptions = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }); + await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, @@ -154,6 +160,7 @@ export const completeDocumentWithToken = async ({ recipientName: recipient.name, recipientId: recipient.id, recipientRole: recipient.role, + actionAuth: authOptions.derivedRecipientActionAuth, }, }), }); diff --git a/packages/lib/server-only/document/create-document-v2.ts b/packages/lib/server-only/document/create-document-v2.ts index a87860488..19719ca85 100644 --- a/packages/lib/server-only/document/create-document-v2.ts +++ b/packages/lib/server-only/document/create-document-v2.ts @@ -39,8 +39,8 @@ export type CreateDocumentOptions = { title: string; externalId?: string; visibility?: DocumentVisibility; - globalAccessAuth?: TDocumentAccessAuthTypes; - globalActionAuth?: TDocumentActionAuthTypes; + globalAccessAuth?: TDocumentAccessAuthTypes[]; + globalActionAuth?: TDocumentActionAuthTypes[]; formValues?: TDocumentFormValues; recipients: TCreateDocumentV2Request['recipients']; }; @@ -113,14 +113,16 @@ export const createDocumentV2 = async ({ } const authOptions = createDocumentAuthOptions({ - globalAccessAuth: data?.globalAccessAuth || null, - globalActionAuth: data?.globalActionAuth || null, + globalAccessAuth: data?.globalAccessAuth || [], + globalActionAuth: data?.globalActionAuth || [], }); - const recipientsHaveActionAuth = data.recipients?.some((recipient) => recipient.actionAuth); + const recipientsHaveActionAuth = data.recipients?.some( + (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, + ); // Check if user has permission to set the global action auth. - if (authOptions.globalActionAuth || recipientsHaveActionAuth) { + if (authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) { const isDocumentEnterprise = await isUserEnterprise({ userId, teamId, @@ -171,8 +173,8 @@ export const createDocumentV2 = async ({ await Promise.all( (data.recipients || []).map(async (recipient) => { const recipientAuthOptions = createRecipientAuthOptions({ - accessAuth: recipient.accessAuth || null, - actionAuth: recipient.actionAuth || null, + accessAuth: recipient.accessAuth ?? [], + actionAuth: recipient.actionAuth ?? [], }); await tx.recipient.create({ diff --git a/packages/lib/server-only/document/get-document-certificate-audit-logs.ts b/packages/lib/server-only/document/get-document-certificate-audit-logs.ts index 676118506..3ace2687f 100644 --- a/packages/lib/server-only/document/get-document-certificate-audit-logs.ts +++ b/packages/lib/server-only/document/get-document-certificate-audit-logs.ts @@ -17,6 +17,7 @@ export const getDocumentCertificateAuditLogs = async ({ in: [ DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, ], @@ -36,6 +37,9 @@ export const getDocumentCertificateAuditLogs = async ({ [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter( (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, ), + [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED]: auditLogs.filter( + (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED, + ), [DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter( (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && diff --git a/packages/lib/server-only/document/is-recipient-authorized.ts b/packages/lib/server-only/document/is-recipient-authorized.ts index 2426b5d88..873b087e1 100644 --- a/packages/lib/server-only/document/is-recipient-authorized.ts +++ b/packages/lib/server-only/document/is-recipient-authorized.ts @@ -5,6 +5,7 @@ import { match } from 'ts-pattern'; import { prisma } from '@documenso/prisma'; import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token'; +import { verifyPassword } from '../2fa/verify-password'; import { AppError, AppErrorCode } from '../../errors/app-error'; import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth'; import { DocumentAuth } from '../../types/document-auth'; @@ -60,23 +61,26 @@ export const isRecipientAuthorized = async ({ recipientAuth: recipient.authOptions, }); - const authMethod: TDocumentAuth | null = + const authMethods: TDocumentAuth[] = type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth; // Early true return when auth is not required. - if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) { + if ( + authMethods.length === 0 || + authMethods.some((method) => method === DocumentAuth.EXPLICIT_NONE) + ) { return true; } // Create auth options when none are passed for account. - if (!authOptions && authMethod === DocumentAuth.ACCOUNT) { + if (!authOptions && authMethods.some((method) => method === DocumentAuth.ACCOUNT)) { authOptions = { type: DocumentAuth.ACCOUNT, }; } // Authentication required does not match provided method. - if (!authOptions || authOptions.type !== authMethod || !userId) { + if (!authOptions || !authMethods.includes(authOptions.type) || !userId) { return false; } @@ -117,6 +121,15 @@ export const isRecipientAuthorized = async ({ window: 10, // 5 minutes worth of tokens }); }) + .with({ type: DocumentAuth.PASSWORD }, async ({ password }) => { + return await verifyPassword({ + userId, + password, + }); + }) + .with({ type: DocumentAuth.EXPLICIT_NONE }, () => { + return true; + }) .exhaustive(); }; @@ -160,7 +173,7 @@ const verifyPasskey = async ({ }: VerifyPasskeyOptions): Promise => { const passkey = await prisma.passkey.findFirst({ where: { - credentialId: Buffer.from(authenticationResponse.id, 'base64'), + credentialId: new Uint8Array(Buffer.from(authenticationResponse.id, 'base64')), userId, }, }); diff --git a/packages/lib/server-only/document/update-document.ts b/packages/lib/server-only/document/update-document.ts index f93661583..fe2f149a5 100644 --- a/packages/lib/server-only/document/update-document.ts +++ b/packages/lib/server-only/document/update-document.ts @@ -21,8 +21,8 @@ export type UpdateDocumentOptions = { title?: string; externalId?: string | null; visibility?: DocumentVisibility | null; - globalAccessAuth?: TDocumentAccessAuthTypes | null; - globalActionAuth?: TDocumentActionAuthTypes | null; + globalAccessAuth?: TDocumentAccessAuthTypes[]; + globalActionAuth?: TDocumentActionAuthTypes[]; useLegacyFieldInsertion?: boolean; }; requestMetadata: ApiRequestMetadata; @@ -119,7 +119,6 @@ export const updateDocument = async ({ // If no data just return the document since this function is normally chained after a meta update. if (!data || Object.values(data).length === 0) { - console.log('no data'); return document; } @@ -137,7 +136,7 @@ export const updateDocument = async ({ data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth; // Check if user has permission to set the global action auth. - if (newGlobalActionAuth) { + if (newGlobalActionAuth && newGlobalActionAuth.length > 0) { const isDocumentEnterprise = await isUserEnterprise({ userId, teamId, diff --git a/packages/lib/server-only/document/validate-field-auth.ts b/packages/lib/server-only/document/validate-field-auth.ts index b78b8a936..8dfc0ab48 100644 --- a/packages/lib/server-only/document/validate-field-auth.ts +++ b/packages/lib/server-only/document/validate-field-auth.ts @@ -3,7 +3,6 @@ import { FieldType } from '@prisma/client'; import { AppError, AppErrorCode } from '../../errors/app-error'; import type { TRecipientActionAuth } from '../../types/document-auth'; -import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { isRecipientAuthorized } from './is-recipient-authorized'; export type ValidateFieldAuthOptions = { @@ -26,14 +25,9 @@ export const validateFieldAuth = async ({ userId, authOptions, }: ValidateFieldAuthOptions) => { - const { derivedRecipientActionAuth } = extractDocumentAuthMethods({ - documentAuth: documentAuthOptions, - recipientAuth: recipient.authOptions, - }); - // Override all non-signature fields to not require any auth. if (field.type !== FieldType.SIGNATURE) { - return null; + return undefined; } const isValid = await isRecipientAuthorized({ @@ -50,5 +44,5 @@ export const validateFieldAuth = async ({ }); } - return derivedRecipientActionAuth; + return authOptions?.type; }; diff --git a/packages/lib/server-only/document/viewed-document.ts b/packages/lib/server-only/document/viewed-document.ts index 1b3da020e..fbc0aaa2d 100644 --- a/packages/lib/server-only/document/viewed-document.ts +++ b/packages/lib/server-only/document/viewed-document.ts @@ -15,7 +15,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type ViewedDocumentOptions = { token: string; - recipientAccessAuth?: TDocumentAccessAuthTypes | null; + recipientAccessAuth?: TDocumentAccessAuthTypes[]; requestMetadata?: RequestMetadata; }; @@ -63,7 +63,7 @@ export const viewedDocument = async ({ recipientId: recipient.id, recipientName: recipient.name, recipientRole: recipient.role, - accessAuth: recipientAccessAuth || undefined, + accessAuth: recipientAccessAuth ?? [], }, }), }); diff --git a/packages/lib/server-only/embedding-presign/create-embedding-presign-token.ts b/packages/lib/server-only/embedding-presign/create-embedding-presign-token.ts index ecdf95d24..f298fe613 100644 --- a/packages/lib/server-only/embedding-presign/create-embedding-presign-token.ts +++ b/packages/lib/server-only/embedding-presign/create-embedding-presign-token.ts @@ -27,7 +27,6 @@ export const createEmbeddingPresignToken = async ({ // In development mode, allow setting expiresIn to 0 for testing // In production, enforce a minimum expiration time const isDevelopment = env('NODE_ENV') !== 'production'; - console.log('isDevelopment', isDevelopment); const minExpirationMinutes = isDevelopment ? 0 : 5; // Ensure expiresIn is at least the minimum allowed value diff --git a/packages/lib/server-only/recipient/create-document-recipients.ts b/packages/lib/server-only/recipient/create-document-recipients.ts index 8a7059ed8..707011296 100644 --- a/packages/lib/server-only/recipient/create-document-recipients.ts +++ b/packages/lib/server-only/recipient/create-document-recipients.ts @@ -22,8 +22,8 @@ export interface CreateDocumentRecipientsOptions { name: string; role: RecipientRole; signingOrder?: number | null; - accessAuth?: TRecipientAccessAuthTypes | null; - actionAuth?: TRecipientActionAuthTypes | null; + accessAuth?: TRecipientAccessAuthTypes[]; + actionAuth?: TRecipientActionAuthTypes[]; }[]; requestMetadata: ApiRequestMetadata; } @@ -71,7 +71,9 @@ export const createDocumentRecipients = async ({ }); } - const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth); + const recipientsHaveActionAuth = recipientsToCreate.some( + (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, + ); // Check if user has permission to set the global action auth. if (recipientsHaveActionAuth) { @@ -110,8 +112,8 @@ export const createDocumentRecipients = async ({ return await Promise.all( normalizedRecipients.map(async (recipient) => { const authOptions = createRecipientAuthOptions({ - accessAuth: recipient.accessAuth || null, - actionAuth: recipient.actionAuth || null, + accessAuth: recipient.accessAuth ?? [], + actionAuth: recipient.actionAuth ?? [], }); const createdRecipient = await tx.recipient.create({ @@ -140,8 +142,8 @@ export const createDocumentRecipients = async ({ recipientName: createdRecipient.name, recipientId: createdRecipient.id, recipientRole: createdRecipient.role, - accessAuth: recipient.accessAuth || undefined, - actionAuth: recipient.actionAuth || undefined, + accessAuth: recipient.accessAuth ?? [], + actionAuth: recipient.actionAuth ?? [], }, }), }); diff --git a/packages/lib/server-only/recipient/create-template-recipients.ts b/packages/lib/server-only/recipient/create-template-recipients.ts index e53ba784b..8768172f4 100644 --- a/packages/lib/server-only/recipient/create-template-recipients.ts +++ b/packages/lib/server-only/recipient/create-template-recipients.ts @@ -19,8 +19,8 @@ export interface CreateTemplateRecipientsOptions { name: string; role: RecipientRole; signingOrder?: number | null; - accessAuth?: TRecipientAccessAuthTypes | null; - actionAuth?: TRecipientActionAuthTypes | null; + accessAuth?: TRecipientAccessAuthTypes[]; + actionAuth?: TRecipientActionAuthTypes[]; }[]; } @@ -60,7 +60,9 @@ export const createTemplateRecipients = async ({ }); } - const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth); + const recipientsHaveActionAuth = recipientsToCreate.some( + (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, + ); // Check if user has permission to set the global action auth. if (recipientsHaveActionAuth) { @@ -99,8 +101,8 @@ export const createTemplateRecipients = async ({ return await Promise.all( normalizedRecipients.map(async (recipient) => { const authOptions = createRecipientAuthOptions({ - accessAuth: recipient.accessAuth || null, - actionAuth: recipient.actionAuth || null, + accessAuth: recipient.accessAuth ?? [], + actionAuth: recipient.actionAuth ?? [], }); const createdRecipient = await tx.recipient.create({ diff --git a/packages/lib/server-only/recipient/set-document-recipients.ts b/packages/lib/server-only/recipient/set-document-recipients.ts index 33a14a28f..b81ca2848 100644 --- a/packages/lib/server-only/recipient/set-document-recipients.ts +++ b/packages/lib/server-only/recipient/set-document-recipients.ts @@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro'; import type { Recipient } from '@prisma/client'; import { RecipientRole } from '@prisma/client'; import { SendStatus, SigningStatus } from '@prisma/client'; +import { isDeepEqual } from 'remeda'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { mailer } from '@documenso/email/mailer'; @@ -96,7 +97,9 @@ export const setDocumentRecipients = async ({ throw new Error('Document already complete'); } - const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); + const recipientsHaveActionAuth = recipients.some( + (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, + ); // Check if user has permission to set the global action auth. if (recipientsHaveActionAuth) { @@ -245,8 +248,8 @@ export const setDocumentRecipients = async ({ metadata: requestMetadata, data: { ...baseAuditLog, - accessAuth: recipient.accessAuth || undefined, - actionAuth: recipient.actionAuth || undefined, + accessAuth: recipient.accessAuth || [], + actionAuth: recipient.actionAuth || [], }, }), }); @@ -361,8 +364,8 @@ type RecipientData = { name: string; role: RecipientRole; signingOrder?: number | null; - accessAuth?: TRecipientAccessAuthTypes | null; - actionAuth?: TRecipientActionAuthTypes | null; + accessAuth?: TRecipientAccessAuthTypes[]; + actionAuth?: TRecipientActionAuthTypes[]; }; type RecipientDataWithClientId = Recipient & { @@ -372,15 +375,15 @@ type RecipientDataWithClientId = Recipient & { const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => { const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); - const newRecipientAccessAuth = newRecipientData.accessAuth || null; - const newRecipientActionAuth = newRecipientData.actionAuth || null; + const newRecipientAccessAuth = newRecipientData.accessAuth || []; + const newRecipientActionAuth = newRecipientData.actionAuth || []; return ( recipient.email !== newRecipientData.email || recipient.name !== newRecipientData.name || recipient.role !== newRecipientData.role || recipient.signingOrder !== newRecipientData.signingOrder || - authOptions.accessAuth !== newRecipientAccessAuth || - authOptions.actionAuth !== newRecipientActionAuth + !isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) || + !isDeepEqual(authOptions.actionAuth, newRecipientActionAuth) ); }; diff --git a/packages/lib/server-only/recipient/set-template-recipients.ts b/packages/lib/server-only/recipient/set-template-recipients.ts index 5d878d90b..c1a7fc25b 100644 --- a/packages/lib/server-only/recipient/set-template-recipients.ts +++ b/packages/lib/server-only/recipient/set-template-recipients.ts @@ -26,7 +26,7 @@ export type SetTemplateRecipientsOptions = { name: string; role: RecipientRole; signingOrder?: number | null; - actionAuth?: TRecipientActionAuthTypes | null; + actionAuth?: TRecipientActionAuthTypes[]; }[]; }; @@ -64,7 +64,9 @@ export const setTemplateRecipients = async ({ throw new Error('Template not found'); } - const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); + const recipientsHaveActionAuth = recipients.some( + (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, + ); // Check if user has permission to set the global action auth. if (recipientsHaveActionAuth) { diff --git a/packages/lib/server-only/recipient/update-document-recipients.ts b/packages/lib/server-only/recipient/update-document-recipients.ts index 577915068..43e6c9c37 100644 --- a/packages/lib/server-only/recipient/update-document-recipients.ts +++ b/packages/lib/server-only/recipient/update-document-recipients.ts @@ -1,6 +1,7 @@ import type { Recipient } from '@prisma/client'; import { RecipientRole } from '@prisma/client'; import { SendStatus, SigningStatus } from '@prisma/client'; +import { isDeepEqual } from 'remeda'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; @@ -72,7 +73,9 @@ export const updateDocumentRecipients = async ({ }); } - const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); + const recipientsHaveActionAuth = recipients.some( + (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, + ); // Check if user has permission to set the global action auth. if (recipientsHaveActionAuth) { @@ -218,8 +221,8 @@ type RecipientData = { name?: string; role?: RecipientRole; signingOrder?: number | null; - accessAuth?: TRecipientAccessAuthTypes | null; - actionAuth?: TRecipientActionAuthTypes | null; + accessAuth?: TRecipientAccessAuthTypes[]; + actionAuth?: TRecipientActionAuthTypes[]; }; const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => { @@ -233,7 +236,7 @@ const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: Recipie recipient.name !== newRecipientData.name || recipient.role !== newRecipientData.role || recipient.signingOrder !== newRecipientData.signingOrder || - authOptions.accessAuth !== newRecipientAccessAuth || - authOptions.actionAuth !== newRecipientActionAuth + !isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) || + !isDeepEqual(authOptions.actionAuth, newRecipientActionAuth) ); }; diff --git a/packages/lib/server-only/recipient/update-recipient.ts b/packages/lib/server-only/recipient/update-recipient.ts index 67077c6ab..a352ca781 100644 --- a/packages/lib/server-only/recipient/update-recipient.ts +++ b/packages/lib/server-only/recipient/update-recipient.ts @@ -20,7 +20,7 @@ export type UpdateRecipientOptions = { name?: string; role?: RecipientRole; signingOrder?: number | null; - actionAuth?: TRecipientActionAuthTypes | null; + actionAuth?: TRecipientActionAuthTypes[]; userId: number; teamId?: number; requestMetadata?: RequestMetadata; @@ -90,7 +90,7 @@ export const updateRecipient = async ({ throw new Error('Recipient not found'); } - if (actionAuth) { + if (actionAuth && actionAuth.length > 0) { const isDocumentEnterprise = await isUserEnterprise({ userId, teamId, @@ -117,7 +117,7 @@ export const updateRecipient = async ({ signingOrder, authOptions: createRecipientAuthOptions({ accessAuth: recipientAuthOptions.accessAuth, - actionAuth: actionAuth ?? null, + actionAuth: actionAuth ?? [], }), }, }); diff --git a/packages/lib/server-only/recipient/update-template-recipients.ts b/packages/lib/server-only/recipient/update-template-recipients.ts index b69b9dc2b..a9d723064 100644 --- a/packages/lib/server-only/recipient/update-template-recipients.ts +++ b/packages/lib/server-only/recipient/update-template-recipients.ts @@ -22,8 +22,8 @@ export interface UpdateTemplateRecipientsOptions { name?: string; role?: RecipientRole; signingOrder?: number | null; - accessAuth?: TRecipientAccessAuthTypes | null; - actionAuth?: TRecipientActionAuthTypes | null; + accessAuth?: TRecipientAccessAuthTypes[]; + actionAuth?: TRecipientActionAuthTypes[]; }[]; } @@ -63,7 +63,9 @@ export const updateTemplateRecipients = async ({ }); } - const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); + const recipientsHaveActionAuth = recipients.some( + (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, + ); // Check if user has permission to set the global action auth. if (recipientsHaveActionAuth) { diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index 9d71f3297..4441b9460 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -70,7 +70,7 @@ export type CreateDocumentFromDirectTemplateOptions = { type CreatedDirectRecipientField = { field: Field & { signature?: Signature | null }; - derivedRecipientActionAuth: TRecipientActionAuthTypes | null; + derivedRecipientActionAuth?: TRecipientActionAuthTypes; }; export const ZCreateDocumentFromDirectTemplateResponseSchema = z.object({ @@ -151,9 +151,9 @@ export const createDocumentFromDirectTemplate = async ({ const directRecipientName = user?.name || initialDirectRecipientName; // Ensure typesafety when we add more options. - const isAccessAuthValid = match(derivedRecipientAccessAuth) + const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0)) .with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail) - .with(null, () => true) + .with(undefined, () => true) .exhaustive(); if (!isAccessAuthValid) { @@ -460,7 +460,7 @@ export const createDocumentFromDirectTemplate = async ({ const createdDirectRecipientFields: CreatedDirectRecipientField[] = [ ...createdDirectRecipient.fields.map((field) => ({ field, - derivedRecipientActionAuth: null, + derivedRecipientActionAuth: undefined, })), ...createdDirectRecipientSignatureFields, ]; @@ -567,6 +567,7 @@ export const createDocumentFromDirectTemplate = async ({ recipientId: createdDirectRecipient.id, recipientName: createdDirectRecipient.name, recipientRole: createdDirectRecipient.role, + actionAuth: createdDirectRecipient.authOptions?.actionAuth ?? [], }, }), ]; diff --git a/packages/lib/server-only/template/update-template.ts b/packages/lib/server-only/template/update-template.ts index dee71d678..54f7864d1 100644 --- a/packages/lib/server-only/template/update-template.ts +++ b/packages/lib/server-only/template/update-template.ts @@ -15,8 +15,8 @@ export type UpdateTemplateOptions = { title?: string; externalId?: string | null; visibility?: DocumentVisibility; - globalAccessAuth?: TDocumentAccessAuthTypes | null; - globalActionAuth?: TDocumentActionAuthTypes | null; + globalAccessAuth?: TDocumentAccessAuthTypes[]; + globalActionAuth?: TDocumentActionAuthTypes[]; publicTitle?: string; publicDescription?: string; type?: Template['type']; @@ -74,7 +74,7 @@ export const updateTemplate = async ({ data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth; // Check if user has permission to set the global action auth. - if (newGlobalActionAuth) { + if (newGlobalActionAuth && newGlobalActionAuth.length > 0) { const isDocumentEnterprise = await isUserEnterprise({ userId, teamId, diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index cb7873834..25679287a 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -123,8 +123,8 @@ export const ZDocumentAuditLogFieldDiffSchema = z.union([ ]); export const ZGenericFromToSchema = z.object({ - from: z.string().nullable(), - to: z.string().nullable(), + from: z.union([z.string(), z.array(z.string())]).nullable(), + to: z.union([z.string(), z.array(z.string())]).nullable(), }); export const ZRecipientDiffActionAuthSchema = ZGenericFromToSchema.extend({ @@ -296,7 +296,7 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({ }, z .object({ - type: ZRecipientActionAuthTypesSchema, + type: ZRecipientActionAuthTypesSchema.optional(), }) .optional(), ), @@ -384,7 +384,7 @@ export const ZDocumentAuditLogEventDocumentFieldPrefilledSchema = z.object({ }, z .object({ - type: ZRecipientActionAuthTypesSchema, + type: ZRecipientActionAuthTypesSchema.optional(), }) .optional(), ), @@ -428,7 +428,13 @@ export const ZDocumentAuditLogEventDocumentMetaUpdatedSchema = z.object({ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED), data: ZBaseRecipientDataSchema.extend({ - accessAuth: z.string().optional(), + accessAuth: z.preprocess((unknownValue) => { + if (!unknownValue) { + return []; + } + + return Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + }, z.array(ZRecipientAccessAuthTypesSchema)), }), }); @@ -438,7 +444,13 @@ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED), data: ZBaseRecipientDataSchema.extend({ - actionAuth: z.string().optional(), + actionAuth: z.preprocess((unknownValue) => { + if (!unknownValue) { + return []; + } + + return Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + }, z.array(ZRecipientActionAuthTypesSchema)), }), }); @@ -516,8 +528,20 @@ export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({ export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED), data: ZBaseRecipientDataSchema.extend({ - accessAuth: ZRecipientAccessAuthTypesSchema.optional(), - actionAuth: ZRecipientActionAuthTypesSchema.optional(), + accessAuth: z.preprocess((unknownValue) => { + if (!unknownValue) { + return []; + } + + return Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + }, z.array(ZRecipientAccessAuthTypesSchema)), + actionAuth: z.preprocess((unknownValue) => { + if (!unknownValue) { + return []; + } + + return Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + }, z.array(ZRecipientActionAuthTypesSchema)), }), }); diff --git a/packages/lib/types/document-auth.ts b/packages/lib/types/document-auth.ts index f0979754d..493e14374 100644 --- a/packages/lib/types/document-auth.ts +++ b/packages/lib/types/document-auth.ts @@ -9,8 +9,10 @@ export const ZDocumentAuthTypesSchema = z.enum([ 'ACCOUNT', 'PASSKEY', 'TWO_FACTOR_AUTH', + 'PASSWORD', 'EXPLICIT_NONE', ]); + export const DocumentAuth = ZDocumentAuthTypesSchema.Enum; const ZDocumentAuthAccountSchema = z.object({ @@ -27,6 +29,11 @@ const ZDocumentAuthPasskeySchema = z.object({ tokenReference: z.string().min(1), }); +const ZDocumentAuthPasswordSchema = z.object({ + type: z.literal(DocumentAuth.PASSWORD), + password: z.string().min(1), +}); + const ZDocumentAuth2FASchema = z.object({ type: z.literal(DocumentAuth.TWO_FACTOR_AUTH), token: z.string().min(4).max(10), @@ -40,6 +47,7 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [ ZDocumentAuthExplicitNoneSchema, ZDocumentAuthPasskeySchema, ZDocumentAuth2FASchema, + ZDocumentAuthPasswordSchema, ]); /** @@ -61,9 +69,15 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ ZDocumentAuthAccountSchema, ZDocumentAuthPasskeySchema, ZDocumentAuth2FASchema, + ZDocumentAuthPasswordSchema, ]); export const ZDocumentActionAuthTypesSchema = z - .enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH]) + .enum([ + DocumentAuth.ACCOUNT, + DocumentAuth.PASSKEY, + DocumentAuth.TWO_FACTOR_AUTH, + DocumentAuth.PASSWORD, + ]) .describe( 'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.', ); @@ -89,6 +103,7 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [ ZDocumentAuthAccountSchema, ZDocumentAuthPasskeySchema, ZDocumentAuth2FASchema, + ZDocumentAuthPasswordSchema, ZDocumentAuthExplicitNoneSchema, ]); export const ZRecipientActionAuthTypesSchema = z @@ -96,6 +111,7 @@ export const ZRecipientActionAuthTypesSchema = z DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, + DocumentAuth.PASSWORD, DocumentAuth.EXPLICIT_NONE, ]) .describe('The type of authentication required for the recipient to sign the document.'); @@ -110,18 +126,26 @@ export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum; */ export const ZDocumentAuthOptionsSchema = z.preprocess( (unknownValue) => { - if (unknownValue) { - return unknownValue; + if (!unknownValue || typeof unknownValue !== 'object') { + return { + globalAccessAuth: [], + globalActionAuth: [], + }; } + const globalAccessAuth = + 'globalAccessAuth' in unknownValue ? processAuthValue(unknownValue.globalAccessAuth) : []; + const globalActionAuth = + 'globalActionAuth' in unknownValue ? processAuthValue(unknownValue.globalActionAuth) : []; + return { - globalAccessAuth: null, - globalActionAuth: null, + globalAccessAuth, + globalActionAuth, }; }, z.object({ - globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable(), - globalActionAuth: ZDocumentActionAuthTypesSchema.nullable(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema), }), ); @@ -130,21 +154,46 @@ export const ZDocumentAuthOptionsSchema = z.preprocess( */ export const ZRecipientAuthOptionsSchema = z.preprocess( (unknownValue) => { - if (unknownValue) { - return unknownValue; + if (!unknownValue || typeof unknownValue !== 'object') { + return { + accessAuth: [], + actionAuth: [], + }; } + const accessAuth = + 'accessAuth' in unknownValue ? processAuthValue(unknownValue.accessAuth) : []; + const actionAuth = + 'actionAuth' in unknownValue ? processAuthValue(unknownValue.actionAuth) : []; + return { - accessAuth: null, - actionAuth: null, + accessAuth, + actionAuth, }; }, z.object({ - accessAuth: ZRecipientAccessAuthTypesSchema.nullable(), - actionAuth: ZRecipientActionAuthTypesSchema.nullable(), + accessAuth: z.array(ZRecipientAccessAuthTypesSchema), + actionAuth: z.array(ZRecipientActionAuthTypesSchema), }), ); +/** + * Utility function to process the auth value. + * + * Converts the old singular auth value to an array of auth values. + */ +const processAuthValue = (value: unknown) => { + if (value === null || value === undefined) { + return []; + } + + if (Array.isArray(value)) { + return value; + } + + return [value]; +}; + export type TDocumentAuth = z.infer; export type TDocumentAuthMethods = z.infer; export type TDocumentAuthOptions = z.infer; diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index bc564370b..287d4a823 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -2,6 +2,7 @@ import type { I18n } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@prisma/client'; import { RecipientRole } from '@prisma/client'; +import { isDeepEqual } from 'remeda'; import { match } from 'ts-pattern'; import type { @@ -106,7 +107,7 @@ export const diffRecipientChanges = ( const newActionAuth = newAuthOptions?.actionAuth === undefined ? oldActionAuth : newAuthOptions.actionAuth; - if (oldAccessAuth !== newAccessAuth) { + if (!isDeepEqual(oldAccessAuth, newAccessAuth)) { diffs.push({ type: RECIPIENT_DIFF_TYPE.ACCESS_AUTH, from: oldAccessAuth ?? '', @@ -114,7 +115,7 @@ export const diffRecipientChanges = ( }); } - if (oldActionAuth !== newActionAuth) { + if (!isDeepEqual(oldActionAuth, newActionAuth)) { diffs.push({ type: RECIPIENT_DIFF_TYPE.ACTION_AUTH, from: oldActionAuth ?? '', diff --git a/packages/lib/utils/document-auth.ts b/packages/lib/utils/document-auth.ts index dcf8ccc9e..716e3fa11 100644 --- a/packages/lib/utils/document-auth.ts +++ b/packages/lib/utils/document-auth.ts @@ -27,17 +27,21 @@ export const extractDocumentAuthMethods = ({ const documentAuthOption = ZDocumentAuthOptionsSchema.parse(documentAuth); const recipientAuthOption = ZRecipientAuthOptionsSchema.parse(recipientAuth); - const derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null = - recipientAuthOption.accessAuth || documentAuthOption.globalAccessAuth; + const derivedRecipientAccessAuth: TRecipientAccessAuthTypes[] = + recipientAuthOption.accessAuth.length > 0 + ? recipientAuthOption.accessAuth + : documentAuthOption.globalAccessAuth; - const derivedRecipientActionAuth: TRecipientActionAuthTypes | null = - recipientAuthOption.actionAuth || documentAuthOption.globalActionAuth; + const derivedRecipientActionAuth: TRecipientActionAuthTypes[] = + recipientAuthOption.actionAuth.length > 0 + ? recipientAuthOption.actionAuth + : documentAuthOption.globalActionAuth; - const recipientAccessAuthRequired = derivedRecipientAccessAuth !== null; + const recipientAccessAuthRequired = derivedRecipientAccessAuth.length > 0; const recipientActionAuthRequired = - derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE && - derivedRecipientActionAuth !== null; + derivedRecipientActionAuth.length > 0 && + !derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE); return { derivedRecipientAccessAuth, @@ -54,8 +58,8 @@ export const extractDocumentAuthMethods = ({ */ export const createDocumentAuthOptions = (options: TDocumentAuthOptions): TDocumentAuthOptions => { return { - globalAccessAuth: options?.globalAccessAuth ?? null, - globalActionAuth: options?.globalActionAuth ?? null, + globalAccessAuth: options?.globalAccessAuth ?? [], + globalActionAuth: options?.globalActionAuth ?? [], }; }; @@ -66,7 +70,7 @@ export const createRecipientAuthOptions = ( options: TRecipientAuthOptions, ): TRecipientAuthOptions => { return { - accessAuth: options?.accessAuth ?? null, - actionAuth: options?.actionAuth ?? null, + accessAuth: options?.accessAuth ?? [], + actionAuth: options?.actionAuth ?? [], }; }; diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index ac977de28..239106a8b 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -206,8 +206,8 @@ export const ZCreateDocumentV2RequestSchema = z.object({ title: ZDocumentTitleSchema, externalId: ZDocumentExternalIdSchema.optional(), visibility: ZDocumentVisibilitySchema.optional(), - globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(), - globalActionAuth: ZDocumentActionAuthTypesSchema.optional(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(), formValues: ZDocumentFormValuesSchema.optional(), recipients: z .array( diff --git a/packages/trpc/server/document-router/update-document.types.ts b/packages/trpc/server/document-router/update-document.types.ts index e0f91b482..291eb82d8 100644 --- a/packages/trpc/server/document-router/update-document.types.ts +++ b/packages/trpc/server/document-router/update-document.types.ts @@ -42,8 +42,8 @@ export const ZUpdateDocumentRequestSchema = z.object({ title: ZDocumentTitleSchema.optional(), externalId: ZDocumentExternalIdSchema.nullish(), visibility: ZDocumentVisibilitySchema.optional(), - globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullish(), - globalActionAuth: ZDocumentActionAuthTypesSchema.nullish(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(), useLegacyFieldInsertion: z.boolean().optional(), }) .optional(), diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index 0c43838a2..060b3a031 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -26,8 +26,8 @@ export const ZCreateRecipientSchema = z.object({ name: z.string(), role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(), - accessAuth: ZRecipientAccessAuthTypesSchema.optional().nullable(), - actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), + accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]), + actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }); export const ZUpdateRecipientSchema = z.object({ @@ -36,8 +36,8 @@ export const ZUpdateRecipientSchema = z.object({ name: z.string().optional(), role: z.nativeEnum(RecipientRole).optional(), signingOrder: z.number().optional(), - accessAuth: ZRecipientAccessAuthTypesSchema.optional().nullable(), - actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), + accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]), + actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }); export const ZCreateDocumentRecipientRequestSchema = z.object({ @@ -106,7 +106,7 @@ export const ZSetDocumentRecipientsRequestSchema = z name: z.string(), role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(), - actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), + actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }), ), }) @@ -190,7 +190,7 @@ export const ZSetTemplateRecipientsRequestSchema = z name: z.string(), role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(), - actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), + actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }), ), }) diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index fc193abd7..ebaf70249 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -133,8 +133,8 @@ export const ZUpdateTemplateRequestSchema = z.object({ title: z.string().min(1).optional(), externalId: z.string().nullish(), visibility: z.nativeEnum(DocumentVisibility).optional(), - globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(), - globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]), publicTitle: z .string() .trim() diff --git a/packages/ui/components/document/document-global-auth-access-select.tsx b/packages/ui/components/document/document-global-auth-access-select.tsx index 1901d9b62..227f31d04 100644 --- a/packages/ui/components/document/document-global-auth-access-select.tsx +++ b/packages/ui/components/document/document-global-auth-access-select.tsx @@ -1,51 +1,75 @@ -import { forwardRef } from 'react'; +import React from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { SelectProps } from '@radix-ui/react-select'; import { InfoIcon } from 'lucide-react'; import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; +import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; -export const DocumentGlobalAuthAccessSelect = forwardRef( - (props, ref) => { - const { _ } = useLingui(); +export interface DocumentGlobalAuthAccessSelectProps { + value?: string[]; + defaultValue?: string[]; + onValueChange?: (value: string[]) => void; + disabled?: boolean; + placeholder?: string; +} - return ( - - ); - }, -); + // Convert string array to Option array for MultiSelect + const selectedOptions = + (value + ?.map((val) => authOptions.find((option) => option.value === val)) + .filter(Boolean) as Option[]) || []; + + // Convert default value to Option array + const defaultOptions = + (defaultValue + ?.map((val) => authOptions.find((option) => option.value === val)) + .filter(Boolean) as Option[]) || []; + + const handleChange = (options: Option[]) => { + const values = options.map((option) => option.value); + onValueChange?.(values); + }; + + return ( + + ); +}; DocumentGlobalAuthAccessSelect.displayName = 'DocumentGlobalAuthAccessSelect'; @@ -63,7 +87,11 @@ export const DocumentGlobalAuthAccessTooltip = () => (

- The authentication required for recipients to view the document. + The authentication methods required for recipients to view the document. +

+ +

+ Multiple access methods can be selected.

    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..35ab93dc8 100644 --- a/packages/ui/components/document/document-global-auth-action-select.tsx +++ b/packages/ui/components/document/document-global-auth-action-select.tsx @@ -1,54 +1,75 @@ -import { forwardRef } from 'react'; - import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -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 { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; +import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; -export const DocumentGlobalAuthActionSelect = forwardRef( - (props, ref) => { - const { _ } = useLingui(); +export interface DocumentGlobalAuthActionSelectProps { + value?: string[]; + defaultValue?: string[]; + onValueChange?: (value: string[]) => void; + disabled?: boolean; + placeholder?: string; +} - return ( - - ); - }, -); + // Convert string array to Option array for MultiSelect + const selectedOptions = + (value + ?.map((val) => authOptions.find((option) => option.value === val)) + .filter(Boolean) as Option[]) || []; + + // Convert default value to Option array + const defaultOptions = + (defaultValue + ?.map((val) => authOptions.find((option) => option.value === val)) + .filter(Boolean) as Option[]) || []; + + const handleChange = (options: Option[]) => { + const values = options.map((option) => option.value); + onValueChange?.(values); + }; + + return ( + + ); +}; DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect'; @@ -64,20 +85,19 @@ export const DocumentGlobalAuthActionTooltip = () => (

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

    - This can be overriden by setting the authentication requirements directly on each - recipient in the next step. + These can be overriden by setting the authentication requirements directly on each + recipient in the next step. Multiple methods can be selected.

      - {/*
    • - Require account - The recipient must be signed in -
    • */}
    • Require passkey - The recipient must have an account and passkey @@ -90,6 +110,14 @@ export const DocumentGlobalAuthActionTooltip = () => ( their settings
    • + +
    • + + Require password - The recipient must have an account and password + configured via their settings, the password will be verified during signing + +
    • +
    • No restrictions - No authentication required diff --git a/packages/ui/components/recipient/recipient-action-auth-select.tsx b/packages/ui/components/recipient/recipient-action-auth-select.tsx index 1ada0fc26..37de3be2f 100644 --- a/packages/ui/components/recipient/recipient-action-auth-select.tsx +++ b/packages/ui/components/recipient/recipient-action-auth-select.tsx @@ -3,97 +3,124 @@ import React from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { SelectProps } from '@radix-ui/react-select'; import { InfoIcon } from 'lucide-react'; import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import { RecipientActionAuth } from '@documenso/lib/types/document-auth'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; +import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; -export type RecipientActionAuthSelectProps = SelectProps; +export interface RecipientActionAuthSelectProps { + value?: string[]; + defaultValue?: string[]; + onValueChange?: (value: string[]) => void; + disabled?: boolean; + placeholder?: string; +} -export const RecipientActionAuthSelect = (props: RecipientActionAuthSelectProps) => { +export const RecipientActionAuthSelect = ({ + value, + defaultValue, + onValueChange, + disabled, + placeholder, +}: RecipientActionAuthSelectProps) => { const { _ } = useLingui(); + // Convert auth types to MultiSelect options + const authOptions: Option[] = [ + { + value: '-1', + label: _(msg`Inherit authentication method`), + }, + ...Object.values(RecipientActionAuth) + .filter((auth) => auth !== RecipientActionAuth.ACCOUNT) + .map((authType) => ({ + value: authType, + label: DOCUMENT_AUTH_TYPES[authType].value, + })), + ]; + + // Convert string array to Option array for MultiSelect + const selectedOptions = + (value + ?.map((val) => authOptions.find((option) => option.value === val)) + .filter(Boolean) as Option[]) || []; + + // Convert default value to Option array + const defaultOptions = + (defaultValue + ?.map((val) => authOptions.find((option) => option.value === val)) + .filter(Boolean) as Option[]) || []; + + const handleChange = (options: Option[]) => { + const values = options.map((option) => option.value); + onValueChange?.(values); + }; + return ( - +
        +
      • + + Inherit authentication method - Use the global action signing + authentication method configured in the "General Settings" step + +
      • +
      • + + Require passkey - The recipient must have an account and passkey + configured via their settings + +
      • +
      • + + Require 2FA - The recipient must have an account and 2FA enabled + via their settings + +
      • +
      • + + None - No authentication required + +
      • +
      + + + ); }; diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index b1f33517d..16aeb691f 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -98,8 +98,8 @@ export const AddSettingsFormPartial = ({ title: document.title, externalId: document.externalId || '', visibility: document.visibility || '', - globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined, - globalActionAuth: documentAuthOption?.globalActionAuth || undefined, + globalAccessAuth: documentAuthOption?.globalAccessAuth || [], + globalActionAuth: documentAuthOption?.globalActionAuth || [], meta: { timezone: @@ -131,6 +131,12 @@ export const AddSettingsFormPartial = ({ ) .otherwise(() => false); + const onFormSubmit = form.handleSubmit(onSubmit); + + const onGoNextClick = () => { + void onFormSubmit().catch(console.error); + }; + // We almost always want to set the timezone to the user's local timezone to avoid confusion // when the document is signed. useEffect(() => { @@ -214,7 +220,11 @@ export const AddSettingsFormPartial = ({ - @@ -244,7 +254,11 @@ export const AddSettingsFormPartial = ({ - + )} @@ -286,7 +300,11 @@ export const AddSettingsFormPartial = ({ - + )} @@ -370,7 +388,7 @@ export const AddSettingsFormPartial = ({