mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 17:21:41 +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 { 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 { Button } from '@documenso/ui/primitives/button';
|
||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
@ -20,6 +21,8 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||
|
||||
@ -51,6 +54,7 @@ export const DocumentSigningAuth2FA = ({
|
||||
}: DocumentSigningAuth2FAProps) => {
|
||||
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
|
||||
useRequiredDocumentSigningAuthContext();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<T2FAAuthFormSchema>({
|
||||
resolver: zodResolver(Z2FAAuthFormSchema),
|
||||
@ -60,27 +64,71 @@ export const DocumentSigningAuth2FA = ({
|
||||
});
|
||||
|
||||
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
|
||||
const [isEmailCodeSent, setIsEmailCodeSent] = useState(false);
|
||||
const [isEmailCodeSending, setIsEmailCodeSending] = useState(false);
|
||||
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) => {
|
||||
try {
|
||||
setIsCurrentlyAuthenticating(true);
|
||||
|
||||
if (verificationMethod === 'email') {
|
||||
// Verify the email code first
|
||||
await verifyCodeMutation.mutateAsync({
|
||||
code: token,
|
||||
recipientId: recipient.id,
|
||||
});
|
||||
}
|
||||
|
||||
await onReauthFormSubmit({
|
||||
type: DocumentAuth.TWO_FACTOR_AUTH,
|
||||
token,
|
||||
});
|
||||
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
setFormErrorCode(error.code);
|
||||
|
||||
// Todo: Alert.
|
||||
}
|
||||
};
|
||||
|
||||
@ -91,20 +139,40 @@ export const DocumentSigningAuth2FA = ({
|
||||
|
||||
setIs2FASetupSuccessful(false);
|
||||
setFormErrorCode(null);
|
||||
setIsEmailCodeSent(false);
|
||||
|
||||
if (open && !user?.twoFactorEnabled) {
|
||||
setVerificationMethod('email');
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<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">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? (
|
||||
<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()}.`
|
||||
)}
|
||||
</p>
|
||||
@ -129,59 +197,113 @@ export const DocumentSigningAuth2FA = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>2FA token</FormLabel>
|
||||
<div className="space-y-4">
|
||||
{user?.twoFactorEnabled && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{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>
|
||||
{verificationMethod === 'email' && (
|
||||
<Alert variant="secondary">
|
||||
<AlertDescription>
|
||||
{isEmailCodeSent ? (
|
||||
<p>
|
||||
<Trans>
|
||||
A verification code has been sent to {recipient.email}. Please enter it below to
|
||||
continue.
|
||||
</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<Trans>
|
||||
We'll send a verification code to {recipient.email} to verify your identity.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||
<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}>
|
||||
<Trans>{actionTarget === 'DOCUMENT' ? 'Sign Document' : 'Sign Field'}</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
<FormControl>
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</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?
|
||||
disabled Boolean @default(false)
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
documents Document[]
|
||||
subscriptions Subscription[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
ownedTeams Team[]
|
||||
ownedPendingTeams TeamPending[]
|
||||
teamMembers TeamMember[]
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorBackupCodes String?
|
||||
url String? @unique
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
documents Document[]
|
||||
subscriptions Subscription[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
ownedTeams Team[]
|
||||
ownedPendingTeams TeamPending[]
|
||||
teamMembers TeamMember[]
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorBackupCodes String?
|
||||
url String? @unique
|
||||
twoFactorEmailVerification UserTwoFactorEmailVerification?
|
||||
|
||||
profile UserProfile?
|
||||
verificationTokens VerificationToken[]
|
||||
@ -795,3 +796,12 @@ model AvatarImage {
|
||||
team Team[]
|
||||
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 { AppError } from '@documenso/lib/errors/app-error';
|
||||
import {
|
||||
sendEmailVerification,
|
||||
verifyEmailCode,
|
||||
} from '@documenso/lib/server-only/2fa/send-email-verification';
|
||||
import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
|
||||
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
|
||||
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
|
||||
@ -8,6 +13,7 @@ import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
|
||||
import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
|
||||
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
@ -15,7 +21,9 @@ import {
|
||||
ZCreatePasskeyMutationSchema,
|
||||
ZDeletePasskeyMutationSchema,
|
||||
ZFindPasskeysQuerySchema,
|
||||
ZSendEmailVerificationMutationSchema,
|
||||
ZUpdatePasskeyMutationSchema,
|
||||
ZVerifyEmailCodeMutationSchema,
|
||||
} from './schema';
|
||||
|
||||
export const authRouter = router({
|
||||
@ -98,4 +106,68 @@ export const authRouter = router({
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
}),
|
||||
|
||||
// Email verification for document signing
|
||||
sendEmailVerification: authenticatedProcedure
|
||||
.input(ZSendEmailVerificationMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { recipientId } = input;
|
||||
const userId = ctx.user.id;
|
||||
let email = ctx.user.email;
|
||||
|
||||
// If recipientId is provided, fetch that recipient's details
|
||||
if (recipientId) {
|
||||
const recipient = await prisma.recipient.findUnique({
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError('NOT_FOUND', {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
email = recipient.email;
|
||||
}
|
||||
|
||||
return sendEmailVerification({
|
||||
userId,
|
||||
email,
|
||||
});
|
||||
}),
|
||||
|
||||
verifyEmailCode: authenticatedProcedure
|
||||
.input(ZVerifyEmailCodeMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { code, recipientId } = input;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
// If recipientId is provided, check that the user has access to it
|
||||
if (recipientId) {
|
||||
const recipient = await prisma.recipient.findUnique({
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError('NOT_FOUND', {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return verifyEmailCode({
|
||||
userId,
|
||||
code,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@ -71,3 +71,18 @@ export const ZFindPasskeysQuerySchema = ZFindSearchParamsSchema.extend({
|
||||
});
|
||||
|
||||
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