diff --git a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx new file mode 100644 index 000000000..f4b8b90d7 --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx @@ -0,0 +1,27 @@ +import { Mails } from 'lucide-react'; + +import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-email'; + +export default function UnverifiedAccount() { + return ( +
+
+ +
+
+

Confirm email

+ +

+ To gain access to your account, please confirm your email address by clicking on the + confirmation link from your inbox. +

+ +

+ If you don't find the confirmation link in your inbox, you can request a new one below. +

+ + +
+
+ ); +} diff --git a/apps/web/src/components/forms/send-confirmation-email.tsx b/apps/web/src/components/forms/send-confirmation-email.tsx new file mode 100644 index 000000000..33247bf9f --- /dev/null +++ b/apps/web/src/components/forms/send-confirmation-email.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export const ZSendConfirmationEmailFormSchema = z.object({ + email: z.string().email().min(1), +}); + +export type TSendConfirmationEmailFormSchema = z.infer; + +export type SendConfirmationEmailFormProps = { + className?: string; +}; + +export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFormProps) => { + const { toast } = useToast(); + + const form = useForm({ + values: { + email: '', + }, + resolver: zodResolver(ZSendConfirmationEmailFormSchema), + }); + + const isSubmitting = form.formState.isSubmitting; + + const { mutateAsync: sendConfirmationEmail } = trpc.profile.sendConfirmationEmail.useMutation(); + + const onFormSubmit = async ({ email }: TSendConfirmationEmailFormSchema) => { + try { + await sendConfirmationEmail({ email }); + + toast({ + title: 'Confirmation email sent', + description: + 'A confirmation email has been sent, and it should arrive in your inbox shortly.', + duration: 5000, + }); + + form.reset(); + } catch (err) { + toast({ + title: 'An error occurred while sending your confirmation email', + description: 'Please try again and make sure you enter the correct email address.', + variant: 'destructive', + }); + } + }; + + return ( +
+ +
+ ( + + Email address + + + + + )} + /> + + + + +
+
+ + ); +}; diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index b21e9621b..ec690a568 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + import { zodResolver } from '@hookform/resolvers/zod'; import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; @@ -38,6 +40,8 @@ const ERROR_MESSAGES: Partial> = { 'This account appears to be using a social login method, please sign in using that method', [ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect', [ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect', + [ErrorCode.UNVERIFIED_EMAIL]: + 'This account has not been verified. Please verify your account before signing in.', }; const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS; @@ -63,6 +67,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign const { toast } = useToast(); const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] = useState(false); + const router = useRouter(); const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState< 'totp' | 'backup' @@ -130,6 +135,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign const errorMessage = ERROR_MESSAGES[result.error]; + if (result.error === ErrorCode.UNVERIFIED_EMAIL) { + router.push(`/unverified-account`); + + toast({ + title: 'Unable to sign in', + description: errorMessage ?? 'An unknown error occurred', + }); + + return; + } + toast({ variant: 'destructive', title: 'Unable to sign in', diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index 430c7ebdf..7082bcee3 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useRouter } from 'next/navigation'; + import { zodResolver } from '@hookform/resolvers/zod'; import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; @@ -55,6 +57,7 @@ export type SignUpFormProps = { export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => { const { toast } = useToast(); const analytics = useAnalytics(); + const router = useRouter(); const form = useForm({ values: { @@ -74,10 +77,13 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign try { await signup({ name, email, password, signature }); - await signIn('credentials', { - email, - password, - callbackUrl: SIGN_UP_REDIRECT_PATH, + router.push(`/unverified-account`); + + toast({ + title: 'Registration Successful', + description: + 'You have successfully registered. Please verify your account by clicking on the link you received in the email.', + duration: 5000, }); analytics.capture('App: User Sign Up', { diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index f23295a81..b944b6e7b 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -13,7 +13,9 @@ import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/cl import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble'; import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa'; +import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id'; import { getUserByEmail } from '../server-only/user/get-user-by-email'; +import { sendConfirmationToken } from '../server-only/user/send-confirmation-token'; import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata'; import { ErrorCode } from './error-codes'; @@ -90,6 +92,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { } } + if (!user.emailVerified) { + const mostRecentToken = await getMostRecentVerificationTokenByUserId({ + userId: user.id, + }); + + if ( + !mostRecentToken || + mostRecentToken.expires.valueOf() <= Date.now() || + DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5 + ) { + await sendConfirmationToken({ email }); + } + + throw new Error(ErrorCode.UNVERIFIED_EMAIL); + } + return { id: Number(user.id), email: user.email, diff --git a/packages/lib/next-auth/error-codes.ts b/packages/lib/next-auth/error-codes.ts index c3dfafece..6e1b7488b 100644 --- a/packages/lib/next-auth/error-codes.ts +++ b/packages/lib/next-auth/error-codes.ts @@ -19,4 +19,5 @@ export const ErrorCode = { INCORRECT_PASSWORD: 'INCORRECT_PASSWORD', MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY', MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE', + UNVERIFIED_EMAIL: 'UNVERIFIED_EMAIL', } as const; diff --git a/packages/lib/server-only/user/get-most-recent-verification-token-by-user-id.ts b/packages/lib/server-only/user/get-most-recent-verification-token-by-user-id.ts new file mode 100644 index 000000000..d9adc4498 --- /dev/null +++ b/packages/lib/server-only/user/get-most-recent-verification-token-by-user-id.ts @@ -0,0 +1,18 @@ +import { prisma } from '@documenso/prisma'; + +export type GetMostRecentVerificationTokenByUserIdOptions = { + userId: number; +}; + +export const getMostRecentVerificationTokenByUserId = async ({ + userId, +}: GetMostRecentVerificationTokenByUserIdOptions) => { + return await prisma.verificationToken.findFirst({ + where: { + userId, + }, + orderBy: { + createdAt: 'desc', + }, + }); +}; diff --git a/packages/lib/server-only/user/send-confirmation-token.ts b/packages/lib/server-only/user/send-confirmation-token.ts index 5206d202e..ef7c4b104 100644 --- a/packages/lib/server-only/user/send-confirmation-token.ts +++ b/packages/lib/server-only/user/send-confirmation-token.ts @@ -1,13 +1,20 @@ import crypto from 'crypto'; +import { DateTime } from 'luxon'; import { prisma } from '@documenso/prisma'; import { ONE_HOUR } from '../../constants/time'; import { sendConfirmationEmail } from '../auth/send-confirmation-email'; +import { getMostRecentVerificationTokenByUserId } from './get-most-recent-verification-token-by-user-id'; const IDENTIFIER = 'confirmation-email'; -export const sendConfirmationToken = async ({ email }: { email: string }) => { +type SendConfirmationTokenOptions = { email: string; force?: boolean }; + +export const sendConfirmationToken = async ({ + email, + force = false, +}: SendConfirmationTokenOptions) => { const token = crypto.randomBytes(20).toString('hex'); const user = await prisma.user.findFirst({ @@ -20,6 +27,21 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => { throw new Error('User not found'); } + if (user.emailVerified) { + throw new Error('Email verified'); + } + + const mostRecentToken = await getMostRecentVerificationTokenByUserId({ userId: user.id }); + + // If we've sent a token in the last 5 minutes, don't send another one + if ( + !force && + mostRecentToken?.createdAt && + DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5 + ) { + return; + } + const createdToken = await prisma.verificationToken.create({ data: { identifier: IDENTIFIER, @@ -37,5 +59,11 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => { throw new Error(`Failed to create the verification token`); } - return sendConfirmationEmail({ userId: user.id }); + try { + await sendConfirmationEmail({ userId: user.id }); + + return { success: true }; + } catch (err) { + throw new Error(`Failed to send the confirmation email`); + } }; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 4a0d47345..bceee020a 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -141,7 +141,7 @@ export const profileRouter = router({ try { const { email } = input; - return sendConfirmationToken({ email }); + return await sendConfirmationToken({ email }); } catch (err) { let message = 'We were unable to send a confirmation email. Please try again.';