Compare commits

...

9 Commits

26 changed files with 784 additions and 202 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
@ -6,9 +6,9 @@ import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; 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 { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog'; import { DialogFooter } from '@documenso/ui/primitives/dialog';
import { import {
@ -20,6 +20,8 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; 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'; import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
@ -51,6 +53,7 @@ export const DocumentSigningAuth2FA = ({
}: DocumentSigningAuth2FAProps) => { }: DocumentSigningAuth2FAProps) => {
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext(); useRequiredDocumentSigningAuthContext();
const { toast } = useToast();
const form = useForm<T2FAAuthFormSchema>({ const form = useForm<T2FAAuthFormSchema>({
resolver: zodResolver(Z2FAAuthFormSchema), resolver: zodResolver(Z2FAAuthFormSchema),
@ -60,27 +63,104 @@ export const DocumentSigningAuth2FA = ({
}); });
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false); const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
const [formErrorCode, setFormErrorCode] = useState<string | null>(null); const [isEmailCodeSent, setIsEmailCodeSent] = useState(false);
const [isEmailCodeSending, setIsEmailCodeSending] = useState(false);
const [canResendEmail, setCanResendEmail] = useState(true);
const [resendCountdown, setResendCountdown] = useState(0);
const countdownTimerRef = useRef<NodeJS.Timeout | null>(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) => { const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
try { try {
setIsCurrentlyAuthenticating(true); setIsCurrentlyAuthenticating(true);
if (verificationMethod === 'email') {
await verifyCodeMutation.mutateAsync({
code: token,
recipientId: recipient.id,
});
}
await onReauthFormSubmit({ await onReauthFormSubmit({
type: DocumentAuth.TWO_FACTOR_AUTH, type: DocumentAuth.TWO_FACTOR_AUTH,
token, token,
}); });
setIsCurrentlyAuthenticating(false); setIsCurrentlyAuthenticating(false);
onOpenChange(false); onOpenChange(false);
} catch (err) { } catch (err) {
setIsCurrentlyAuthenticating(false); setIsCurrentlyAuthenticating(false);
const error = AppError.parseError(err); toast({
setFormErrorCode(error.code); title: 'Unauthorized',
description: 'We were unable to verify your details.',
// Todo: Alert. variant: 'destructive',
});
} }
}; };
@ -90,21 +170,47 @@ export const DocumentSigningAuth2FA = ({
}); });
setIs2FASetupSuccessful(false); setIs2FASetupSuccessful(false);
setFormErrorCode(null); setIsEmailCodeSent(false);
// eslint-disable-next-line react-hooks/exhaustive-deps if (open && !user?.twoFactorEnabled) {
}, [open]); 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<Tabs
value={verificationMethod}
onValueChange={(val) => setVerificationMethod(val as 'app' | 'email')}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="app">Authenticator App</TabsTrigger>
<TabsTrigger value="email">Email Verification</TabsTrigger>
</TabsList>
</Tabs>
<Alert variant="warning"> <Alert variant="warning">
<AlertDescription> <AlertDescription>
<p> <p>
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? ( {recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? (
<Trans>You need to setup 2FA to mark this document as viewed.</Trans> <Trans>You need to setup 2FA to mark this document as viewed.</Trans>
) : ( ) : (
// Todo: Translate
`You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.` `You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`
)} )}
</p> </p>
@ -129,59 +235,106 @@ export const DocumentSigningAuth2FA = ({
} }
return ( return (
<Form {...form}> <div className="space-y-4">
<form onSubmit={form.handleSubmit(onFormSubmit)}> {user?.twoFactorEnabled && (
<fieldset disabled={isCurrentlyAuthenticating}> <Tabs
<div className="space-y-4"> value={verificationMethod}
<FormField onValueChange={(val) => setVerificationMethod(val as 'app' | 'email')}
control={form.control} >
name="token" <TabsList className="grid w-full grid-cols-2">
render={({ field }) => ( <TabsTrigger value="app">Authenticator App</TabsTrigger>
<FormItem> <TabsTrigger value="email">Email Verification</TabsTrigger>
<FormLabel required>2FA token</FormLabel> </TabsList>
</Tabs>
)}
<FormControl> {verificationMethod === 'email' && (
<PinInput {...field} value={field.value ?? ''} maxLength={6}> <Alert variant="secondary">
{Array(6) <AlertDescription>
.fill(null) {isEmailCodeSent ? (
.map((_, i) => ( <p>
<PinInputGroup key={i}> <Trans>
<PinInputSlot index={i} /> A verification code has been sent to {recipient.email}. Please enter it below to
</PinInputGroup> continue.
))} </Trans>
</PinInput> </p>
</FormControl> ) : (
<p>
<FormMessage /> <Trans>
</FormItem> We'll send a verification code to {recipient.email} to verify your identity.
)} </Trans>
/> </p>
{formErrorCode && (
<Alert variant="destructive">
<AlertTitle>
<Trans>Unauthorized</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
We were unable to verify your details. Please try again or contact support
</Trans>
</AlertDescription>
</Alert>
)} )}
</AlertDescription>
</Alert>
)}
<DialogFooter> <Form {...form}>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> <form onSubmit={form.handleSubmit(onFormSubmit)}>
<Trans>Cancel</Trans> <fieldset disabled={isCurrentlyAuthenticating}>
</Button> <div className="space-y-4">
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel required>
{verificationMethod === 'app' ? (
<Trans>2FA token</Trans>
) : (
<Trans>Verification code</Trans>
)}
</FormLabel>
<Button type="submit" loading={isCurrentlyAuthenticating}> <FormControl>
<Trans>Sign</Trans> <PinInput {...field} value={field.value ?? ''} maxLength={6}>
</Button> {Array(6)
</DialogFooter> .fill(null)
</div> .map((_, i) => (
</fieldset> <PinInputGroup key={i}>
</form> <PinInputSlot index={i} />
</Form> </PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{verificationMethod === 'email' && (
<div className="flex justify-center">
<Button
type="button"
variant="link"
disabled={isEmailCodeSending || !canResendEmail}
onClick={() => void sendEmailVerificationCode()}
>
{isEmailCodeSending ? (
<Trans>Sending...</Trans>
) : !canResendEmail ? (
<Trans>Resend code ({resendCountdown}s)</Trans>
) : (
<Trans>Resend code</Trans>
)}
</Button>
</div>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
<Trans>{actionTarget === 'DOCUMENT' ? 'Sign Document' : 'Sign Field'}</Trans>
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
</div>
); );
}; };

View File

@ -27,7 +27,6 @@ export type DocumentSigningAuthDialogProps = {
actionTarget: FieldType | 'DOCUMENT'; actionTarget: FieldType | 'DOCUMENT';
open: boolean; open: boolean;
onOpenChange: (value: boolean) => void; onOpenChange: (value: boolean) => void;
/** /**
* The callback to run when the reauth form is filled out. * The callback to run when the reauth form is filled out.
*/ */
@ -38,6 +37,7 @@ export const DocumentSigningAuthDialog = ({
title, title,
description, description,
documentAuthType, documentAuthType,
actionTarget,
open, open,
onOpenChange, onOpenChange,
onReauthFormSubmit, onReauthFormSubmit,
@ -56,10 +56,22 @@ export const DocumentSigningAuthDialog = ({
<Dialog open={open} onOpenChange={handleOnOpenChange}> <Dialog open={open} onOpenChange={handleOnOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{title || <Trans>Sign field</Trans>}</DialogTitle> <DialogTitle>
{title ||
(actionTarget === 'DOCUMENT' ? (
<Trans>Sign document</Trans>
) : (
<Trans>Sign field</Trans>
))}
</DialogTitle>
<DialogDescription> <DialogDescription>
{description || <Trans>Reauthentication is required to sign this field</Trans>} {description || (
<Trans>
Reauthentication is required to sign this{' '}
{actionTarget === 'DOCUMENT' ? 'document' : 'field'}
</Trans>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -78,6 +90,7 @@ export const DocumentSigningAuthDialog = ({
)) ))
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => ( .with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
<DocumentSigningAuth2FA <DocumentSigningAuth2FA
actionTarget={actionTarget === 'DOCUMENT' ? 'DOCUMENT' : 'FIELD'}
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit} onReauthFormSubmit={onReauthFormSubmit}

View File

@ -43,6 +43,7 @@ export type DocumentSigningAuthContextValue = {
setPreferredPasskeyId: (_value: string | null) => void; setPreferredPasskeyId: (_value: string | null) => void;
user?: SessionUser | null; user?: SessionUser | null;
refetchPasskeys: () => Promise<void>; refetchPasskeys: () => Promise<void>;
isEnterprise: boolean;
}; };
const DocumentSigningAuthContext = createContext<DocumentSigningAuthContextValue | null>(null); const DocumentSigningAuthContext = createContext<DocumentSigningAuthContextValue | null>(null);
@ -66,6 +67,7 @@ export interface DocumentSigningAuthProviderProps {
recipient: Recipient; recipient: Recipient;
user?: SessionUser | null; user?: SessionUser | null;
children: React.ReactNode; children: React.ReactNode;
isEnterprise: boolean;
} }
export const DocumentSigningAuthProvider = ({ export const DocumentSigningAuthProvider = ({
@ -73,6 +75,7 @@ export const DocumentSigningAuthProvider = ({
recipient: initialRecipient, recipient: initialRecipient,
user, user,
children, children,
isEnterprise,
}: DocumentSigningAuthProviderProps) => { }: DocumentSigningAuthProviderProps) => {
const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions); const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions);
const [recipient, setRecipient] = useState(initialRecipient); const [recipient, setRecipient] = useState(initialRecipient);
@ -138,8 +141,13 @@ export const DocumentSigningAuthProvider = ({
.exhaustive(); .exhaustive();
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
// Directly run callback if no auth required. // Determine if authentication is required based on enterprise status and action target.
if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) { 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(); await options.onReauthFormSubmit();
return; return;
} }
@ -198,6 +206,7 @@ export const DocumentSigningAuthProvider = ({
preferredPasskeyId, preferredPasskeyId,
setPreferredPasskeyId, setPreferredPasskeyId,
refetchPasskeys, refetchPasskeys,
isEnterprise,
}} }}
> >
{children} {children}
@ -218,6 +227,8 @@ export const DocumentSigningAuthProvider = ({
type ExecuteActionAuthProcedureOptions = Omit< type ExecuteActionAuthProcedureOptions = Omit<
DocumentSigningAuthDialogProps, DocumentSigningAuthDialogProps,
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' 'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole'
>; > & {
actionTarget: FieldType | 'DOCUMENT';
};
DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider'; DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider';

View File

@ -28,6 +28,7 @@ import {
AssistantConfirmationDialog, AssistantConfirmationDialog,
type NextSigner, type NextSigner,
} from '../../dialogs/assistant-confirmation-dialog'; } from '../../dialogs/assistant-confirmation-dialog';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { useRequiredDocumentSigningContext } from './document-signing-provider'; import { useRequiredDocumentSigningContext } from './document-signing-provider';
@ -39,6 +40,7 @@ export type DocumentSigningFormProps = {
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[]; allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void; setSelectedSignerId?: (id: number | null) => void;
isEnterprise: boolean;
}; };
export const DocumentSigningForm = ({ export const DocumentSigningForm = ({
@ -49,6 +51,7 @@ export const DocumentSigningForm = ({
isRecipientsTurn, isRecipientsTurn,
allRecipients = [], allRecipients = [],
setSelectedSignerId, setSelectedSignerId,
isEnterprise,
}: DocumentSigningFormProps) => { }: DocumentSigningFormProps) => {
const { sessionData } = useOptionalSession(); const { sessionData } = useOptionalSession();
const user = sessionData?.user; const user = sessionData?.user;
@ -62,6 +65,7 @@ export const DocumentSigningForm = ({
const assistantSignersId = useId(); const assistantSignersId = useId();
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext(); const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false); const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
@ -114,11 +118,16 @@ export const DocumentSigningForm = ({
setIsAssistantSubmitting(true); setIsAssistantSubmitting(true);
try { try {
await completeDocument(undefined, nextSigner); await executeActionAuthProcedure({
actionTarget: 'DOCUMENT',
onReauthFormSubmit: async (authOptions) => {
await completeDocument(authOptions, nextSigner);
},
});
} catch (err) { } catch (err) {
toast({ toast({
title: 'Error', title: _(msg`Error`),
description: 'An error occurred while completing the document. Please try again.', description: _(msg`An error occurred while completing the document. Please try again.`),
variant: 'destructive', variant: 'destructive',
}); });
@ -229,7 +238,12 @@ export const DocumentSigningForm = ({
fields={fields} fields={fields}
fieldsValidated={fieldsValidated} fieldsValidated={fieldsValidated}
onSignatureComplete={async (nextSigner) => { onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner); await executeActionAuthProcedure({
actionTarget: 'DOCUMENT',
onReauthFormSubmit: async (authOptions) => {
await completeDocument(authOptions, nextSigner);
},
});
}} }}
role={recipient.role} role={recipient.role}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner} allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
@ -409,7 +423,12 @@ export const DocumentSigningForm = ({
fieldsValidated={fieldsValidated} fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn} disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => { onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner); await executeActionAuthProcedure({
actionTarget: 'DOCUMENT',
onReauthFormSubmit: async (authOptions) => {
await completeDocument(authOptions, nextSigner);
},
});
}} }}
role={recipient.role} role={recipient.role}
allowDictateNextSigner={ allowDictateNextSigner={

View File

@ -47,6 +47,7 @@ export type DocumentSigningPageViewProps = {
completedFields: CompletedField[]; completedFields: CompletedField[];
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[]; allRecipients?: RecipientWithFields[];
isEnterprise: boolean;
}; };
export const DocumentSigningPageView = ({ export const DocumentSigningPageView = ({
@ -56,6 +57,7 @@ export const DocumentSigningPageView = ({
completedFields, completedFields,
isRecipientsTurn, isRecipientsTurn,
allRecipients = [], allRecipients = [],
isEnterprise,
}: DocumentSigningPageViewProps) => { }: DocumentSigningPageViewProps) => {
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
@ -153,6 +155,7 @@ export const DocumentSigningPageView = ({
isRecipientsTurn={isRecipientsTurn} isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients} allRecipients={allRecipients}
setSelectedSignerId={setSelectedSignerId} setSelectedSignerId={setSelectedSignerId}
isEnterprise={isEnterprise}
/> />
</div> </div>
</div> </div>

View File

@ -152,7 +152,7 @@ export const TemplateEditForm = ({
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }

View File

@ -94,6 +94,7 @@ export default function DirectTemplatePage() {
documentAuthOptions={template.authOptions} documentAuthOptions={template.authOptions}
recipient={directTemplateRecipient} recipient={directTemplateRecipient}
user={user} user={user}
isEnterprise={false}
> >
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8"> <div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<h1 <h1

View File

@ -6,6 +6,7 @@ import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized'; import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
@ -60,6 +61,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 }); throw new Response('Not Found', { status: 404 });
} }
const isEnterprise = user?.id
? await isUserEnterprise({ userId: user.id }).catch(() => false)
: false;
const recipientWithFields = { ...recipient, fields }; const recipientWithFields = { ...recipient, fields };
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token }); const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
@ -115,6 +120,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
isDocumentAccessValid: false, isDocumentAccessValid: false,
recipientEmail: recipient.email, recipientEmail: recipient.email,
recipientHasAccount, recipientHasAccount,
isEnterprise,
} as const); } as const);
} }
@ -149,6 +155,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
completedFields, completedFields,
recipientSignature, recipientSignature,
isRecipientsTurn, isRecipientsTurn,
isEnterprise,
} as const); } as const);
} }
@ -176,6 +183,7 @@ export default function SigningPage() {
isRecipientsTurn, isRecipientsTurn,
allRecipients, allRecipients,
recipientWithFields, recipientWithFields,
isEnterprise,
} = data; } = data;
if (document.deletedAt || document.status === DocumentStatus.REJECTED) { if (document.deletedAt || document.status === DocumentStatus.REJECTED) {
@ -241,6 +249,7 @@ export default function SigningPage() {
documentAuthOptions={document.authOptions} documentAuthOptions={document.authOptions}
recipient={recipient} recipient={recipient}
user={user} user={user}
isEnterprise={isEnterprise}
> >
<DocumentSigningPageView <DocumentSigningPageView
recipient={recipientWithFields} recipient={recipientWithFields}
@ -249,6 +258,7 @@ export default function SigningPage() {
completedFields={completedFields} completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn} isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients} allRecipients={allRecipients}
isEnterprise={isEnterprise}
/> />
</DocumentSigningAuthProvider> </DocumentSigningAuthProvider>
</DocumentSigningProvider> </DocumentSigningProvider>

View File

@ -143,6 +143,7 @@ export default function EmbedDirectTemplatePage() {
documentAuthOptions={template.authOptions} documentAuthOptions={template.authOptions}
recipient={recipient} recipient={recipient}
user={user} user={user}
isEnterprise={isEnterpriseDocument}
> >
<DocumentSigningRecipientProvider recipient={recipient}> <DocumentSigningRecipientProvider recipient={recipient}>
<EmbedDirectTemplateClientPage <EmbedDirectTemplateClientPage

View File

@ -168,6 +168,7 @@ export default function EmbedSignDocumentPage() {
documentAuthOptions={document.authOptions} documentAuthOptions={document.authOptions}
recipient={recipient} recipient={recipient}
user={user} user={user}
isEnterprise={isEnterpriseDocument}
> >
<EmbedSignDocumentClientPage <EmbedSignDocumentClientPage
token={token} token={token}

View File

@ -116,15 +116,15 @@ test.describe('[EE_ONLY]', () => {
redirectPath: `/documents/${document.id}/edit`, redirectPath: `/documents/${document.id}/edit`,
}); });
// Global action auth should not be visible. // Global action auth should now be visible for all users
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible(); await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
// Next step. // Next step.
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Advanced settings should not be visible. // Advanced settings should now be visible for all users
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible(); await expect(page.getByLabel('Show advanced settings')).toBeVisible();
}); });
}); });
@ -146,8 +146,8 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
await page.getByLabel('Require account').getByText('Require account').click(); await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible. // Action auth should now be visible for all users
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible(); await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
// Save the settings by going to the next step. // Save the settings by going to the next step.

