Compare commits

...

4 Commits

30 changed files with 831 additions and 196 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,46 @@ 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 +234,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

@ -43,6 +43,7 @@ export const DocumentSigningAuthDialog = ({
title, title,
description, description,
availableAuthTypes, availableAuthTypes,
actionTarget,
open, open,
onOpenChange, onOpenChange,
onReauthFormSubmit, onReauthFormSubmit,
@ -107,15 +108,32 @@ export const DocumentSigningAuthDialog = ({
> >
<ChevronLeftIcon className="h-4 w-4" /> <ChevronLeftIcon className="h-4 w-4" />
</Button> </Button>
<span>{title || <Trans>Sign field</Trans>}</span> <span>
{title ||
(actionTarget === 'DOCUMENT' ? (
<Trans>Sign document</Trans>
) : (
<Trans>Sign field</Trans>
))}
</span>
</div> </div>
)} )}
{(!selectedAuthType || validAuthTypes.length === 1) && {(!selectedAuthType || validAuthTypes.length === 1) &&
(title || <Trans>Sign field</Trans>)} (title ||
(actionTarget === 'DOCUMENT' ? (
<Trans>Sign document</Trans>
) : (
<Trans>Sign field</Trans>
)))}
</DialogTitle> </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>
@ -180,6 +198,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

@ -42,6 +42,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);
@ -65,6 +66,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 = ({
@ -72,6 +74,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);
@ -144,8 +147,13 @@ export const DocumentSigningAuthProvider = ({
}, [derivedRecipientActionAuth, user, recipient]); }, [derivedRecipientActionAuth, user, recipient]);
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;
} }
@ -205,6 +213,7 @@ export const DocumentSigningAuthProvider = ({
preferredPasskeyId, preferredPasskeyId,
setPreferredPasskeyId, setPreferredPasskeyId,
refetchPasskeys, refetchPasskeys,
isEnterprise,
}} }}
> >
{children} {children}
@ -225,6 +234,8 @@ export const DocumentSigningAuthProvider = ({
type ExecuteActionAuthProcedureOptions = Omit< type ExecuteActionAuthProcedureOptions = Omit<
DocumentSigningAuthDialogProps, DocumentSigningAuthDialogProps,
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' | 'availableAuthTypes' 'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' | 'availableAuthTypes'
>; > & {
actionTarget: FieldType | 'DOCUMENT';
};
DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider'; DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider';

View File

@ -27,6 +27,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';
@ -38,6 +39,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 = ({
@ -48,6 +50,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;
@ -61,6 +64,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);
@ -113,11 +117,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',
}); });
@ -207,7 +216,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}
@ -367,7 +381,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

