diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx index e0b5ca2b1..3c4ab41e8 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans } from '@lingui/react/macro'; @@ -6,9 +6,9 @@ import { RecipientRole } from '@prisma/client'; 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 { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { DialogFooter } from '@documenso/ui/primitives/dialog'; import { @@ -20,6 +20,8 @@ import { FormMessage, } from '@documenso/ui/primitives/form/form'; import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog'; @@ -51,6 +53,7 @@ export const DocumentSigningAuth2FA = ({ }: DocumentSigningAuth2FAProps) => { const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext(); + const { toast } = useToast(); const form = useForm({ resolver: zodResolver(Z2FAAuthFormSchema), @@ -60,27 +63,104 @@ export const DocumentSigningAuth2FA = ({ }); const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false); - const [formErrorCode, setFormErrorCode] = useState(null); + const [isEmailCodeSent, setIsEmailCodeSent] = useState(false); + const [isEmailCodeSending, setIsEmailCodeSending] = useState(false); + const [canResendEmail, setCanResendEmail] = useState(true); + const [resendCountdown, setResendCountdown] = useState(0); + const countdownTimerRef = useRef(null); + const [verificationMethod, setVerificationMethod] = useState<'app' | 'email'>( + user?.twoFactorEnabled ? 'app' : 'email', + ); + const emailSendInitiatedRef = useRef(false); + + const sendVerificationMutation = trpc.auth.sendEmailVerification.useMutation({ + onSuccess: () => { + setIsEmailCodeSent(true); + setCanResendEmail(false); + setResendCountdown(60); + + countdownTimerRef.current = setInterval(() => { + setResendCountdown((prev) => { + if (prev <= 1) { + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + } + setCanResendEmail(true); + return 0; + } + return prev - 1; + }); + }, 1000); + + toast({ + title: 'Verification code sent', + description: `A verification code has been sent to ${recipient.email}`, + }); + }, + onError: (error) => { + console.error('Failed to send verification code', error); + toast({ + title: 'Failed to send verification code', + description: 'Please try again or contact support', + variant: 'destructive', + }); + }, + onSettled: () => { + setIsEmailCodeSending(false); + }, + }); + + const verifyCodeMutation = trpc.auth.verifyEmailCode.useMutation(); + + const sendEmailVerificationCode = async () => { + try { + setIsEmailCodeSending(true); + await sendVerificationMutation.mutateAsync({ + recipientId: recipient.id, + }); + } catch (error) { + toast({ + title: 'Failed to send verification code', + description: 'Please try again.', + variant: 'destructive', + }); + } + }; + + useEffect(() => { + return () => { + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + } + }; + }, []); const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => { try { setIsCurrentlyAuthenticating(true); + if (verificationMethod === 'email') { + await verifyCodeMutation.mutateAsync({ + code: token, + recipientId: recipient.id, + }); + } + await onReauthFormSubmit({ type: DocumentAuth.TWO_FACTOR_AUTH, token, }); setIsCurrentlyAuthenticating(false); - onOpenChange(false); } catch (err) { setIsCurrentlyAuthenticating(false); - const error = AppError.parseError(err); - setFormErrorCode(error.code); - - // Todo: Alert. + toast({ + title: 'Unauthorized', + description: 'We were unable to verify your details.', + variant: 'destructive', + }); } }; @@ -90,21 +170,46 @@ export const DocumentSigningAuth2FA = ({ }); setIs2FASetupSuccessful(false); - setFormErrorCode(null); + setIsEmailCodeSent(false); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); + if (open && !user?.twoFactorEnabled) { + setVerificationMethod('email'); + } + }, [open, user?.twoFactorEnabled, form]); - if (!user?.twoFactorEnabled && !is2FASetupSuccessful) { + useEffect(() => { + if (!open || verificationMethod !== 'email') { + emailSendInitiatedRef.current = false; + } + }, [open, verificationMethod]); + + useEffect(() => { + if (open && verificationMethod === 'email' && !isEmailCodeSent && !isEmailCodeSending) { + if (!emailSendInitiatedRef.current) { + emailSendInitiatedRef.current = true; + void sendEmailVerificationCode(); + } + } + }, [open, verificationMethod, isEmailCodeSent, isEmailCodeSending]); + + if (verificationMethod === 'app' && !user?.twoFactorEnabled && !is2FASetupSuccessful) { return (
+ setVerificationMethod(val as 'app' | 'email')} + > + + Authenticator App + Email Verification + +

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

@@ -129,59 +234,106 @@ export const DocumentSigningAuth2FA = ({ } return ( -
- -
-
- ( - - 2FA token +
+ {user?.twoFactorEnabled && ( + setVerificationMethod(val as 'app' | 'email')} + > + + Authenticator App + Email Verification + + + )} - - - {Array(6) - .fill(null) - .map((_, i) => ( - - - - ))} - - - - - - )} - /> - - {formErrorCode && ( - - - Unauthorized - - - - We were unable to verify your details. Please try again or contact support - - - + {verificationMethod === 'email' && ( + + + {isEmailCodeSent ? ( +

+ + A verification code has been sent to {recipient.email}. Please enter it below to + continue. + +

+ ) : ( +

+ + We'll send a verification code to {recipient.email} to verify your identity. + +

)} +
+
+ )} - - + + +
+
+ ( + + + {verificationMethod === 'app' ? ( + 2FA token + ) : ( + Verification code + )} + - - -
-
- - + + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + + + + + + )} + /> + + {verificationMethod === 'email' && ( +
+ +
+ )} + + + + + + +
+
+ + +
); }; 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 706daf686..f743f380b 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 @@ -43,6 +43,7 @@ export const DocumentSigningAuthDialog = ({ title, description, availableAuthTypes, + actionTarget, open, onOpenChange, onReauthFormSubmit, @@ -107,15 +108,32 @@ export const DocumentSigningAuthDialog = ({ > - {title || Sign field} + + {title || + (actionTarget === 'DOCUMENT' ? ( + Sign document + ) : ( + Sign field + ))} + )} {(!selectedAuthType || validAuthTypes.length === 1) && - (title || Sign field)} + (title || + (actionTarget === 'DOCUMENT' ? ( + Sign document + ) : ( + Sign field + )))} - {description || Reauthentication is required to sign this field} + {description || ( + + Reauthentication is required to sign this{' '} + {actionTarget === 'DOCUMENT' ? 'document' : 'field'} + + )} @@ -180,6 +198,7 @@ export const DocumentSigningAuthDialog = ({ )) .with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => ( void; user?: SessionUser | null; refetchPasskeys: () => Promise; + isEnterprise: boolean; }; const DocumentSigningAuthContext = createContext(null); @@ -65,6 +66,7 @@ export interface DocumentSigningAuthProviderProps { recipient: Recipient; user?: SessionUser | null; children: React.ReactNode; + isEnterprise: boolean; } export const DocumentSigningAuthProvider = ({ @@ -72,6 +74,7 @@ export const DocumentSigningAuthProvider = ({ recipient: initialRecipient, user, children, + isEnterprise, }: DocumentSigningAuthProviderProps) => { const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions); const [recipient, setRecipient] = useState(initialRecipient); @@ -144,8 +147,13 @@ export const DocumentSigningAuthProvider = ({ }, [derivedRecipientActionAuth, user, recipient]); const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { - // Directly run callback if no auth required. - if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) { + // Determine if authentication is required based on enterprise status and action target. + const requiresAuthTrigger = isEnterprise + ? derivedRecipientActionAuth && options.actionTarget === FieldType.SIGNATURE + : derivedRecipientActionAuth && options.actionTarget === 'DOCUMENT'; + + // Directly run callback if no auth trigger is needed. + if (!requiresAuthTrigger) { await options.onReauthFormSubmit(); return; } @@ -205,6 +213,7 @@ export const DocumentSigningAuthProvider = ({ preferredPasskeyId, setPreferredPasskeyId, refetchPasskeys, + isEnterprise, }} > {children} @@ -225,6 +234,8 @@ export const DocumentSigningAuthProvider = ({ type ExecuteActionAuthProcedureOptions = Omit< DocumentSigningAuthDialogProps, 'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' | 'availableAuthTypes' ->; +> & { + actionTarget: FieldType | 'DOCUMENT'; +}; DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider'; diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx index 967c5d725..1c20725da 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-form.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -28,6 +28,7 @@ import { AssistantConfirmationDialog, type NextSigner, } from '../../dialogs/assistant-confirmation-dialog'; +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; import { useRequiredDocumentSigningContext } from './document-signing-provider'; @@ -39,6 +40,7 @@ export type DocumentSigningFormProps = { isRecipientsTurn: boolean; allRecipients?: RecipientWithFields[]; setSelectedSignerId?: (id: number | null) => void; + isEnterprise: boolean; }; export const DocumentSigningForm = ({ @@ -49,6 +51,7 @@ export const DocumentSigningForm = ({ isRecipientsTurn, allRecipients = [], setSelectedSignerId, + isEnterprise, }: DocumentSigningFormProps) => { const { sessionData } = useOptionalSession(); const user = sessionData?.user; @@ -62,6 +65,7 @@ export const DocumentSigningForm = ({ const assistantSignersId = useId(); const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext(); + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false); @@ -114,11 +118,16 @@ export const DocumentSigningForm = ({ setIsAssistantSubmitting(true); try { - await completeDocument(undefined, nextSigner); + await executeActionAuthProcedure({ + actionTarget: 'DOCUMENT', + onReauthFormSubmit: async (authOptions) => { + await completeDocument(authOptions, nextSigner); + }, + }); } catch (err) { toast({ - title: 'Error', - description: 'An error occurred while completing the document. Please try again.', + title: _(msg`Error`), + description: _(msg`An error occurred while completing the document. Please try again.`), variant: 'destructive', }); @@ -229,7 +238,12 @@ export const DocumentSigningForm = ({ fields={fields} fieldsValidated={fieldsValidated} onSignatureComplete={async (nextSigner) => { - await completeDocument(undefined, nextSigner); + await executeActionAuthProcedure({ + actionTarget: 'DOCUMENT', + onReauthFormSubmit: async (authOptions) => { + await completeDocument(authOptions, nextSigner); + }, + }); }} role={recipient.role} allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner} @@ -409,7 +423,12 @@ export const DocumentSigningForm = ({ fieldsValidated={fieldsValidated} disabled={!isRecipientsTurn} onSignatureComplete={async (nextSigner) => { - await completeDocument(undefined, nextSigner); + await executeActionAuthProcedure({ + actionTarget: 'DOCUMENT', + onReauthFormSubmit: async (authOptions) => { + await completeDocument(authOptions, nextSigner); + }, + }); }} role={recipient.role} allowDictateNextSigner={ diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx index e750bae74..834e622f3 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx @@ -48,6 +48,7 @@ export type DocumentSigningPageViewProps = { isRecipientsTurn: boolean; allRecipients?: RecipientWithFields[]; includeSenderDetails: boolean; + isEnterprise: boolean; }; export const DocumentSigningPageView = ({ @@ -58,6 +59,7 @@ export const DocumentSigningPageView = ({ isRecipientsTurn, allRecipients = [], includeSenderDetails, + isEnterprise, }: DocumentSigningPageViewProps) => { const { documentData, documentMeta } = document; @@ -154,6 +156,7 @@ export const DocumentSigningPageView = ({ isRecipientsTurn={isRecipientsTurn} allRecipients={allRecipients} setSelectedSignerId={setSelectedSignerId} + isEnterprise={isEnterprise} /> 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 ec1f234db..f8f03cef1 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -156,7 +156,7 @@ export const TemplateEditForm = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while updating the document settings.`), + description: _(msg`An error occurred while updating the template settings.`), variant: 'destructive', }); } diff --git a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx index ee37e5d0b..ea24805f6 100644 --- a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx @@ -94,6 +94,7 @@ export default function DirectTemplatePage() { documentAuthOptions={template.authOptions} recipient={directTemplateRecipient} user={user} + isEnterprise={false} >

false) + : false; + const [document, recipient, fields, completedFields] = await Promise.all([ getDocumentAndSenderByToken({ token, @@ -116,6 +121,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { isDocumentAccessValid: false, recipientEmail: recipient.email, recipientHasAccount, + isEnterprise, } as const); } @@ -153,6 +159,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { recipientSignature, isRecipientsTurn, includeSenderDetails: settings.includeSenderDetails, + isEnterprise, } as const); } @@ -181,6 +188,7 @@ export default function SigningPage() { allRecipients, includeSenderDetails, recipientWithFields, + isEnterprise, } = data; if (document.deletedAt || document.status === DocumentStatus.REJECTED) { @@ -246,6 +254,7 @@ export default function SigningPage() { documentAuthOptions={document.authOptions} recipient={recipient} user={user} + isEnterprise={isEnterprise} > diff --git a/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx b/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx index 5c3d1d916..ad2223dad 100644 --- a/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx +++ b/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx @@ -88,6 +88,8 @@ export async function loader({ params, request }: Route.LoaderArgs) { const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId); + const isEnterpriseDocument = Boolean(organisationClaim); + return superLoaderJson({ token, user, @@ -96,12 +98,21 @@ export async function loader({ params, request }: Route.LoaderArgs) { fields, hidePoweredBy, allowEmbedSigningWhitelabel, + isEnterpriseDocument, }); } export default function EmbedDirectTemplatePage() { - const { token, user, template, recipient, fields, hidePoweredBy, allowEmbedSigningWhitelabel } = - useSuperLoaderData(); + const { + token, + user, + template, + recipient, + fields, + hidePoweredBy, + allowEmbedSigningWhitelabel, + isEnterpriseDocument, + } = useSuperLoaderData(); return ( (); return ( @@ -148,6 +152,7 @@ export default function EmbedSignDocumentPage() { documentAuthOptions={document.authOptions} recipient={recipient} user={user} + isEnterprise={isEnterpriseDocument} > (); const revalidator = useRevalidator(); @@ -264,6 +267,7 @@ export default function MultisignPage() { documentAuthOptions={selectedDocument.authOptions} recipient={selectedRecipient} user={user} + isEnterprise={isEnterprise} > { await page.getByRole('option').filter({ hasText: 'Require account' }).click(); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); - // Action auth should NOT be visible. - await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible(); + // Action auth should now be visible for all users + await expect(page.getByTestId('documentActionSelectValue')).toBeVisible(); // Save the settings by going to the next step. 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 f0a86347e..a46d855cd 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 @@ -25,8 +25,8 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => { await page.getByRole('option').filter({ hasText: 'Require account' }).click(); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); - // Action auth should NOT be visible. - await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible(); + // Action auth should now be visible for all users + await expect(page.getByTestId('documentActionSelectValue')).toBeVisible(); // Save the settings by going to the next step. await page.getByRole('button', { name: 'Continue' }).click(); diff --git a/packages/email/template-components/template-verification-code.tsx b/packages/email/template-components/template-verification-code.tsx new file mode 100644 index 000000000..690533dba --- /dev/null +++ b/packages/email/template-components/template-verification-code.tsx @@ -0,0 +1,43 @@ +import { Trans } from '@lingui/react/macro'; + +import { Section, Text } from '../components'; +import { TemplateDocumentImage } from './template-document-image'; + +export type TemplateVerificationCodeProps = { + verificationCode: string; + assetBaseUrl: string; +}; + +export const TemplateVerificationCode = ({ + verificationCode, + assetBaseUrl, +}: TemplateVerificationCodeProps) => { + return ( + <> + + +
+ + Your verification code + + + + Please use the code below to verify your identity for document signing. + + + + {verificationCode} + + + + + If you did not request this code, you can ignore this email. The code will expire after + 10 minutes. + + +
+ + ); +}; + +export default TemplateVerificationCode; diff --git a/packages/email/templates/verification-code.tsx b/packages/email/templates/verification-code.tsx new file mode 100644 index 000000000..a93f7d79d --- /dev/null +++ b/packages/email/templates/verification-code.tsx @@ -0,0 +1,62 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; + +import { Body, Container, Head, Hr, Html, Img, Preview, Section } from '../components'; +import { useBranding } from '../providers/branding'; +import { TemplateFooter } from '../template-components/template-footer'; +import type { TemplateVerificationCodeProps } from '../template-components/template-verification-code'; +import { TemplateVerificationCode } from '../template-components/template-verification-code'; + +export type VerificationCodeTemplateProps = Partial; + +export const VerificationCodeTemplate = ({ + verificationCode = '000000', + assetBaseUrl = 'http://localhost:3002', +}: VerificationCodeTemplateProps) => { + const { _ } = useLingui(); + const branding = useBranding(); + + const previewText = msg`Your verification code for document signing`; + + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + + + {_(previewText)} + + +
+ +
+ {branding.brandingEnabled && branding.brandingLogo ? ( + Branding Logo + ) : ( + Documenso Logo + )} + + +
+
+ +
+ + + + +
+ + + ); +}; + +export default VerificationCodeTemplate; diff --git a/packages/lib/server-only/2fa/send-email-verification.ts b/packages/lib/server-only/2fa/send-email-verification.ts new file mode 100644 index 000000000..bccf5fd43 --- /dev/null +++ b/packages/lib/server-only/2fa/send-email-verification.ts @@ -0,0 +1,120 @@ +import { createElement } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { randomInt } from 'crypto'; + +import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes'; +import { mailer } from '@documenso/email/mailer'; +import { VerificationCodeTemplate } from '@documenso/email/templates/verification-code'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +import { getI18nInstance } from '../../client-only/providers/i18n-server'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; +import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; + +const ExtendedAuthErrorCode = { + ...AuthenticationErrorCode, + InternalError: 'INTERNAL_ERROR', + VerificationNotFound: 'VERIFICATION_NOT_FOUND', + VerificationExpired: 'VERIFICATION_EXPIRED', +}; + +const VERIFICATION_CODE_EXPIRY = 10 * 60 * 1000; + +export type SendEmailVerificationOptions = { + userId: number; + email: string; +}; + +export const sendEmailVerification = async ({ userId, email }: SendEmailVerificationOptions) => { + try { + const verificationCode = randomInt(100000, 1000000).toString(); + const i18n = await getI18nInstance(); + + await prisma.userTwoFactorEmailVerification.upsert({ + where: { + userId, + }, + create: { + userId, + verificationCode, + expiresAt: new Date(Date.now() + VERIFICATION_CODE_EXPIRY), + }, + update: { + verificationCode, + expiresAt: new Date(Date.now() + VERIFICATION_CODE_EXPIRY), + }, + }); + + const template = createElement(VerificationCodeTemplate, { + verificationCode, + assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(), + }); + + const [html, text] = await Promise.all([ + renderEmailWithI18N(template, { lang: 'en' }), + renderEmailWithI18N(template, { lang: 'en', plainText: true }), + ]); + + await mailer.sendMail({ + to: email, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: i18n._(msg`Your verification code for document signing`), + html, + text, + }); + + return { success: true }; + } catch (error) { + console.error('Error sending email verification', error); + throw new AppError(ExtendedAuthErrorCode.InternalError); + } +}; + +export type VerifyEmailCodeOptions = { + userId: number; + code: string; +}; + +export const verifyEmailCode = async ({ userId, code }: VerifyEmailCodeOptions) => { + try { + const verification = await prisma.userTwoFactorEmailVerification.findUnique({ + where: { + userId, + }, + }); + + if (!verification) { + throw new AppError(ExtendedAuthErrorCode.VerificationNotFound); + } + + if (verification.expiresAt < new Date()) { + throw new AppError(ExtendedAuthErrorCode.VerificationExpired); + } + + if (verification.verificationCode !== code) { + throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode); + } + + await prisma.userTwoFactorEmailVerification.delete({ + where: { + userId, + }, + }); + + return { success: true }; + } catch (error) { + console.error('Error verifying email code', error); + + if (error instanceof AppError) { + throw error; + } + + throw new AppError(ExtendedAuthErrorCode.InternalError); + } +}; diff --git a/packages/lib/server-only/document/update-document.ts b/packages/lib/server-only/document/update-document.ts index accb9fadf..8c583e11e 100644 --- a/packages/lib/server-only/document/update-document.ts +++ b/packages/lib/server-only/document/update-document.ts @@ -1,5 +1,4 @@ -import { DocumentVisibility } from '@prisma/client'; -import { DocumentStatus, TeamMemberRole } from '@prisma/client'; +import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client'; import { match } from 'ts-pattern'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; @@ -118,13 +117,6 @@ export const updateDocument = async ({ const newGlobalActionAuth = data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth; - // Check if user has permission to set the global action auth. - if (newGlobalActionAuth.length > 0 && !document.team.organisation.organisationClaim.flags.cfr21) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'You do not have permission to set the action auth', - }); - } - const isTitleSame = data.title === undefined || data.title === document.title; const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId; const isGlobalAccessSame = diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts index c3e18cb98..b5cedd504 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -15,7 +15,7 @@ import { AUTO_SIGNABLE_FIELD_TYPES } from '../../constants/autosign'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; -import type { TRecipientActionAuth } from '../../types/document-auth'; +import type { TRecipientActionAuth, TRecipientActionAuthTypes } from '../../types/document-auth'; import { ZCheckboxFieldMeta, ZDropdownFieldMeta, @@ -25,7 +25,9 @@ import { } from '../../types/field-meta'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; +import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { validateFieldAuth } from '../document/validate-field-auth'; +import { isUserEnterprise } from '../user/is-user-enterprise'; export type SignFieldWithTokenOptions = { token: string; @@ -171,13 +173,25 @@ export const signFieldWithToken = async ({ } } - const derivedRecipientActionAuth = await validateFieldAuth({ - documentAuthOptions: document.authOptions, - recipient, - field, - userId, - authOptions, - }); + const isEnterprise = userId ? await isUserEnterprise({ userId }) : false; + let requiredAuthType: TRecipientActionAuthTypes | null = null; + + if (isEnterprise) { + const authType = await validateFieldAuth({ + documentAuthOptions: document.authOptions, + recipient, + field, + userId, + authOptions, + }); + requiredAuthType = authType ?? null; + } else { + const { derivedRecipientActionAuth } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }); + requiredAuthType = derivedRecipientActionAuth.length > 0 ? derivedRecipientActionAuth[0] : null; + } const documentMeta = await prisma.documentMeta.findFirst({ where: { @@ -311,9 +325,9 @@ export const signFieldWithToken = async ({ }), ) .exhaustive(), - fieldSecurity: derivedRecipientActionAuth + fieldSecurity: requiredAuthType ? { - type: derivedRecipientActionAuth, + type: requiredAuthType, } : undefined, }, diff --git a/packages/lib/server-only/organisation/get-organisation-claims.ts b/packages/lib/server-only/organisation/get-organisation-claims.ts index 18096269d..bee05659e 100644 --- a/packages/lib/server-only/organisation/get-organisation-claims.ts +++ b/packages/lib/server-only/organisation/get-organisation-claims.ts @@ -37,3 +37,23 @@ export const getOrganisationClaimByTeamId = async ({ teamId }: { teamId: number return organisationClaim; }; + +export const getOrganisationClaimByUserId = async ({ userId }: { userId: number }) => { + const organisationClaim = await prisma.organisationClaim.findFirst({ + where: { + organisation: { + members: { + some: { + userId: userId, + }, + }, + }, + }, + }); + + if (!organisationClaim) { + throw new AppError(AppErrorCode.NOT_FOUND); + } + + return organisationClaim; +}; diff --git a/packages/lib/server-only/template/update-template.ts b/packages/lib/server-only/template/update-template.ts index 4485fdc66..a5ea8fa2d 100644 --- a/packages/lib/server-only/template/update-template.ts +++ b/packages/lib/server-only/template/update-template.ts @@ -4,8 +4,10 @@ import { prisma } from '@documenso/prisma'; import { AppError, AppErrorCode } from '../../errors/app-error'; import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth'; +import { DocumentAuth } from '../../types/document-auth'; import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth'; import { buildTeamWhereQuery } from '../../utils/teams'; +import { isUserEnterprise } from '../user/is-user-enterprise'; export type UpdateTemplateOptions = { userId: number; @@ -75,10 +77,21 @@ export const updateTemplate = async ({ data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth; // Check if user has permission to set the global action auth. - if (newGlobalActionAuth.length > 0 && !template.team.organisation.organisationClaim.flags.cfr21) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'You do not have permission to set the action auth', + // Only ACCOUNT and PASSKEY require enterprise permissions + if ( + newGlobalActionAuth && + (newGlobalActionAuth.includes(DocumentAuth.ACCOUNT) || + newGlobalActionAuth.includes(DocumentAuth.PASSKEY)) + ) { + const isDocumentEnterprise = await isUserEnterprise({ + userId, }); + + if (!isDocumentEnterprise) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to set this action auth type', + }); + } } const authOptions = createDocumentAuthOptions({ diff --git a/packages/lib/server-only/user/is-user-enterprise.ts b/packages/lib/server-only/user/is-user-enterprise.ts new file mode 100644 index 000000000..adcaa96d5 --- /dev/null +++ b/packages/lib/server-only/user/is-user-enterprise.ts @@ -0,0 +1,14 @@ +import { getOrganisationClaimByUserId } from '../organisation/get-organisation-claims'; + +/** + * Check if a user has enterprise features enabled (cfr21 flag). + */ +export const isUserEnterprise = async ({ userId }: { userId: number }): Promise => { + try { + const organisationClaim = await getOrganisationClaimByUserId({ userId }); + return Boolean(organisationClaim.flags.cfr21); + } catch { + // If we can't find the organisation claim, assume non-enterprise + return false; + } +}; diff --git a/packages/lib/types/document-auth.ts b/packages/lib/types/document-auth.ts index 493e14374..13cdf919a 100644 --- a/packages/lib/types/document-auth.ts +++ b/packages/lib/types/document-auth.ts @@ -82,6 +82,16 @@ export const ZDocumentActionAuthTypesSchema = z 'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.', ); +/** + * The non-enterprise document action auth methods. + * + * Only includes options available to non-enterprise users. + */ +export const ZNonEnterpriseDocumentActionAuthTypesSchema = z.enum([ + DocumentAuth.TWO_FACTOR_AUTH, + DocumentAuth.EXPLICIT_NONE, +]); + /** * The recipient access auth methods. * @@ -118,6 +128,7 @@ export const ZRecipientActionAuthTypesSchema = z export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum; export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum; +export const NonEnterpriseDocumentActionAuth = ZNonEnterpriseDocumentActionAuthTypesSchema.Enum; export const RecipientAccessAuth = ZRecipientAccessAuthTypesSchema.Enum; export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum; @@ -201,6 +212,9 @@ export type TDocumentAccessAuth = z.infer; export type TDocumentAccessAuthTypes = z.infer; export type TDocumentActionAuth = z.infer; export type TDocumentActionAuthTypes = z.infer; +export type TNonEnterpriseDocumentActionAuthTypes = z.infer< + typeof ZNonEnterpriseDocumentActionAuthTypesSchema +>; export type TRecipientAccessAuth = z.infer; export type TRecipientAccessAuthTypes = z.infer; export type TRecipientActionAuth = z.infer; diff --git a/packages/prisma/migrations/20250722134012_add_email_migration/migration.sql b/packages/prisma/migrations/20250722134012_add_email_migration/migration.sql new file mode 100644 index 000000000..0ccfc9012 --- /dev/null +++ b/packages/prisma/migrations/20250722134012_add_email_migration/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "UserTwoFactorEmailVerification" ( + "userId" INTEGER NOT NULL, + "verificationCode" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserTwoFactorEmailVerification_pkey" PRIMARY KEY ("userId") +); + +-- AddForeignKey +ALTER TABLE "UserTwoFactorEmailVerification" ADD CONSTRAINT "UserTwoFactorEmailVerification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index cc1fc031d..e6fbc5efe 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -59,9 +59,10 @@ model User { ownedOrganisations Organisation[] organisationMember OrganisationMember[] - twoFactorSecret String? - twoFactorEnabled Boolean @default(false) - twoFactorBackupCodes String? + twoFactorSecret String? + twoFactorEnabled Boolean @default(false) + twoFactorBackupCodes String? + twoFactorEmailVerification UserTwoFactorEmailVerification? folders Folder[] documents Document[] @@ -952,3 +953,12 @@ model AvatarImage { user User[] organisation Organisation[] } + +model UserTwoFactorEmailVerification { + userId Int @id + verificationCode String + expiresAt DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 2eb54d1e6..3ec381e7f 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -1,5 +1,10 @@ import type { RegistrationResponseJSON } from '@simplewebauthn/types'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { + sendEmailVerification, + verifyEmailCode, +} from '@documenso/lib/server-only/2fa/send-email-verification'; import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey'; import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options'; import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options'; @@ -8,6 +13,7 @@ import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey'; import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys'; import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey'; import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { @@ -15,7 +21,9 @@ import { ZCreatePasskeyMutationSchema, ZDeletePasskeyMutationSchema, ZFindPasskeysQuerySchema, + ZSendEmailVerificationMutationSchema, ZUpdatePasskeyMutationSchema, + ZVerifyEmailCodeMutationSchema, } from './schema'; export const authRouter = router({ @@ -110,4 +118,68 @@ export const authRouter = router({ requestMetadata: ctx.metadata.requestMetadata, }); }), + + // Email verification for document signing + sendEmailVerification: authenticatedProcedure + .input(ZSendEmailVerificationMutationSchema) + .mutation(async ({ ctx, input }) => { + const { recipientId } = input; + const userId = ctx.user.id; + let email = ctx.user.email; + + // If recipientId is provided, fetch that recipient's details + if (recipientId) { + const recipient = await prisma.recipient.findUnique({ + where: { + id: recipientId, + }, + select: { + email: true, + }, + }); + + if (!recipient) { + throw new AppError('NOT_FOUND', { + message: 'Recipient not found', + }); + } + + email = recipient.email; + } + + return sendEmailVerification({ + userId, + email, + }); + }), + + verifyEmailCode: authenticatedProcedure + .input(ZVerifyEmailCodeMutationSchema) + .mutation(async ({ ctx, input }) => { + const { code, recipientId } = input; + const userId = ctx.user.id; + + // If recipientId is provided, check that the user has access to it + if (recipientId) { + const recipient = await prisma.recipient.findUnique({ + where: { + id: recipientId, + }, + select: { + email: true, + }, + }); + + if (!recipient) { + throw new AppError('NOT_FOUND', { + message: 'Recipient not found', + }); + } + } + + return verifyEmailCode({ + userId, + code, + }); + }), }); diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index 55ea2167d..7e6d91027 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -71,3 +71,18 @@ export const ZFindPasskeysQuerySchema = ZFindSearchParamsSchema.extend({ }); export type TSignUpMutationSchema = z.infer; + +export const ZSendEmailVerificationMutationSchema = z.object({ + recipientId: z.number().optional(), +}); + +export type TSendEmailVerificationMutationSchema = z.infer< + typeof ZSendEmailVerificationMutationSchema +>; + +export const ZVerifyEmailCodeMutationSchema = z.object({ + code: z.string().min(6).max(6), + recipientId: z.number().optional(), +}); + +export type TVerifyEmailCodeMutationSchema = z.infer; diff --git a/packages/ui/components/document/document-global-auth-action-select.tsx b/packages/ui/components/document/document-global-auth-action-select.tsx index 35ab93dc8..3fb353838 100644 --- a/packages/ui/components/document/document-global-auth-action-select.tsx +++ b/packages/ui/components/document/document-global-auth-action-select.tsx @@ -4,7 +4,11 @@ import { Trans } from '@lingui/react/macro'; import { InfoIcon } from 'lucide-react'; import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; -import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth'; +import { + DocumentActionAuth, + DocumentAuth, + NonEnterpriseDocumentActionAuth, +} from '@documenso/lib/types/document-auth'; import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; @@ -14,6 +18,7 @@ export interface DocumentGlobalAuthActionSelectProps { onValueChange?: (value: string[]) => void; disabled?: boolean; placeholder?: string; + isDocumentEnterprise?: boolean; } export const DocumentGlobalAuthActionSelect = ({ @@ -22,21 +27,26 @@ export const DocumentGlobalAuthActionSelect = ({ onValueChange, disabled, placeholder, + isDocumentEnterprise, }: DocumentGlobalAuthActionSelectProps) => { const { _ } = useLingui(); + const authTypes = isDocumentEnterprise + ? Object.values(DocumentActionAuth).filter((auth) => auth !== DocumentAuth.ACCOUNT) + : Object.values(NonEnterpriseDocumentActionAuth).filter( + (auth) => auth !== DocumentAuth.EXPLICIT_NONE, + ); + // Convert auth types to MultiSelect options const authOptions: Option[] = [ { value: '-1', label: _(msg`No restrictions`), }, - ...Object.values(DocumentActionAuth) - .filter((auth) => auth !== DocumentAuth.ACCOUNT) - .map((authType) => ({ - value: authType, - label: DOCUMENT_AUTH_TYPES[authType].value, - })), + ...authTypes.map((authType) => ({ + value: authType, + label: DOCUMENT_AUTH_TYPES[authType].value, + })), ]; // Convert string array to Option array for MultiSelect diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index 3c06f9d1c..8d9285ed8 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -294,28 +294,27 @@ export const AddSettingsFormPartial = ({ /> )} - {organisation.organisationClaim.flags.cfr21 && ( - ( - - - Recipient action authentication - - + ( + + + Recipient action authentication + + - - - - - )} - /> - )} + + + + + )} + /> diff --git a/packages/ui/primitives/template-flow/add-template-settings.tsx b/packages/ui/primitives/template-flow/add-template-settings.tsx index 16e40f3d6..e90f9858c 100644 --- a/packages/ui/primitives/template-flow/add-template-settings.tsx +++ b/packages/ui/primitives/template-flow/add-template-settings.tsx @@ -371,28 +371,27 @@ export const AddTemplateSettingsFormPartial = ({ )} /> - {organisation.organisationClaim.flags.cfr21 && ( - ( - - - Recipient action authentication - - + ( + + + Recipient action authentication + + - - - - - )} - /> - )} + + + + + )} + /> {distributionMethod === DocumentDistributionMethod.EMAIL && (