View File

@ -113,8 +113,8 @@ test.describe('[EE_ONLY]', () => {
redirectPath: `/templates/${template.id}/edit`, redirectPath: `/templates/${template.id}/edit`,
}); });
// Global action auth should not be visible. // Global action auth should now be visible for all users
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible(); await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
// Next step. // Next step.
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@ -143,8 +143,8 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
await page.getByLabel('Require account').getByText('Require account').click(); await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible. // Action auth should now be visible for all users
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible(); await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
// Save the settings by going to the next step. // Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();

View File

@ -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 (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
<Trans>Your verification code</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>Please use the code below to verify your identity for document signing.</Trans>
</Text>
<Text className="my-6 text-center text-3xl font-bold tracking-widest">
{verificationCode}
</Text>
<Text className="my-1 text-center text-sm text-slate-400">
<Trans>
If you did not request this code, you can ignore this email. The code will expire after
10 minutes.
</Trans>
</Text>
</Section>
</>
);
};
export default TemplateVerificationCode;

View File

@ -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<TemplateVerificationCodeProps>;
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 (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
<Section className="p-2">
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
) : (
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
)}
<TemplateVerificationCode
verificationCode={verificationCode}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default VerificationCodeTemplate;

View File

@ -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);
}
};

View File

@ -1,8 +1,6 @@
import { DocumentVisibility } from '@prisma/client'; import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs'; import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
@ -117,7 +115,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) { if (!data || Object.values(data).length === 0) {
console.log('no data'); console.log('no data');
return document; return document;
@ -130,26 +127,11 @@ export const updateDocument = async ({
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null; const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null; const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
// If the new global auth values aren't passed in, fallback to the current document values.
const newGlobalAccessAuth = const newGlobalAccessAuth =
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth; data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
const newGlobalActionAuth = const newGlobalActionAuth =
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth; data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const isTitleSame = data.title === undefined || data.title === document.title; const isTitleSame = data.title === undefined || data.title === document.title;
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId; const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
const isGlobalAccessSame = const isGlobalAccessSame =

View File

@ -2,6 +2,7 @@ import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox'; import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown'; import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number'; import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
@ -13,7 +14,7 @@ import { prisma } from '@documenso/prisma';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; 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 { import {
ZCheckboxFieldMeta, ZCheckboxFieldMeta,
ZDropdownFieldMeta, ZDropdownFieldMeta,
@ -23,6 +24,7 @@ import {
} from '../../types/field-meta'; } from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { validateFieldAuth } from '../document/validate-field-auth'; import { validateFieldAuth } from '../document/validate-field-auth';
export type SignFieldWithTokenOptions = { export type SignFieldWithTokenOptions = {
@ -169,13 +171,24 @@ export const signFieldWithToken = async ({
} }
} }
const derivedRecipientActionAuth = await validateFieldAuth({ const isEnterprise = userId ? await isUserEnterprise({ userId }) : false;
documentAuthOptions: document.authOptions, let requiredAuthType: TRecipientActionAuthTypes | null = null;
recipient,
field, if (isEnterprise) {
userId, requiredAuthType = await validateFieldAuth({
authOptions, documentAuthOptions: document.authOptions,
}); recipient,
field,
userId,
authOptions,
});
} else {
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
requiredAuthType = derivedRecipientActionAuth;
}
const documentMeta = await prisma.documentMeta.findFirst({ const documentMeta = await prisma.documentMeta.findFirst({
where: { where: {
@ -286,9 +299,9 @@ export const signFieldWithToken = async ({
}), }),
) )
.exhaustive(), .exhaustive(),
fieldSecurity: derivedRecipientActionAuth fieldSecurity: requiredAuthType
? { ? {
type: derivedRecipientActionAuth, type: requiredAuthType,
} }
: undefined, : undefined,
}, },

View File

@ -5,6 +5,7 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth'; import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth'; import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
export type UpdateTemplateOptions = { export type UpdateTemplateOptions = {
@ -74,7 +75,11 @@ export const updateTemplate = async ({
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth; data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth. // Check if user has permission to set the global action auth.
if (newGlobalActionAuth) { // Only ACCOUNT and PASSKEY require enterprise permissions
if (
newGlobalActionAuth &&
(newGlobalActionAuth === DocumentAuth.ACCOUNT || newGlobalActionAuth === DocumentAuth.PASSKEY)
) {
const isDocumentEnterprise = await isUserEnterprise({ const isDocumentEnterprise = await isUserEnterprise({
userId, userId,
teamId, teamId,
@ -82,7 +87,7 @@ export const updateTemplate = async ({
if (!isDocumentEnterprise) { if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth', message: 'You do not have permission to set this action auth type',
}); });
} }
} }

View File

@ -68,6 +68,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 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. * The recipient access auth methods.
* *
@ -102,6 +112,7 @@ export const ZRecipientActionAuthTypesSchema = z
export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum; export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum;
export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum; export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum;
export const NonEnterpriseDocumentActionAuth = ZNonEnterpriseDocumentActionAuthTypesSchema.Enum;
export const RecipientAccessAuth = ZRecipientAccessAuthTypesSchema.Enum; export const RecipientAccessAuth = ZRecipientAccessAuthTypesSchema.Enum;
export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum; export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum;
@ -152,6 +163,9 @@ export type TDocumentAccessAuth = z.infer<typeof ZDocumentAccessAuthSchema>;
export type TDocumentAccessAuthTypes = z.infer<typeof ZDocumentAccessAuthTypesSchema>; export type TDocumentAccessAuthTypes = z.infer<typeof ZDocumentAccessAuthTypesSchema>;
export type TDocumentActionAuth = z.infer<typeof ZDocumentActionAuthSchema>; export type TDocumentActionAuth = z.infer<typeof ZDocumentActionAuthSchema>;
export type TDocumentActionAuthTypes = z.infer<typeof ZDocumentActionAuthTypesSchema>; export type TDocumentActionAuthTypes = z.infer<typeof ZDocumentActionAuthTypesSchema>;
export type TNonEnterpriseDocumentActionAuthTypes = z.infer<
typeof ZNonEnterpriseDocumentActionAuthTypesSchema
>;
export type TRecipientAccessAuth = z.infer<typeof ZRecipientAccessAuthSchema>; export type TRecipientAccessAuth = z.infer<typeof ZRecipientAccessAuthSchema>;
export type TRecipientAccessAuthTypes = z.infer<typeof ZRecipientAccessAuthTypesSchema>; export type TRecipientAccessAuthTypes = z.infer<typeof ZRecipientAccessAuthTypesSchema>;
export type TRecipientActionAuth = z.infer<typeof ZRecipientActionAuthSchema>; export type TRecipientActionAuth = z.infer<typeof ZRecipientActionAuthSchema>;

View File

@ -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;

View File

@ -53,19 +53,20 @@ model User {
avatarImageId String? avatarImageId String?
disabled Boolean @default(false) disabled Boolean @default(false)
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
documents Document[] documents Document[]
folders Folder[] folders Folder[]
subscriptions Subscription[] subscriptions Subscription[]
passwordResetTokens PasswordResetToken[] passwordResetTokens PasswordResetToken[]
ownedTeams Team[] ownedTeams Team[]
ownedPendingTeams TeamPending[] ownedPendingTeams TeamPending[]
teamMembers TeamMember[] teamMembers TeamMember[]
twoFactorSecret String? twoFactorSecret String?
twoFactorEnabled Boolean @default(false) twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String? twoFactorBackupCodes String?
url String? @unique url String? @unique
twoFactorEmailVerification UserTwoFactorEmailVerification?
profile UserProfile? profile UserProfile?
verificationTokens VerificationToken[] verificationTokens VerificationToken[]
@ -839,3 +840,12 @@ model AvatarImage {
team Team[] team Team[]
user User[] user User[]
} }
model UserTwoFactorEmailVerification {
userId Int @id
verificationCode String
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

View File

@ -1,5 +1,10 @@
import type { RegistrationResponseJSON } from '@simplewebauthn/types'; 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 { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options'; import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-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 { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey'; import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure, procedure, router } from '../trpc'; import { authenticatedProcedure, procedure, router } from '../trpc';
import { import {
@ -15,7 +21,9 @@ import {
ZCreatePasskeyMutationSchema, ZCreatePasskeyMutationSchema,
ZDeletePasskeyMutationSchema, ZDeletePasskeyMutationSchema,
ZFindPasskeysQuerySchema, ZFindPasskeysQuerySchema,
ZSendEmailVerificationMutationSchema,
ZUpdatePasskeyMutationSchema, ZUpdatePasskeyMutationSchema,
ZVerifyEmailCodeMutationSchema,
} from './schema'; } from './schema';
export const authRouter = router({ export const authRouter = router({
@ -98,4 +106,68 @@ export const authRouter = router({
requestMetadata: ctx.metadata.requestMetadata, 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,
});
}),
}); });

View File

@ -71,3 +71,18 @@ export const ZFindPasskeysQuerySchema = ZFindSearchParamsSchema.extend({
}); });
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>; export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
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<typeof ZVerifyEmailCodeMutationSchema>;

View File

@ -7,7 +7,11 @@ import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react'; import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; 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 { import {
Select, Select,
SelectContent, SelectContent,
@ -17,38 +21,47 @@ import {
} from '@documenso/ui/primitives/select'; } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentGlobalAuthActionSelect = forwardRef<HTMLButtonElement, SelectProps>( interface DocumentGlobalAuthActionSelectProps extends SelectProps {
(props, ref) => { isDocumentEnterprise?: boolean;
const { _ } = useLingui(); }
return ( export const DocumentGlobalAuthActionSelect = forwardRef<
<Select {...props}> HTMLButtonElement,
<SelectTrigger className="bg-background text-muted-foreground"> DocumentGlobalAuthActionSelectProps
<SelectValue >(({ isDocumentEnterprise, ...props }, ref) => {
ref={ref} const { _ } = useLingui();
data-testid="documentActionSelectValue"
placeholder={_(msg`No restrictions`)}
/>
</SelectTrigger>
<SelectContent position="popper"> const authTypes = isDocumentEnterprise
{/* Note: -1 is remapped in the Zod schema to the required value. */} ? Object.values(DocumentActionAuth).filter((auth) => auth !== DocumentAuth.ACCOUNT)
<SelectItem value={'-1'}> : Object.values(NonEnterpriseDocumentActionAuth).filter(
<Trans>No restrictions</Trans> (auth) => auth !== DocumentAuth.EXPLICIT_NONE,
);
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue
ref={ref}
data-testid="documentActionSelectValue"
placeholder={_(msg`No restrictions`)}
/>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>
<Trans>No restrictions</Trans>
</SelectItem>
{authTypes.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem> </SelectItem>
))}
{Object.values(DocumentActionAuth) </SelectContent>
.filter((auth) => auth !== DocumentAuth.ACCOUNT) </Select>
.map((authType) => ( );
<SelectItem key={authType} value={authType}> });
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
);
DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect'; DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect';

View File

@ -1,10 +1,15 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { useLingui } from '@lingui/react/macro'; import {
import { DocumentVisibility, TeamMemberRole } from '@prisma/client'; DocumentStatus,
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@prisma/client'; DocumentVisibility,
type Field,
type Recipient,
SendStatus,
TeamMemberRole,
} from '@prisma/client';
import { InfoIcon } from 'lucide-react'; import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -274,24 +279,22 @@ export const AddSettingsFormPartial = ({
/> />
)} )}
{isDocumentEnterprise && ( <FormField
<FormField control={form.control}
control={form.control} name="globalActionAuth"
name="globalActionAuth" render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <FormLabel className="flex flex-row items-center">
<FormLabel className="flex flex-row items-center"> <Trans>Recipient action authentication</Trans>
<Trans>Recipient action authentication</Trans> <DocumentGlobalAuthActionTooltip />
<DocumentGlobalAuthActionTooltip /> </FormLabel>
</FormLabel>
<FormControl> <FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} /> <DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
)}
<Accordion type="multiple" className="mt-6"> <Accordion type="multiple" className="mt-6">
<AccordionItem value="advanced-options" className="border-none"> <AccordionItem value="advanced-options" className="border-none">

View File

@ -1,10 +1,14 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro'; import {
import { DocumentVisibility, TeamMemberRole } from '@prisma/client'; DocumentDistributionMethod,
import { DocumentDistributionMethod, type Field, type Recipient } from '@prisma/client'; DocumentVisibility,
type Field,
type Recipient,
TeamMemberRole,
} from '@prisma/client';
import { InfoIcon } from 'lucide-react'; import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -366,24 +370,26 @@ export const AddTemplateSettingsFormPartial = ({
)} )}
/> />
{isEnterprise && ( <FormField
<FormField control={form.control}
control={form.control} name="globalActionAuth"
name="globalActionAuth" render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <FormLabel className="flex flex-row items-center">
<FormLabel className="flex flex-row items-center"> <Trans>Recipient action authentication</Trans>
<Trans>Recipient action authentication</Trans> <DocumentGlobalAuthActionTooltip />
<DocumentGlobalAuthActionTooltip /> </FormLabel>
</FormLabel>
<FormControl> <FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} /> <DocumentGlobalAuthActionSelect
</FormControl> {...field}
</FormItem> onValueChange={field.onChange}
)} isDocumentEnterprise={isEnterprise}
/> />
)} </FormControl>
</FormItem>
)}
/>
{distributionMethod === DocumentDistributionMethod.EMAIL && ( {distributionMethod === DocumentDistributionMethod.EMAIL && (
<Accordion type="multiple"> <Accordion type="multiple">