@ -50,6 +50,7 @@ export type DocumentSigningPageViewProps = {
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[]; allRecipients?: RecipientWithFields[];
includeSenderDetails: boolean; includeSenderDetails: boolean;
isEnterprise: boolean;
}; };
export const DocumentSigningPageView = ({ export const DocumentSigningPageView = ({
@ -60,6 +61,7 @@ export const DocumentSigningPageView = ({
isRecipientsTurn, isRecipientsTurn,
allRecipients = [], allRecipients = [],
includeSenderDetails, includeSenderDetails,
isEnterprise,
}: DocumentSigningPageViewProps) => { }: DocumentSigningPageViewProps) => {
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
@ -208,6 +210,7 @@ export const DocumentSigningPageView = ({
isRecipientsTurn={isRecipientsTurn} isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients} allRecipients={allRecipients}
setSelectedSignerId={setSelectedSignerId} setSelectedSignerId={setSelectedSignerId}
isEnterprise={isEnterprise}
/> />
</div> </div>
</div> </div>

View File

@ -157,7 +157,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

@ -9,10 +9,10 @@ import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { FolderGrid } from '~/components/general/folder/folder-grid'; import { FolderGrid } from '~/components/general/folder/folder-grid';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
import { TemplatesTable } from '~/components/tables/templates-table'; import { TemplatesTable } from '~/components/tables/templates-table';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
export function meta() { export function meta() {
return appMetaTags('Templates'); return appMetaTags('Templates');

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

@ -19,6 +19,7 @@ import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant'; import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings'; import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { isUserEnterprise } from '@documenso/lib/server-only/user/is-user-enterprise';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { SigningCard3D } from '@documenso/ui/components/signing-card';
@ -41,6 +42,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 [document, recipient, fields, completedFields] = await Promise.all([ const [document, recipient, fields, completedFields] = await Promise.all([
getDocumentAndSenderByToken({ getDocumentAndSenderByToken({
token, token,
@ -116,6 +121,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);
} }
@ -153,6 +159,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
recipientSignature, recipientSignature,
isRecipientsTurn, isRecipientsTurn,
includeSenderDetails: settings.includeSenderDetails, includeSenderDetails: settings.includeSenderDetails,
isEnterprise,
} as const); } as const);
} }
@ -181,6 +188,7 @@ export default function SigningPage() {
allRecipients, allRecipients,
includeSenderDetails, includeSenderDetails,
recipientWithFields, recipientWithFields,
isEnterprise,
} = data; } = data;
if (document.deletedAt || document.status === DocumentStatus.REJECTED) { if (document.deletedAt || document.status === DocumentStatus.REJECTED) {
@ -246,6 +254,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}
@ -255,6 +264,7 @@ export default function SigningPage() {
isRecipientsTurn={isRecipientsTurn} isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients} allRecipients={allRecipients}
includeSenderDetails={includeSenderDetails} includeSenderDetails={includeSenderDetails}
isEnterprise={isEnterprise}
/> />
</DocumentSigningAuthProvider> </DocumentSigningAuthProvider>
</DocumentSigningProvider> </DocumentSigningProvider>

View File

@ -88,6 +88,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId); const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
const isEnterpriseDocument = Boolean(organisationClaim);
return superLoaderJson({ return superLoaderJson({
token, token,
user, user,
@ -96,12 +98,21 @@ export async function loader({ params, request }: Route.LoaderArgs) {
fields, fields,
hidePoweredBy, hidePoweredBy,
allowEmbedSigningWhitelabel, allowEmbedSigningWhitelabel,
isEnterpriseDocument,
}); });
} }
export default function EmbedDirectTemplatePage() { export default function EmbedDirectTemplatePage() {
const { token, user, template, recipient, fields, hidePoweredBy, allowEmbedSigningWhitelabel } = const {
useSuperLoaderData<typeof loader>(); token,
user,
template,
recipient,
fields,
hidePoweredBy,
allowEmbedSigningWhitelabel,
isEnterpriseDocument,
} = useSuperLoaderData<typeof loader>();
return ( return (
<DocumentSigningProvider <DocumentSigningProvider
@ -116,6 +127,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

@ -109,6 +109,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
}) })
: []; : [];
const isEnterpriseDocument = Boolean(organisationClaim);
return superLoaderJson({ return superLoaderJson({
token, token,
user, user,
@ -119,6 +121,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
completedFields, completedFields,
hidePoweredBy, hidePoweredBy,
allowEmbedSigningWhitelabel, allowEmbedSigningWhitelabel,
isEnterpriseDocument,
}); });
} }
@ -133,6 +136,7 @@ export default function EmbedSignDocumentPage() {
completedFields, completedFields,
hidePoweredBy, hidePoweredBy,
allowEmbedSigningWhitelabel, allowEmbedSigningWhitelabel,
isEnterpriseDocument,
} = useSuperLoaderData<typeof loader>(); } = useSuperLoaderData<typeof loader>();
return ( return (
@ -148,6 +152,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

@ -48,6 +48,7 @@ export async function loader({ request }: Route.LoaderArgs) {
user, user,
hidePoweredBy: false, hidePoweredBy: false,
allowWhitelabelling: false, allowWhitelabelling: false,
isEnterprise: false,
}); });
} }
@ -55,17 +56,19 @@ export async function loader({ request }: Route.LoaderArgs) {
const allowWhitelabelling = organisationClaim.flags.embedSigningWhiteLabel; const allowWhitelabelling = organisationClaim.flags.embedSigningWhiteLabel;
const hidePoweredBy = organisationClaim.flags.hidePoweredBy; const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
const isEnterprise = Boolean(organisationClaim.flags.cfr21);
return superLoaderJson({ return superLoaderJson({
envelopes, envelopes,
user, user,
hidePoweredBy, hidePoweredBy,
allowWhitelabelling, allowWhitelabelling,
isEnterprise,
}); });
} }
export default function MultisignPage() { export default function MultisignPage() {
const { envelopes, user, hidePoweredBy, allowWhitelabelling } = const { envelopes, user, hidePoweredBy, allowWhitelabelling, isEnterprise } =
useSuperLoaderData<typeof loader>(); useSuperLoaderData<typeof loader>();
const revalidator = useRevalidator(); const revalidator = useRevalidator();
@ -264,6 +267,7 @@ export default function MultisignPage() {
documentAuthOptions={selectedDocument.authOptions} documentAuthOptions={selectedDocument.authOptions}
recipient={selectedRecipient} recipient={selectedRecipient}
user={user} user={user}
isEnterprise={isEnterprise}
> >
<DocumentSigningRecipientProvider recipient={selectedRecipient} targetSigner={null}> <DocumentSigningRecipientProvider recipient={selectedRecipient} targetSigner={null}>
<MultiSignDocumentSigningView <MultiSignDocumentSigningView

View File

@ -27,8 +27,8 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
await page.getByRole('option').filter({ hasText: 'Require account' }).click(); await page.getByRole('option').filter({ hasText: '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

@ -25,8 +25,8 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
await page.getByRole('option').filter({ hasText: 'Require account' }).click(); await page.getByRole('option').filter({ hasText: '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,5 +1,4 @@
import { DocumentVisibility } from '@prisma/client'; import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { isDeepEqual } from 'remeda'; import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -119,13 +118,6 @@ export const updateDocument = async ({
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.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 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

@ -15,7 +15,7 @@ import { AUTO_SIGNABLE_FIELD_TYPES } from '../../constants/autosign';
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,
@ -25,7 +25,9 @@ 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';
import { isUserEnterprise } from '../user/is-user-enterprise';
export type SignFieldWithTokenOptions = { export type SignFieldWithTokenOptions = {
token: string; token: string;
@ -171,13 +173,25 @@ 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, const authType = await validateFieldAuth({
authOptions, 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({ const documentMeta = await prisma.documentMeta.findFirst({
where: { where: {
@ -311,9 +325,9 @@ export const signFieldWithToken = async ({
}), }),
) )
.exhaustive(), .exhaustive(),
fieldSecurity: derivedRecipientActionAuth fieldSecurity: requiredAuthType
? { ? {
type: derivedRecipientActionAuth, type: requiredAuthType,
} }
: undefined, : undefined,
}, },

View File

@ -37,3 +37,23 @@ export const getOrganisationClaimByTeamId = async ({ teamId }: { teamId: number
return organisationClaim; 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;
};

View File

@ -4,8 +4,10 @@ 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';
import { buildTeamWhereQuery } from '../../utils/teams'; import { buildTeamWhereQuery } from '../../utils/teams';
import { isUserEnterprise } from '../user/is-user-enterprise';
export type UpdateTemplateOptions = { export type UpdateTemplateOptions = {
userId: number; userId: number;
@ -76,10 +78,21 @@ 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.length > 0 && !template.team.organisation.organisationClaim.flags.cfr21) { // Only ACCOUNT and PASSKEY require enterprise permissions
throw new AppError(AppErrorCode.UNAUTHORIZED, { if (
message: 'You do not have permission to set the action auth', 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({ const authOptions = createDocumentAuthOptions({

View File

@ -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<boolean> => {
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;
}
};

View File

@ -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 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.
* *
@ -118,6 +128,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;
@ -201,6 +212,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

@ -59,9 +59,10 @@ model User {
ownedOrganisations Organisation[] ownedOrganisations Organisation[]
organisationMember OrganisationMember[] organisationMember OrganisationMember[]
twoFactorSecret String? twoFactorSecret String?
twoFactorEnabled Boolean @default(false) twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String? twoFactorBackupCodes String?
twoFactorEmailVerification UserTwoFactorEmailVerification?
folders Folder[] folders Folder[]
documents Document[] documents Document[]
@ -985,6 +986,15 @@ model AvatarImage {
organisation Organisation[] organisation Organisation[]
} }
model UserTwoFactorEmailVerification {
userId Int @id
verificationCode String
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum EmailDomainStatus { enum EmailDomainStatus {
PENDING PENDING
ACTIVE ACTIVE

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({
@ -110,4 +118,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

@ -4,70 +4,70 @@ import { Trans } from '@lingui/react/macro';
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 {
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect'; DocumentActionAuth,
DocumentAuth,
NonEnterpriseDocumentActionAuth,
} from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export interface DocumentGlobalAuthActionSelectProps { export interface DocumentGlobalAuthActionSelectProps {
value?: string[]; value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void; onValueChange?: (value: string[]) => void;
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
isDocumentEnterprise?: boolean;
} }
export const DocumentGlobalAuthActionSelect = ({ export const DocumentGlobalAuthActionSelect = ({
value, value,
defaultValue,
onValueChange, onValueChange,
disabled, disabled,
placeholder, placeholder,
isDocumentEnterprise,
}: DocumentGlobalAuthActionSelectProps) => { }: DocumentGlobalAuthActionSelectProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
// Convert auth types to MultiSelect options const authTypes = isDocumentEnterprise
const authOptions: Option[] = [ ? Object.values(DocumentActionAuth).filter((auth) => auth !== DocumentAuth.ACCOUNT)
{ : Object.values(NonEnterpriseDocumentActionAuth).filter(
value: '-1', (auth) => auth !== DocumentAuth.EXPLICIT_NONE,
label: _(msg`No restrictions`), );
},
...Object.values(DocumentActionAuth)
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
.map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
// Convert string array to Option array for MultiSelect const selectedValue = value?.[0] || '';
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
// Convert default value to Option array const handleChange = (newValue: string) => {
const defaultOptions = if (newValue === '-1') {
(defaultValue onValueChange?.([]);
?.map((val) => authOptions.find((option) => option.value === val)) } else {
.filter(Boolean) as Option[]) || []; onValueChange?.([newValue]);
}
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
}; };
return ( return (
<MultiSelect <Select value={selectedValue || '-1'} onValueChange={handleChange} disabled={disabled}>
value={selectedOptions} <SelectTrigger
defaultOptions={defaultOptions} className="bg-background text-muted-foreground"
options={authOptions} data-testid="documentActionSelectValue"
onChange={handleChange} >
disabled={disabled} <SelectValue placeholder={placeholder || _(msg`Select authentication method`)} />
placeholder={placeholder || _(msg`Select authentication methods`)} </SelectTrigger>
className="bg-background text-muted-foreground" <SelectContent>
hideClearAllButton={false} <SelectItem value="-1">{_(msg`No restrictions`)}</SelectItem>
data-testid="documentActionSelectValue" {authTypes.map((authType) => (
/> <SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
); );
}; };
@ -86,14 +86,14 @@ export const DocumentGlobalAuthActionTooltip = () => (
<p> <p>
<Trans> <Trans>
The authentication methods required for recipients to sign the signature field. The authentication method required for recipients to sign the signature field.
</Trans> </Trans>
</p> </p>
<p> <p>
<Trans> <Trans>
These can be overriden by setting the authentication requirements directly on each This can be overridden by setting the authentication requirements directly on each
recipient in the next step. Multiple methods can be selected. recipient in the next step.
</Trans> </Trans>
</p> </p>

View File

@ -294,28 +294,27 @@ export const AddSettingsFormPartial = ({
/> />
)} )}
{organisation.organisationClaim.flags.cfr21 && ( <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 <DocumentGlobalAuthActionSelect
value={field.value} value={field.value}
disabled={field.disabled} disabled={field.disabled}
onValueChange={field.onChange} onValueChange={field.onChange}
/> isDocumentEnterprise={organisation?.organisationClaim?.flags?.cfr21 || false}
</FormControl> />
</FormItem> </FormControl>
)} </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

@ -382,28 +382,27 @@ export const AddTemplateSettingsFormPartial = ({
)} )}
/> />
{organisation.organisationClaim.flags.cfr21 && ( <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 <DocumentGlobalAuthActionSelect
value={field.value} value={field.value}
disabled={field.disabled} disabled={field.disabled}
onValueChange={field.onChange} onValueChange={field.onChange}
/> isDocumentEnterprise={organisation.organisationClaim.flags.cfr21}
</FormControl> />
</FormItem> </FormControl>
)} </FormItem>
/> )}
)} />
{distributionMethod === DocumentDistributionMethod.EMAIL && ( {distributionMethod === DocumentDistributionMethod.EMAIL && (
<Accordion type="multiple"> <Accordion type="multiple">