mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 09:12:02 +10:00
feat: email verification for document signing 2FA
This commit is contained in:
@ -8,6 +8,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
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 { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } 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';
|
||||||
@ -20,6 +21,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 +54,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 +64,71 @@ export const DocumentSigningAuth2FA = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
|
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
|
||||||
|
const [isEmailCodeSent, setIsEmailCodeSent] = useState(false);
|
||||||
|
const [isEmailCodeSending, setIsEmailCodeSending] = useState(false);
|
||||||
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||||
|
const [verificationMethod, setVerificationMethod] = useState<'app' | 'email'>(
|
||||||
|
user?.twoFactorEnabled ? 'app' : 'email',
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendVerificationMutation = trpc.auth.sendEmailVerification.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsEmailCodeSent(true);
|
||||||
|
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) {
|
||||||
|
// Error is handled in the mutation callbacks
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
|
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
|
||||||
try {
|
try {
|
||||||
setIsCurrentlyAuthenticating(true);
|
setIsCurrentlyAuthenticating(true);
|
||||||
|
|
||||||
|
if (verificationMethod === 'email') {
|
||||||
|
// Verify the email code first
|
||||||
|
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);
|
const error = AppError.parseError(err);
|
||||||
setFormErrorCode(error.code);
|
setFormErrorCode(error.code);
|
||||||
|
|
||||||
// Todo: Alert.
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -91,20 +139,40 @@ export const DocumentSigningAuth2FA = ({
|
|||||||
|
|
||||||
setIs2FASetupSuccessful(false);
|
setIs2FASetupSuccessful(false);
|
||||||
setFormErrorCode(null);
|
setFormErrorCode(null);
|
||||||
|
setIsEmailCodeSent(false);
|
||||||
|
|
||||||
|
if (open && !user?.twoFactorEnabled) {
|
||||||
|
setVerificationMethod('email');
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open]);
|
}, [open, user?.twoFactorEnabled]);
|
||||||
|
|
||||||
if (!user?.twoFactorEnabled && !is2FASetupSuccessful) {
|
useEffect(() => {
|
||||||
|
if (open && verificationMethod === 'email' && !isEmailCodeSent && !isEmailCodeSending) {
|
||||||
|
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 +197,113 @@ 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>{actionTarget === 'DOCUMENT' ? 'Sign Document' : 'Sign Field'}</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}
|
||||||
|
onClick={() => void sendEmailVerificationCode()}
|
||||||
|
>
|
||||||
|
{isEmailCodeSending ? <Trans>Sending...</Trans> : <Trans>Resend code</Trans>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
62
packages/email/templates/verification-code.tsx
Normal file
62
packages/email/templates/verification-code.tsx
Normal 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;
|
||||||
120
packages/lib/server-only/2fa/send-email-verification.ts
Normal file
120
packages/lib/server-only/2fa/send-email-verification.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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;
|
||||||
@ -53,18 +53,19 @@ model User {
|
|||||||
avatarImageId String?
|
avatarImageId String?
|
||||||
disabled Boolean @default(false)
|
disabled Boolean @default(false)
|
||||||
|
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
documents Document[]
|
documents Document[]
|
||||||
subscriptions Subscription[]
|
subscriptions Subscription[]
|
||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
ownedTeams Team[]
|
ownedTeams Team[]
|
||||||
ownedPendingTeams TeamPending[]
|
ownedPendingTeams TeamPending[]
|
||||||
teamMembers TeamMember[]
|
teamMembers TeamMember[]
|
||||||
twoFactorSecret String?
|
twoFactorSecret String?
|
||||||
twoFactorEnabled Boolean @default(false)
|
twoFactorEnabled Boolean @default(false)
|
||||||
twoFactorBackupCodes String?
|
twoFactorBackupCodes String?
|
||||||
url String? @unique
|
url String? @unique
|
||||||
|
twoFactorEmailVerification UserTwoFactorEmailVerification?
|
||||||
|
|
||||||
profile UserProfile?
|
profile UserProfile?
|
||||||
verificationTokens VerificationToken[]
|
verificationTokens VerificationToken[]
|
||||||
@ -795,3 +796,12 @@ model AvatarImage {
|
|||||||
team Team[]
|
team Team[]
|
||||||
user User[]
|
user User[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserTwoFactorEmailVerification {
|
||||||
|
userId Int @id
|
||||||
|
verificationCode String
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
|
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
|
||||||
|
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import {
|
||||||
|
sendEmailVerification,
|
||||||
|
verifyEmailCode,
|
||||||
|
} from '@documenso/lib/server-only/2fa/send-email-verification';
|
||||||
import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
|
import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
|
||||||
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
|
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
|
||||||
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
|
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
|
||||||
@ -8,6 +13,7 @@ import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
|
|||||||
import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
|
import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
|
||||||
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
|
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@ -15,7 +21,9 @@ import {
|
|||||||
ZCreatePasskeyMutationSchema,
|
ZCreatePasskeyMutationSchema,
|
||||||
ZDeletePasskeyMutationSchema,
|
ZDeletePasskeyMutationSchema,
|
||||||
ZFindPasskeysQuerySchema,
|
ZFindPasskeysQuerySchema,
|
||||||
|
ZSendEmailVerificationMutationSchema,
|
||||||
ZUpdatePasskeyMutationSchema,
|
ZUpdatePasskeyMutationSchema,
|
||||||
|
ZVerifyEmailCodeMutationSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
export const authRouter = router({
|
export const authRouter = router({
|
||||||
@ -98,4 +106,68 @@ export const authRouter = router({
|
|||||||
requestMetadata: ctx.metadata.requestMetadata,
|
requestMetadata: ctx.metadata.requestMetadata,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Email verification for document signing
|
||||||
|
sendEmailVerification: authenticatedProcedure
|
||||||
|
.input(ZSendEmailVerificationMutationSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { recipientId } = input;
|
||||||
|
const userId = ctx.user.id;
|
||||||
|
let email = ctx.user.email;
|
||||||
|
|
||||||
|
// If recipientId is provided, fetch that recipient's details
|
||||||
|
if (recipientId) {
|
||||||
|
const recipient = await prisma.recipient.findUnique({
|
||||||
|
where: {
|
||||||
|
id: recipientId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new AppError('NOT_FOUND', {
|
||||||
|
message: 'Recipient not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
email = recipient.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendEmailVerification({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
verifyEmailCode: authenticatedProcedure
|
||||||
|
.input(ZVerifyEmailCodeMutationSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { code, recipientId } = input;
|
||||||
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
|
// If recipientId is provided, check that the user has access to it
|
||||||
|
if (recipientId) {
|
||||||
|
const recipient = await prisma.recipient.findUnique({
|
||||||
|
where: {
|
||||||
|
id: recipientId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new AppError('NOT_FOUND', {
|
||||||
|
message: 'Recipient not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifyEmailCode({
|
||||||
|
userId,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user