mirror of
https://github.com/documenso/documenso.git
synced 2025-11-09 20:12:31 +10:00
feat: support 2fa for document completion (#2063)
Adds support for 2FA when completing a document, also adds support for using email for 2FA when no authenticator has been associated with the account.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@ -57,3 +57,6 @@ logs.json
|
|||||||
# claude
|
# claude
|
||||||
.claude
|
.claude
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
# agents
|
||||||
|
.specs
|
||||||
|
|||||||
@ -417,11 +417,11 @@ export const DirectTemplateSigningForm = ({
|
|||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={handleSubmit}
|
onSignatureComplete={async () => handleSubmit()}
|
||||||
documentTitle={template.title}
|
documentTitle={template.title}
|
||||||
fields={localFields}
|
fields={localFields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
role={directRecipient.role}
|
recipient={directRecipient}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DocumentFlowFormContainerFooter>
|
</DocumentFlowFormContainerFooter>
|
||||||
|
|||||||
@ -0,0 +1,312 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { ArrowLeftIcon, KeyIcon, MailIcon } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Form, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||||
|
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
|
|
||||||
|
type FormStep = 'method-selection' | 'code-input';
|
||||||
|
type TwoFactorMethod = 'email' | 'authenticator';
|
||||||
|
|
||||||
|
const ZAccessAuth2FAFormSchema = z.object({
|
||||||
|
token: z.string().length(6, { message: 'Token must be 6 characters long' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TAccessAuth2FAFormSchema = z.infer<typeof ZAccessAuth2FAFormSchema>;
|
||||||
|
|
||||||
|
export type AccessAuth2FAFormProps = {
|
||||||
|
onSubmit: (accessAuthOptions: TRecipientAccessAuth) => void;
|
||||||
|
token: string;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AccessAuth2FAForm = ({ onSubmit, token, error }: AccessAuth2FAFormProps) => {
|
||||||
|
const [step, setStep] = useState<FormStep>('method-selection');
|
||||||
|
const [selectedMethod, setSelectedMethod] = useState<TwoFactorMethod | null>(null);
|
||||||
|
|
||||||
|
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
|
||||||
|
const [millisecondsRemaining, setMillisecondsRemaining] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { user } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
|
const { mutateAsync: request2FAEmail, isPending: isRequesting2FAEmail } =
|
||||||
|
trpc.document.accessAuth.request2FAEmail.useMutation();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(ZAccessAuth2FAFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
token: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasAuthenticatorEnabled = user?.twoFactorEnabled === true;
|
||||||
|
|
||||||
|
const onMethodSelect = async (method: TwoFactorMethod) => {
|
||||||
|
setSelectedMethod(method);
|
||||||
|
|
||||||
|
if (method === 'email') {
|
||||||
|
try {
|
||||||
|
const result = await request2FAEmail({
|
||||||
|
token: token,
|
||||||
|
});
|
||||||
|
|
||||||
|
setExpiresAt(result.expiresAt);
|
||||||
|
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
|
||||||
|
|
||||||
|
setStep('code-input');
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep('code-input');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = (data: TAccessAuth2FAFormSchema) => {
|
||||||
|
if (!selectedMethod) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the auth options for the completion attempt
|
||||||
|
const accessAuthOptions: TRecipientAccessAuth = {
|
||||||
|
type: 'TWO_FACTOR_AUTH',
|
||||||
|
token: data.token, // Just the user's code - backend will validate using method type
|
||||||
|
method: selectedMethod,
|
||||||
|
};
|
||||||
|
|
||||||
|
onSubmit(accessAuthOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGoBack = () => {
|
||||||
|
setStep('method-selection');
|
||||||
|
setSelectedMethod(null);
|
||||||
|
setExpiresAt(null);
|
||||||
|
setMillisecondsRemaining(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResendEmail = async () => {
|
||||||
|
if (selectedMethod !== 'email') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await request2FAEmail({
|
||||||
|
token: token,
|
||||||
|
});
|
||||||
|
|
||||||
|
setExpiresAt(result.expiresAt);
|
||||||
|
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (expiresAt) {
|
||||||
|
setMillisecondsRemaining(expiresAt.valueOf() - Date.now());
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [expiresAt]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-4">
|
||||||
|
{step === 'method-selection' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
<Trans>Choose verification method</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<Trans>Please select how you'd like to receive your verification code.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" padding="tight" className="text-sm">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="flex h-auto w-full justify-start gap-3 p-4"
|
||||||
|
onClick={async () => onMethodSelect('email')}
|
||||||
|
disabled={isRequesting2FAEmail}
|
||||||
|
>
|
||||||
|
<MailIcon className="h-5 w-5" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium">
|
||||||
|
<Trans>Email verification</Trans>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
<Trans>We'll send a 6-digit code to your email</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{hasAuthenticatorEnabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="flex h-auto w-full justify-start gap-3 p-4"
|
||||||
|
onClick={async () => onMethodSelect('authenticator')}
|
||||||
|
disabled={isRequesting2FAEmail}
|
||||||
|
>
|
||||||
|
<KeyIcon className="h-5 w-5" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium">
|
||||||
|
<Trans>Authenticator app</Trans>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
<Trans>Use your authenticator app to generate a code</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'code-input' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onGoBack}>
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
<Trans>Enter verification code</Trans>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{selectedMethod === 'email' ? (
|
||||||
|
<Trans>
|
||||||
|
We've sent a 6-digit verification code to your email. Please enter it below to
|
||||||
|
complete the document.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Please open your authenticator app and enter the 6-digit code for this document.
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="access-auth-2fa-form"
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||||
|
>
|
||||||
|
<fieldset disabled={isRequesting2FAEmail || form.formState.isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1 items-center justify-center">
|
||||||
|
<PinInput
|
||||||
|
{...field}
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="^\d+$"
|
||||||
|
aria-label="2FA code"
|
||||||
|
containerClassName="h-12 justify-center"
|
||||||
|
>
|
||||||
|
<PinInputGroup>
|
||||||
|
<PinInputSlot className="h-12 w-12 text-lg" index={0} />
|
||||||
|
<PinInputSlot className="h-12 w-12 text-lg" index={1} />
|
||||||
|
<PinInputSlot className="h-12 w-12 text-lg" index={2} />
|
||||||
|
<PinInputSlot className="h-12 w-12 text-lg" index={3} />
|
||||||
|
<PinInputSlot className="h-12 w-12 text-lg" index={4} />
|
||||||
|
<PinInputSlot className="h-12 w-12 text-lg" index={5} />
|
||||||
|
</PinInputGroup>
|
||||||
|
</PinInput>
|
||||||
|
|
||||||
|
{expiresAt && millisecondsRemaining !== null && (
|
||||||
|
<div
|
||||||
|
className={cn('text-muted-foreground mt-2 text-center text-sm', {
|
||||||
|
'text-destructive': millisecondsRemaining <= 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Trans>
|
||||||
|
Expires in{' '}
|
||||||
|
{DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat(
|
||||||
|
'mm:ss',
|
||||||
|
)}
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="access-auth-2fa-form"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!form.formState.isValid}
|
||||||
|
loading={isRequesting2FAEmail || form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<Trans>Verify & Complete</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{selectedMethod === 'email' && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onResendEmail}
|
||||||
|
loading={isRequesting2FAEmail}
|
||||||
|
>
|
||||||
|
<Trans>Resend code</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -2,12 +2,17 @@ import { useMemo, 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';
|
||||||
import type { Field } from '@prisma/client';
|
import type { Field, Recipient } from '@prisma/client';
|
||||||
import { RecipientRole } from '@prisma/client';
|
import { RecipientRole } from '@prisma/client';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import {
|
||||||
|
type TRecipientAccessAuth,
|
||||||
|
ZDocumentAccessAuthSchema,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -27,15 +32,21 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
|
|
||||||
export type DocumentSigningCompleteDialogProps = {
|
export type DocumentSigningCompleteDialogProps = {
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
|
onSignatureComplete: (
|
||||||
role: RecipientRole;
|
nextSigner?: { name: string; email: string },
|
||||||
|
accessAuthOptions?: TRecipientAccessAuth,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
allowDictateNextSigner?: boolean;
|
allowDictateNextSigner?: boolean;
|
||||||
defaultNextSigner?: {
|
defaultNextSigner?: {
|
||||||
@ -47,6 +58,7 @@ export type DocumentSigningCompleteDialogProps = {
|
|||||||
const ZNextSignerFormSchema = z.object({
|
const ZNextSignerFormSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
email: z.string().email('Invalid email address'),
|
email: z.string().email('Invalid email address'),
|
||||||
|
accessAuthOptions: ZDocumentAccessAuthSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||||
@ -57,7 +69,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
fields,
|
fields,
|
||||||
fieldsValidated,
|
fieldsValidated,
|
||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
role,
|
recipient,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
allowDictateNextSigner = false,
|
allowDictateNextSigner = false,
|
||||||
defaultNextSigner,
|
defaultNextSigner,
|
||||||
@ -65,6 +77,11 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
||||||
|
|
||||||
|
const [showTwoFactorForm, setShowTwoFactorForm] = useState(false);
|
||||||
|
const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth, user } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
const form = useForm<TNextSignerFormSchema>({
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -75,6 +92,11 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
|
|
||||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
||||||
|
|
||||||
|
const completionRequires2FA = useMemo(
|
||||||
|
() => derivedRecipientAccessAuth.includes('TWO_FACTOR_AUTH'),
|
||||||
|
[derivedRecipientAccessAuth],
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
if (form.formState.isSubmitting || !isComplete) {
|
if (form.formState.isSubmitting || !isComplete) {
|
||||||
return;
|
return;
|
||||||
@ -93,14 +115,41 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
|
|
||||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||||
try {
|
try {
|
||||||
if (allowDictateNextSigner && data.name && data.email) {
|
// Check if 2FA is required
|
||||||
await onSignatureComplete({ name: data.name, email: data.email });
|
if (completionRequires2FA && !data.accessAuthOptions) {
|
||||||
} else {
|
setShowTwoFactorForm(true);
|
||||||
await onSignatureComplete();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextSigner =
|
||||||
|
allowDictateNextSigner && data.name && data.email
|
||||||
|
? { name: data.name, email: data.email }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await onSignatureComplete(nextSigner, data.accessAuthOptions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error completing signature:', error);
|
const err = AppError.parseError(error);
|
||||||
|
|
||||||
|
if (AppErrorCode.TWO_FACTOR_AUTH_FAILED === err.code) {
|
||||||
|
// This was a 2FA validation failure - show the 2FA dialog again with error
|
||||||
|
form.setValue('accessAuthOptions', undefined);
|
||||||
|
|
||||||
|
setTwoFactorValidationError('Invalid verification code. Please try again.');
|
||||||
|
setShowTwoFactorForm(true);
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTwoFactorFormSubmit = (validatedAuthOptions: TRecipientAccessAuth) => {
|
||||||
|
form.setValue('accessAuthOptions', validatedAuthOptions);
|
||||||
|
|
||||||
|
setShowTwoFactorForm(false);
|
||||||
|
setTwoFactorValidationError(null);
|
||||||
|
|
||||||
|
// Now trigger the form submission with auth options
|
||||||
|
void form.handleSubmit(onFormSubmit)();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
||||||
@ -116,7 +165,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{match({ isComplete, role })
|
{match({ isComplete, role: recipient.role })
|
||||||
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
|
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
|
||||||
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
|
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
|
||||||
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
|
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
|
||||||
@ -128,12 +177,13 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
{!showTwoFactorForm && (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<div className="text-foreground text-xl font-semibold">
|
<div className="text-foreground text-xl font-semibold">
|
||||||
{match(role)
|
{match(recipient.role)
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
||||||
@ -144,7 +194,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
{match(role)
|
{match(recipient.role)
|
||||||
.with(RecipientRole.VIEWER, () => (
|
.with(RecipientRole.VIEWER, () => (
|
||||||
<span>
|
<span>
|
||||||
<Trans>
|
<Trans>
|
||||||
@ -293,7 +343,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
disabled={!isComplete || !isNextSignerValid}
|
disabled={!isComplete || !isNextSignerValid}
|
||||||
loading={form.formState.isSubmitting}
|
loading={form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
{match(role)
|
{match(recipient.role)
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
||||||
@ -306,6 +356,15 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTwoFactorForm && (
|
||||||
|
<AccessAuth2FAForm
|
||||||
|
token={recipient.token}
|
||||||
|
error={twoFactorValidationError}
|
||||||
|
onSubmit={onTwoFactorFormSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { Controller, useForm } from 'react-hook-form';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
@ -34,10 +34,10 @@ export type DocumentSigningFormProps = {
|
|||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
setSelectedSignerId?: (id: number | null) => void;
|
setSelectedSignerId?: (id: number | null) => void;
|
||||||
completeDocument: (
|
completeDocument: (options: {
|
||||||
authOptions?: TRecipientActionAuth,
|
accessAuthOptions?: TRecipientAccessAuth;
|
||||||
nextSigner?: { email: string; name: string },
|
nextSigner?: { email: string; name: string };
|
||||||
) => Promise<void>;
|
}) => Promise<void>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
fieldsValidated: () => void;
|
fieldsValidated: () => void;
|
||||||
nextRecipient?: RecipientWithFields;
|
nextRecipient?: RecipientWithFields;
|
||||||
@ -105,7 +105,7 @@ export const DocumentSigningForm = ({
|
|||||||
setIsAssistantSubmitting(true);
|
setIsAssistantSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await completeDocument(undefined, nextSigner);
|
await completeDocument({ nextSigner });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@ -149,10 +149,10 @@ export const DocumentSigningForm = ({
|
|||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={localFieldsValidated}
|
fieldsValidated={localFieldsValidated}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
|
||||||
await completeDocument(undefined, nextSigner);
|
completeDocument({ nextSigner, accessAuthOptions })
|
||||||
}}
|
}
|
||||||
role={recipient.role}
|
recipient={recipient}
|
||||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
defaultNextSigner={
|
defaultNextSigner={
|
||||||
nextRecipient
|
nextRecipient
|
||||||
@ -309,10 +309,13 @@ export const DocumentSigningForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={localFieldsValidated}
|
fieldsValidated={localFieldsValidated}
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
|
||||||
await completeDocument(undefined, nextSigner);
|
completeDocument({
|
||||||
}}
|
accessAuthOptions,
|
||||||
role={recipient.role}
|
nextSigner,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
recipient={recipient}
|
||||||
allowDictateNextSigner={
|
allowDictateNextSigner={
|
||||||
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
|
|||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import {
|
import {
|
||||||
ZCheckboxFieldMeta,
|
ZCheckboxFieldMeta,
|
||||||
ZDropdownFieldMeta,
|
ZDropdownFieldMeta,
|
||||||
@ -46,6 +46,7 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
|
|||||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||||
|
|
||||||
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
@ -70,6 +71,12 @@ export const DocumentSigningPageView = ({
|
|||||||
}: DocumentSigningPageViewProps) => {
|
}: DocumentSigningPageViewProps) => {
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth, user: authUser } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
|
const hasAuthenticator = authUser?.twoFactorEnabled
|
||||||
|
? authUser.twoFactorEnabled && authUser.email === recipient.email
|
||||||
|
: false;
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
@ -94,14 +101,16 @@ export const DocumentSigningPageView = ({
|
|||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeDocument = async (
|
const completeDocument = async (options: {
|
||||||
authOptions?: TRecipientActionAuth,
|
accessAuthOptions?: TRecipientAccessAuth;
|
||||||
nextSigner?: { email: string; name: string },
|
nextSigner?: { email: string; name: string };
|
||||||
) => {
|
}) => {
|
||||||
|
const { accessAuthOptions, nextSigner } = options;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
authOptions,
|
accessAuthOptions,
|
||||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -265,10 +274,10 @@ export const DocumentSigningPageView = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner) =>
|
||||||
await completeDocument(undefined, nextSigner);
|
completeDocument({ nextSigner })
|
||||||
}}
|
}
|
||||||
role={recipient.role}
|
recipient={recipient}
|
||||||
allowDictateNextSigner={
|
allowDictateNextSigner={
|
||||||
nextRecipient && documentMeta?.allowDictateNextSigner
|
nextRecipient && documentMeta?.allowDictateNextSigner
|
||||||
}
|
}
|
||||||
|
|||||||
@ -151,6 +151,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
|||||||
|
|
||||||
authLevel = match(accessAuthMethod)
|
authLevel = match(accessAuthMethod)
|
||||||
.with('ACCOUNT', () => _(msg`Account Authentication`))
|
.with('ACCOUNT', () => _(msg`Account Authentication`))
|
||||||
|
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Authentication`))
|
||||||
.with(undefined, () => _(msg`Email`))
|
.with(undefined, () => _(msg`Email`))
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,10 +47,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Ensure typesafety when we add more options.
|
// Ensure typesafety when we add more options.
|
||||||
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
|
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
|
||||||
|
match(auth)
|
||||||
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user))
|
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user))
|
||||||
.with(undefined, () => true)
|
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
|
||||||
.exhaustive();
|
.exhaustive(),
|
||||||
|
);
|
||||||
|
|
||||||
if (!isAccessAuthValid) {
|
if (!isAccessAuthValid) {
|
||||||
return superLoaderJson({
|
return superLoaderJson({
|
||||||
|
|||||||
@ -3,12 +3,12 @@ import { DocumentSigningOrder, DocumentStatus, RecipientRole, SigningStatus } fr
|
|||||||
import { Clock8 } from 'lucide-react';
|
import { Clock8 } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
|
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
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 { 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 { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
@ -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 { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
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';
|
||||||
|
|
||||||
@ -98,16 +99,16 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
recipientAuth: recipient.authOptions,
|
recipientAuth: recipient.authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDocumentAccessValid = await isRecipientAuthorized({
|
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
||||||
type: 'ACCESS',
|
match(accesssAuth)
|
||||||
documentAuthOptions: document.authOptions,
|
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
||||||
recipient,
|
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
|
||||||
userId: user?.id,
|
.exhaustive(),
|
||||||
});
|
);
|
||||||
|
|
||||||
let recipientHasAccount: boolean | null = null;
|
let recipientHasAccount: boolean | null = null;
|
||||||
|
|
||||||
if (!isDocumentAccessValid) {
|
if (!isAccessAuthValid) {
|
||||||
recipientHasAccount = await getUserByEmail({ email: recipient.email })
|
recipientHasAccount = await getUserByEmail({ email: recipient.email })
|
||||||
.then((user) => !!user)
|
.then((user) => !!user)
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|||||||
@ -58,10 +58,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
documentAuth: template.authOptions,
|
documentAuth: template.authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
|
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
|
||||||
|
match(auth)
|
||||||
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
|
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
|
||||||
.with(undefined, () => true)
|
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
|
||||||
.exhaustive();
|
.exhaustive(),
|
||||||
|
);
|
||||||
|
|
||||||
if (!isAccessAuthValid) {
|
if (!isAccessAuthValid) {
|
||||||
throw data(
|
throw data(
|
||||||
|
|||||||
@ -75,10 +75,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
documentAuth: document.authOptions,
|
documentAuth: document.authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
|
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
||||||
|
match(accesssAuth)
|
||||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
||||||
.with(undefined, () => true)
|
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
|
||||||
.exhaustive();
|
.exhaustive(),
|
||||||
|
);
|
||||||
|
|
||||||
if (!isAccessAuthValid) {
|
if (!isAccessAuthValid) {
|
||||||
throw data(
|
throw data(
|
||||||
|
|||||||
@ -0,0 +1,60 @@
|
|||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { Heading, Img, Section, Text } from '../components';
|
||||||
|
|
||||||
|
export type TemplateAccessAuth2FAProps = {
|
||||||
|
documentTitle: string;
|
||||||
|
code: string;
|
||||||
|
userEmail: string;
|
||||||
|
userName: string;
|
||||||
|
expiresInMinutes: number;
|
||||||
|
assetBaseUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplateAccessAuth2FA = ({
|
||||||
|
documentTitle,
|
||||||
|
code,
|
||||||
|
userName,
|
||||||
|
expiresInMinutes,
|
||||||
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
|
}: TemplateAccessAuth2FAProps) => {
|
||||||
|
const getAssetUrl = (path: string) => {
|
||||||
|
return new URL(path, assetBaseUrl).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Img src={getAssetUrl('/static/document.png')} alt="Document" className="mx-auto h-12 w-12" />
|
||||||
|
|
||||||
|
<Section className="mt-8">
|
||||||
|
<Heading className="text-center text-lg font-semibold text-slate-900">
|
||||||
|
<Trans>Verification Code Required</Trans>
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text className="mt-2 text-center text-slate-700">
|
||||||
|
<Trans>
|
||||||
|
Hi {userName}, you need to enter a verification code to complete the document "
|
||||||
|
{documentTitle}".
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section className="mt-6 rounded-lg bg-slate-50 p-6 text-center">
|
||||||
|
<Text className="mb-2 text-sm font-medium text-slate-600">
|
||||||
|
<Trans>Your verification code:</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text className="text-2xl font-bold tracking-wider text-slate-900">{code}</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text className="mt-4 text-center text-sm text-slate-600">
|
||||||
|
<Trans>This code will expire in {expiresInMinutes} minutes.</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="mt-4 text-center text-sm text-slate-500">
|
||||||
|
<Trans>
|
||||||
|
If you didn't request this verification code, you can safely ignore this email.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
77
packages/email/templates/access-auth-2fa.tsx
Normal file
77
packages/email/templates/access-auth-2fa.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
|
||||||
|
import { useBranding } from '../providers/branding';
|
||||||
|
import { TemplateAccessAuth2FA } from '../template-components/template-access-auth-2fa';
|
||||||
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
|
|
||||||
|
export type AccessAuth2FAEmailTemplateProps = {
|
||||||
|
documentTitle: string;
|
||||||
|
code: string;
|
||||||
|
userEmail: string;
|
||||||
|
userName: string;
|
||||||
|
expiresInMinutes: number;
|
||||||
|
assetBaseUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AccessAuth2FAEmailTemplate = ({
|
||||||
|
documentTitle,
|
||||||
|
code,
|
||||||
|
userEmail,
|
||||||
|
userName,
|
||||||
|
expiresInMinutes,
|
||||||
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
|
}: AccessAuth2FAEmailTemplateProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const branding = useBranding();
|
||||||
|
|
||||||
|
const previewText = msg`Your verification code is ${code}`;
|
||||||
|
|
||||||
|
const getAssetUrl = (path: string) => {
|
||||||
|
return new URL(path, assetBaseUrl).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{_(previewText)}</Preview>
|
||||||
|
|
||||||
|
<Body className="mx-auto my-auto bg-white font-sans">
|
||||||
|
<Section>
|
||||||
|
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||||
|
<Section>
|
||||||
|
{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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TemplateAccessAuth2FA
|
||||||
|
documentTitle={documentTitle}
|
||||||
|
code={code}
|
||||||
|
userEmail={userEmail}
|
||||||
|
userName={userName}
|
||||||
|
expiresInMinutes={expiresInMinutes}
|
||||||
|
assetBaseUrl={assetBaseUrl}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<div className="mx-auto mt-12 max-w-xl" />
|
||||||
|
|
||||||
|
<Container className="mx-auto max-w-xl">
|
||||||
|
<TemplateFooter isDocument={false} />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccessAuth2FAEmailTemplate;
|
||||||
@ -17,6 +17,7 @@ export enum AppErrorCode {
|
|||||||
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
|
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
|
||||||
'SCHEMA_FAILED' = 'SCHEMA_FAILED',
|
'SCHEMA_FAILED' = 'SCHEMA_FAILED',
|
||||||
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS',
|
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS',
|
||||||
|
'TWO_FACTOR_AUTH_FAILED' = 'TWO_FACTOR_AUTH_FAILED',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
|
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
|
||||||
@ -32,6 +33,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
|||||||
[AppErrorCode.RETRY_EXCEPTION]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
[AppErrorCode.RETRY_EXCEPTION]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||||
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||||
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
|
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
|
||||||
|
[AppErrorCode.TWO_FACTOR_AUTH_FAILED]: { code: 'UNAUTHORIZED', status: 401 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ZAppErrorJsonSchema = z.object({
|
export const ZAppErrorJsonSchema = z.object({
|
||||||
|
|||||||
1
packages/lib/server-only/2fa/email/constants.ts
Normal file
1
packages/lib/server-only/2fa/email/constants.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const TWO_FACTOR_EMAIL_EXPIRATION_MINUTES = 5;
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import { hmac } from '@noble/hashes/hmac';
|
||||||
|
import { sha256 } from '@noble/hashes/sha256';
|
||||||
|
import { createTOTPKeyURI } from 'oslo/otp';
|
||||||
|
|
||||||
|
import { DOCUMENSO_ENCRYPTION_KEY } from '../../../constants/crypto';
|
||||||
|
|
||||||
|
const ISSUER = 'Documenso Email 2FA';
|
||||||
|
|
||||||
|
export type GenerateTwoFactorCredentialsFromEmailOptions = {
|
||||||
|
documentId: number;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an encrypted token containing a 6-digit 2FA code for email verification.
|
||||||
|
*
|
||||||
|
* @param options - The options for generating the token
|
||||||
|
* @returns Object containing the token and the 6-digit code
|
||||||
|
*/
|
||||||
|
export const generateTwoFactorCredentialsFromEmail = ({
|
||||||
|
documentId,
|
||||||
|
email,
|
||||||
|
}: GenerateTwoFactorCredentialsFromEmailOptions) => {
|
||||||
|
if (!DOCUMENSO_ENCRYPTION_KEY) {
|
||||||
|
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = `email-2fa|v1|email:${email}|id:${documentId}`;
|
||||||
|
|
||||||
|
const secret = hmac(sha256, DOCUMENSO_ENCRYPTION_KEY, identity);
|
||||||
|
|
||||||
|
const uri = createTOTPKeyURI(ISSUER, email, secret);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri,
|
||||||
|
secret,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { generateHOTP } from 'oslo/otp';
|
||||||
|
|
||||||
|
import { generateTwoFactorCredentialsFromEmail } from './generate-2fa-credentials-from-email';
|
||||||
|
|
||||||
|
export type GenerateTwoFactorTokenFromEmailOptions = {
|
||||||
|
documentId: number;
|
||||||
|
email: string;
|
||||||
|
period?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateTwoFactorTokenFromEmail = async ({
|
||||||
|
email,
|
||||||
|
documentId,
|
||||||
|
period = 30_000,
|
||||||
|
}: GenerateTwoFactorTokenFromEmailOptions) => {
|
||||||
|
const { secret } = generateTwoFactorCredentialsFromEmail({ email, documentId });
|
||||||
|
|
||||||
|
const counter = Math.floor(Date.now() / period);
|
||||||
|
|
||||||
|
const token = await generateHOTP(secret, counter);
|
||||||
|
|
||||||
|
return token;
|
||||||
|
};
|
||||||
124
packages/lib/server-only/2fa/email/send-2fa-token-email.ts
Normal file
124
packages/lib/server-only/2fa/email/send-2fa-token-email.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||||
|
import { AppError, AppErrorCode } from '../../../errors/app-error';
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||||
|
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
||||||
|
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||||
|
import { getEmailContext } from '../../email/get-email-context';
|
||||||
|
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from './constants';
|
||||||
|
import { generateTwoFactorTokenFromEmail } from './generate-2fa-token-from-email';
|
||||||
|
|
||||||
|
export type Send2FATokenEmailOptions = {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const send2FATokenEmail = async ({ token, documentId }: Send2FATokenEmailOptions) => {
|
||||||
|
const document = await prisma.document.findFirst({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
recipients: {
|
||||||
|
some: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
documentMeta: true,
|
||||||
|
team: {
|
||||||
|
select: {
|
||||||
|
teamEmail: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Document not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [recipient] = document.recipients;
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Recipient not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({
|
||||||
|
documentId,
|
||||||
|
email: recipient.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||||
|
emailType: 'RECIPIENT',
|
||||||
|
source: {
|
||||||
|
type: 'team',
|
||||||
|
teamId: document.teamId,
|
||||||
|
},
|
||||||
|
meta: document.documentMeta,
|
||||||
|
});
|
||||||
|
|
||||||
|
const i18n = await getI18nInstance(emailLanguage);
|
||||||
|
|
||||||
|
const subject = i18n._(msg`Your two-factor authentication code`);
|
||||||
|
|
||||||
|
const template = createElement(AccessAuth2FAEmailTemplate, {
|
||||||
|
documentTitle: document.title,
|
||||||
|
userName: recipient.name,
|
||||||
|
userEmail: recipient.email,
|
||||||
|
code: twoFactorTokenToken,
|
||||||
|
expiresInMinutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES,
|
||||||
|
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [html, text] = await Promise.all([
|
||||||
|
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||||
|
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: recipient.email,
|
||||||
|
name: recipient.name,
|
||||||
|
},
|
||||||
|
from: senderEmail,
|
||||||
|
replyTo: replyToEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
|
||||||
|
documentId: document.id,
|
||||||
|
data: {
|
||||||
|
recipientEmail: recipient.email,
|
||||||
|
recipientName: recipient.name,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { generateHOTP } from 'oslo/otp';
|
||||||
|
|
||||||
|
import { generateTwoFactorCredentialsFromEmail } from './generate-2fa-credentials-from-email';
|
||||||
|
|
||||||
|
export type ValidateTwoFactorTokenFromEmailOptions = {
|
||||||
|
documentId: number;
|
||||||
|
email: string;
|
||||||
|
code: string;
|
||||||
|
period?: number;
|
||||||
|
window?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateTwoFactorTokenFromEmail = async ({
|
||||||
|
documentId,
|
||||||
|
email,
|
||||||
|
code,
|
||||||
|
period = 30_000,
|
||||||
|
window = 1,
|
||||||
|
}: ValidateTwoFactorTokenFromEmailOptions) => {
|
||||||
|
const { secret } = generateTwoFactorCredentialsFromEmail({ email, documentId });
|
||||||
|
|
||||||
|
let now = Date.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < window; i++) {
|
||||||
|
const counter = Math.floor(now / period);
|
||||||
|
|
||||||
|
const hotp = await generateHOTP(secret, counter);
|
||||||
|
|
||||||
|
if (code === hotp) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
now -= period;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
@ -18,7 +18,8 @@ import { prisma } from '@documenso/prisma';
|
|||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { jobs } from '../../jobs/client';
|
import { jobs } from '../../jobs/client';
|
||||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
import type { TRecipientAccessAuth, TRecipientActionAuth } from '../../types/document-auth';
|
||||||
|
import { DocumentAuth } from '../../types/document-auth';
|
||||||
import {
|
import {
|
||||||
ZWebhookDocumentSchema,
|
ZWebhookDocumentSchema,
|
||||||
mapDocumentToWebhookDocumentPayload,
|
mapDocumentToWebhookDocumentPayload,
|
||||||
@ -26,6 +27,7 @@ import {
|
|||||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||||
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||||
import { sendPendingEmail } from './send-pending-email';
|
import { sendPendingEmail } from './send-pending-email';
|
||||||
|
|
||||||
export type CompleteDocumentWithTokenOptions = {
|
export type CompleteDocumentWithTokenOptions = {
|
||||||
@ -33,6 +35,7 @@ export type CompleteDocumentWithTokenOptions = {
|
|||||||
documentId: number;
|
documentId: number;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
authOptions?: TRecipientActionAuth;
|
authOptions?: TRecipientActionAuth;
|
||||||
|
accessAuthOptions?: TRecipientAccessAuth;
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
nextSigner?: {
|
nextSigner?: {
|
||||||
email: string;
|
email: string;
|
||||||
@ -64,6 +67,8 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
|
|||||||
export const completeDocumentWithToken = async ({
|
export const completeDocumentWithToken = async ({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
|
userId,
|
||||||
|
accessAuthOptions,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
nextSigner,
|
nextSigner,
|
||||||
}: CompleteDocumentWithTokenOptions) => {
|
}: CompleteDocumentWithTokenOptions) => {
|
||||||
@ -111,24 +116,57 @@ export const completeDocumentWithToken = async ({
|
|||||||
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Document reauth for completing documents is currently not required.
|
// Check ACCESS AUTH 2FA validation during document completion
|
||||||
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipient.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
// const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
|
||||||
// documentAuth: document.authOptions,
|
if (!accessAuthOptions) {
|
||||||
// recipientAuth: recipient.authOptions,
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
// });
|
message: 'Access authentication required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// const isValid = await isRecipientAuthorized({
|
const isValid = await isRecipientAuthorized({
|
||||||
// type: 'ACTION',
|
type: 'ACCESS_2FA',
|
||||||
// document: document,
|
documentAuthOptions: document.authOptions,
|
||||||
// recipient: recipient,
|
recipient: recipient,
|
||||||
// userId,
|
userId, // Can be undefined for non-account recipients
|
||||||
// authOptions,
|
authOptions: accessAuthOptions,
|
||||||
// });
|
});
|
||||||
|
|
||||||
// if (!isValid) {
|
if (!isValid) {
|
||||||
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
|
await prisma.documentAuditLog.create({
|
||||||
// }
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED,
|
||||||
|
documentId: document.id,
|
||||||
|
data: {
|
||||||
|
recipientId: recipient.id,
|
||||||
|
recipientName: recipient.name,
|
||||||
|
recipientEmail: recipient.email,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new AppError(AppErrorCode.TWO_FACTOR_AUTH_FAILED, {
|
||||||
|
message: 'Invalid 2FA authentication',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED,
|
||||||
|
documentId: document.id,
|
||||||
|
data: {
|
||||||
|
recipientId: recipient.id,
|
||||||
|
recipientName: recipient.name,
|
||||||
|
recipientEmail: recipient.email,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.recipient.update({
|
await tx.recipient.update({
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { validateTwoFactorTokenFromEmail } from '../2fa/email/validate-2fa-token-from-email';
|
||||||
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
|
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
|
||||||
import { verifyPassword } from '../2fa/verify-password';
|
import { verifyPassword } from '../2fa/verify-password';
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
@ -14,9 +15,10 @@ import { getAuthenticatorOptions } from '../../utils/authenticator';
|
|||||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||||
|
|
||||||
type IsRecipientAuthorizedOptions = {
|
type IsRecipientAuthorizedOptions = {
|
||||||
type: 'ACCESS' | 'ACTION';
|
// !: Probably find a better name than 'ACCESS_2FA' if requirements change.
|
||||||
|
type: 'ACCESS' | 'ACCESS_2FA' | 'ACTION';
|
||||||
documentAuthOptions: Document['authOptions'];
|
documentAuthOptions: Document['authOptions'];
|
||||||
recipient: Pick<Recipient, 'authOptions' | 'email'>;
|
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ID of the user who initiated the request.
|
* The ID of the user who initiated the request.
|
||||||
@ -61,8 +63,11 @@ export const isRecipientAuthorized = async ({
|
|||||||
recipientAuth: recipient.authOptions,
|
recipientAuth: recipient.authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const authMethods: TDocumentAuth[] =
|
const authMethods: TDocumentAuth[] = match(type)
|
||||||
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
|
.with('ACCESS', () => derivedRecipientAccessAuth)
|
||||||
|
.with('ACCESS_2FA', () => derivedRecipientAccessAuth)
|
||||||
|
.with('ACTION', () => derivedRecipientActionAuth)
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
// Early true return when auth is not required.
|
// Early true return when auth is not required.
|
||||||
if (
|
if (
|
||||||
@ -72,6 +77,11 @@ export const isRecipientAuthorized = async ({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Early true return for ACCESS auth if all methods are 2FA since validation happens in ACCESS_2FA.
|
||||||
|
if (type === 'ACCESS' && authMethods.every((method) => method === DocumentAuth.TWO_FACTOR_AUTH)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Create auth options when none are passed for account.
|
// Create auth options when none are passed for account.
|
||||||
if (!authOptions && authMethods.some((method) => method === DocumentAuth.ACCOUNT)) {
|
if (!authOptions && authMethods.some((method) => method === DocumentAuth.ACCOUNT)) {
|
||||||
authOptions = {
|
authOptions = {
|
||||||
@ -80,12 +90,16 @@ export const isRecipientAuthorized = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Authentication required does not match provided method.
|
// Authentication required does not match provided method.
|
||||||
if (!authOptions || !authMethods.includes(authOptions.type) || !userId) {
|
if (!authOptions || !authMethods.includes(authOptions.type)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await match(authOptions)
|
return await match(authOptions)
|
||||||
.with({ type: DocumentAuth.ACCOUNT }, async () => {
|
.with({ type: DocumentAuth.ACCOUNT }, async () => {
|
||||||
|
if (!userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const recipientUser = await getUserByEmail(recipient.email);
|
const recipientUser = await getUserByEmail(recipient.email);
|
||||||
|
|
||||||
if (!recipientUser) {
|
if (!recipientUser) {
|
||||||
@ -95,13 +109,40 @@ export const isRecipientAuthorized = async ({
|
|||||||
return recipientUser.id === userId;
|
return recipientUser.id === userId;
|
||||||
})
|
})
|
||||||
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
|
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
|
||||||
|
if (!userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return await isPasskeyAuthValid({
|
return await isPasskeyAuthValid({
|
||||||
userId,
|
userId,
|
||||||
authenticationResponse,
|
authenticationResponse,
|
||||||
tokenReference,
|
tokenReference,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => {
|
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token, method }) => {
|
||||||
|
if (type === 'ACCESS') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'ACCESS_2FA' && method === 'email') {
|
||||||
|
if (!recipient.documentId) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Document ID is required for email 2FA verification',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await validateTwoFactorTokenFromEmail({
|
||||||
|
documentId: recipient.documentId,
|
||||||
|
email: recipient.email,
|
||||||
|
code: token,
|
||||||
|
window: 10, // 5 minutes worth of tokens
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
@ -115,6 +156,7 @@ export const isRecipientAuthorized = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For ACTION auth or authenticator method, use TOTP
|
||||||
return await verifyTwoFactorAuthenticationToken({
|
return await verifyTwoFactorAuthenticationToken({
|
||||||
user,
|
user,
|
||||||
totpCode: token,
|
totpCode: token,
|
||||||
@ -122,6 +164,10 @@ export const isRecipientAuthorized = async ({
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.with({ type: DocumentAuth.PASSWORD }, async ({ password }) => {
|
.with({ type: DocumentAuth.PASSWORD }, async ({ password }) => {
|
||||||
|
if (!userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return await verifyPassword({
|
return await verifyPassword({
|
||||||
userId,
|
userId,
|
||||||
password,
|
password,
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { isRecipientAuthorized } from './is-recipient-authorized';
|
|||||||
|
|
||||||
export type ValidateFieldAuthOptions = {
|
export type ValidateFieldAuthOptions = {
|
||||||
documentAuthOptions: Document['authOptions'];
|
documentAuthOptions: Document['authOptions'];
|
||||||
recipient: Pick<Recipient, 'authOptions' | 'email'>;
|
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
|
||||||
field: Field;
|
field: Field;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
authOptions?: TRecipientActionAuth;
|
authOptions?: TRecipientActionAuth;
|
||||||
|
|||||||
@ -159,6 +159,7 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
// Ensure typesafety when we add more options.
|
// Ensure typesafety when we add more options.
|
||||||
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
|
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
|
||||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail)
|
.with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail)
|
||||||
|
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct templates
|
||||||
.with(undefined, () => true)
|
.with(undefined, () => true)
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
|
|
||||||
@ -205,6 +206,7 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
recipient: {
|
recipient: {
|
||||||
authOptions: directTemplateRecipient.authOptions,
|
authOptions: directTemplateRecipient.authOptions,
|
||||||
email: directRecipientEmail,
|
email: directRecipientEmail,
|
||||||
|
documentId: template.id,
|
||||||
},
|
},
|
||||||
field: templateField,
|
field: templateField,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
|
|||||||
@ -40,6 +40,11 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
|||||||
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
||||||
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
|
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
|
||||||
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
|
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
|
||||||
|
|
||||||
|
// ACCESS AUTH 2FA events.
|
||||||
|
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
|
||||||
|
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
|
||||||
|
'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails.
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
||||||
@ -487,6 +492,42 @@ export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Document recipient requested a 2FA token.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventDocumentRecipientRequested2FAEmailSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED),
|
||||||
|
data: z.object({
|
||||||
|
recipientEmail: z.string(),
|
||||||
|
recipientName: z.string(),
|
||||||
|
recipientId: z.number(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Document recipient validated a 2FA token.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventDocumentRecipientValidated2FAEmailSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED),
|
||||||
|
data: z.object({
|
||||||
|
recipientEmail: z.string(),
|
||||||
|
recipientName: z.string(),
|
||||||
|
recipientId: z.number(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Document recipient failed to validate a 2FA token.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventDocumentRecipientFailed2FAEmailSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED),
|
||||||
|
data: z.object({
|
||||||
|
recipientEmail: z.string(),
|
||||||
|
recipientName: z.string(),
|
||||||
|
recipientId: z.number(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event: Document sent.
|
* Event: Document sent.
|
||||||
*/
|
*/
|
||||||
@ -627,6 +668,9 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
|||||||
ZDocumentAuditLogEventDocumentViewedSchema,
|
ZDocumentAuditLogEventDocumentViewedSchema,
|
||||||
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
|
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
|
||||||
ZDocumentAuditLogEventDocumentRecipientRejectedSchema,
|
ZDocumentAuditLogEventDocumentRecipientRejectedSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentRecipientRequested2FAEmailSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentRecipientValidated2FAEmailSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentRecipientFailed2FAEmailSchema,
|
||||||
ZDocumentAuditLogEventDocumentSentSchema,
|
ZDocumentAuditLogEventDocumentSentSchema,
|
||||||
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
|
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
|
||||||
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,
|
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,
|
||||||
|
|||||||
@ -37,6 +37,7 @@ const ZDocumentAuthPasswordSchema = z.object({
|
|||||||
const ZDocumentAuth2FASchema = z.object({
|
const ZDocumentAuth2FASchema = z.object({
|
||||||
type: z.literal(DocumentAuth.TWO_FACTOR_AUTH),
|
type: z.literal(DocumentAuth.TWO_FACTOR_AUTH),
|
||||||
token: z.string().min(4).max(10),
|
token: z.string().min(4).max(10),
|
||||||
|
method: z.enum(['email', 'authenticator']).default('authenticator').optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -55,9 +56,12 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
|
|||||||
*
|
*
|
||||||
* Must keep these two in sync.
|
* Must keep these two in sync.
|
||||||
*/
|
*/
|
||||||
export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]);
|
export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [
|
||||||
|
ZDocumentAuthAccountSchema,
|
||||||
|
ZDocumentAuth2FASchema,
|
||||||
|
]);
|
||||||
export const ZDocumentAccessAuthTypesSchema = z
|
export const ZDocumentAccessAuthTypesSchema = z
|
||||||
.enum([DocumentAuth.ACCOUNT])
|
.enum([DocumentAuth.ACCOUNT, DocumentAuth.TWO_FACTOR_AUTH])
|
||||||
.describe('The type of authentication required for the recipient to access the document.');
|
.describe('The type of authentication required for the recipient to access the document.');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -89,9 +93,10 @@ export const ZDocumentActionAuthTypesSchema = z
|
|||||||
*/
|
*/
|
||||||
export const ZRecipientAccessAuthSchema = z.discriminatedUnion('type', [
|
export const ZRecipientAccessAuthSchema = z.discriminatedUnion('type', [
|
||||||
ZDocumentAuthAccountSchema,
|
ZDocumentAuthAccountSchema,
|
||||||
|
ZDocumentAuth2FASchema,
|
||||||
]);
|
]);
|
||||||
export const ZRecipientAccessAuthTypesSchema = z
|
export const ZRecipientAccessAuthTypesSchema = z
|
||||||
.enum([DocumentAuth.ACCOUNT])
|
.enum([DocumentAuth.ACCOUNT, DocumentAuth.TWO_FACTOR_AUTH])
|
||||||
.describe('The type of authentication required for the recipient to access the document.');
|
.describe('The type of authentication required for the recipient to access the document.');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -476,6 +476,36 @@ export const formatDocumentAuditLogAction = (
|
|||||||
identified: result,
|
identified: result,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, ({ data }) => {
|
||||||
|
const userName = prefix || _(msg`Recipient`);
|
||||||
|
|
||||||
|
const result = msg`${userName} requested a 2FA token for the document`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
anonymous: result,
|
||||||
|
identified: result,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED }, ({ data }) => {
|
||||||
|
const userName = prefix || _(msg`Recipient`);
|
||||||
|
|
||||||
|
const result = msg`${userName} validated a 2FA token for the document`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
anonymous: result,
|
||||||
|
identified: result,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED }, ({ data }) => {
|
||||||
|
const userName = prefix || _(msg`Recipient`);
|
||||||
|
|
||||||
|
const result = msg`${userName} failed to validate a 2FA token for the document`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
anonymous: result,
|
||||||
|
identified: result,
|
||||||
|
};
|
||||||
|
})
|
||||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
|
||||||
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
|
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
|
||||||
identified: data.isResending
|
identified: data.isResending
|
||||||
|
|||||||
@ -0,0 +1,94 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from '@documenso/lib/server-only/2fa/email/constants';
|
||||||
|
import { send2FATokenEmail } from '@documenso/lib/server-only/2fa/email/send-2fa-token-email';
|
||||||
|
import { DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { procedure } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZAccessAuthRequest2FAEmailRequestSchema,
|
||||||
|
ZAccessAuthRequest2FAEmailResponseSchema,
|
||||||
|
} from './access-auth-request-2fa-email.types';
|
||||||
|
|
||||||
|
export const accessAuthRequest2FAEmailRoute = procedure
|
||||||
|
.input(ZAccessAuthRequest2FAEmailRequestSchema)
|
||||||
|
.output(ZAccessAuthRequest2FAEmailResponseSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { token } = input;
|
||||||
|
|
||||||
|
const user = ctx.user;
|
||||||
|
|
||||||
|
// Get document and recipient by token
|
||||||
|
const document = await prisma.document.findFirst({
|
||||||
|
where: {
|
||||||
|
recipients: {
|
||||||
|
some: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
recipients: {
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Document not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [recipient] = document.recipients;
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipient.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '2FA is not required for this document',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (user && recipient.email !== user.email) {
|
||||||
|
// throw new TRPCError({
|
||||||
|
// code: 'UNAUTHORIZED',
|
||||||
|
// message: 'User does not match recipient',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
const expiresAt = DateTime.now().plus({ minutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES });
|
||||||
|
|
||||||
|
await send2FATokenEmail({
|
||||||
|
token,
|
||||||
|
documentId: document.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
expiresAt: expiresAt.toJSDate(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending access auth 2FA email:', error);
|
||||||
|
|
||||||
|
if (error instanceof TRPCError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: 'Failed to send 2FA email',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZAccessAuthRequest2FAEmailRequestSchema = z.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZAccessAuthRequest2FAEmailResponseSchema = z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
expiresAt: z.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAccessAuthRequest2FAEmailRequest = z.infer<
|
||||||
|
typeof ZAccessAuthRequest2FAEmailRequestSchema
|
||||||
|
>;
|
||||||
|
export type TAccessAuthRequest2FAEmailResponse = z.infer<
|
||||||
|
typeof ZAccessAuthRequest2FAEmailResponseSchema
|
||||||
|
>;
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { router } from '../trpc';
|
import { router } from '../trpc';
|
||||||
|
import { accessAuthRequest2FAEmailRoute } from './access-auth-request-2fa-email';
|
||||||
import { createDocumentRoute } from './create-document';
|
import { createDocumentRoute } from './create-document';
|
||||||
import { createDocumentTemporaryRoute } from './create-document-temporary';
|
import { createDocumentTemporaryRoute } from './create-document-temporary';
|
||||||
import { deleteDocumentRoute } from './delete-document';
|
import { deleteDocumentRoute } from './delete-document';
|
||||||
@ -38,6 +39,10 @@ export const documentRouter = router({
|
|||||||
getDocumentByToken: getDocumentByTokenRoute,
|
getDocumentByToken: getDocumentByTokenRoute,
|
||||||
findDocumentsInternal: findDocumentsInternalRoute,
|
findDocumentsInternal: findDocumentsInternalRoute,
|
||||||
|
|
||||||
|
accessAuth: router({
|
||||||
|
request2FAEmail: accessAuthRequest2FAEmailRoute,
|
||||||
|
}),
|
||||||
|
|
||||||
auditLog: {
|
auditLog: {
|
||||||
find: findDocumentAuditLogsRoute,
|
find: findDocumentAuditLogsRoute,
|
||||||
download: downloadDocumentAuditLogsRoute,
|
download: downloadDocumentAuditLogsRoute,
|
||||||
|
|||||||
@ -525,7 +525,7 @@ export const recipientRouter = router({
|
|||||||
completeDocumentWithToken: procedure
|
completeDocumentWithToken: procedure
|
||||||
.input(ZCompleteDocumentWithTokenMutationSchema)
|
.input(ZCompleteDocumentWithTokenMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { token, documentId, authOptions, nextSigner } = input;
|
const { token, documentId, authOptions, accessAuthOptions, nextSigner } = input;
|
||||||
|
|
||||||
ctx.logger.info({
|
ctx.logger.info({
|
||||||
input: {
|
input: {
|
||||||
@ -537,6 +537,7 @@ export const recipientRouter = router({
|
|||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
authOptions,
|
authOptions,
|
||||||
|
accessAuthOptions,
|
||||||
nextSigner,
|
nextSigner,
|
||||||
userId: ctx.user?.id,
|
userId: ctx.user?.id,
|
||||||
requestMetadata: ctx.metadata.requestMetadata,
|
requestMetadata: ctx.metadata.requestMetadata,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
||||||
import {
|
import {
|
||||||
|
ZRecipientAccessAuthSchema,
|
||||||
ZRecipientAccessAuthTypesSchema,
|
ZRecipientAccessAuthTypesSchema,
|
||||||
ZRecipientActionAuthSchema,
|
ZRecipientActionAuthSchema,
|
||||||
ZRecipientActionAuthTypesSchema,
|
ZRecipientActionAuthTypesSchema,
|
||||||
@ -164,6 +165,7 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
|
|||||||
token: z.string(),
|
token: z.string(),
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
authOptions: ZRecipientActionAuthSchema.optional(),
|
authOptions: ZRecipientActionAuthSchema.optional(),
|
||||||
|
accessAuthOptions: ZRecipientAccessAuthSchema.optional(),
|
||||||
nextSigner: z
|
nextSigner: z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email().max(254),
|
email: z.string().email().max(254),
|
||||||
|
|||||||
Reference in New Issue
Block a user