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..7a0a9c78d --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useState } from 'react'; + +import { useSearchParams } from 'next/navigation'; + +import { Mails } from 'lucide-react'; + +import { ONE_SECOND } from '@documenso/lib/constants/time'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND; + +export default function UnverifiedAccount() { + const [isButtonDisabled, setIsButtonDisabled] = useState(false); + const searchParams = useSearchParams(); + const { toast } = useToast(); + + const token = searchParams?.get('t') ?? ''; + + const { data: { email } = {} } = trpc.profile.getUserFromVerificationToken.useQuery({ token }); + + const { mutateAsync: sendConfirmationEmail } = trpc.profile.sendConfirmationEmail.useMutation(); + + const onResendConfirmationEmail = async () => { + if (!email) { + toast({ + title: 'Unable to send confirmation email', + description: 'Something went wrong while sending the confirmation email. Please try again.', + variant: 'destructive', + }); + + return; + } + + try { + setIsButtonDisabled(true); + + await sendConfirmationEmail({ email: email }); + + toast({ + title: 'Success', + description: 'Verification email sent successfully.', + duration: 5000, + }); + + setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT); + } catch (err) { + setIsButtonDisabled(false); + + toast({ + title: 'Error', + description: 'Something went wrong while sending the confirmation email.', + variant: 'destructive', + }); + } + }; + + return ( +
+
+ +
+
+

Confirm email

+ +

+ To gain full access to your account and unlock all its features, please confirm your email + address by clicking on the link sent to your email address. +

+ + +
+
+ ); +} diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 4e671a569..2924080b0 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'; @@ -9,6 +11,7 @@ import { FcGoogle } from 'react-icons/fc'; import { z } from 'zod'; import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes'; +import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog'; @@ -31,6 +34,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; @@ -54,6 +59,7 @@ export const SignInForm = ({ className }: SignInFormProps) => { const { toast } = useToast(); const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] = useState(false); + const router = useRouter(); const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState< 'totp' | 'backup' @@ -69,6 +75,8 @@ export const SignInForm = ({ className }: SignInFormProps) => { resolver: zodResolver(ZSignInFormSchema), }); + const { mutateAsync: getUser } = trpc.profile.getUserByEmail.useMutation(); + const isSubmitting = form.formState.isSubmitting; const onCloseTwoFactorAuthenticationDialog = () => { @@ -122,6 +130,15 @@ export const SignInForm = ({ className }: SignInFormProps) => { const errorMessage = ERROR_MESSAGES[result.error]; + if (result.error === ErrorCode.UNVERIFIED_EMAIL) { + const user = await getUser({ email }); + const token = user?.VerificationToken[user.VerificationToken.length - 1].token; + + router.push(`/unverified-account?t=${token}`); + + 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 b91b4a9fd..526836ca7 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -1,7 +1,8 @@ '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'; import { z } from 'zod'; @@ -42,6 +43,7 @@ export type SignUpFormProps = { export const SignUpForm = ({ className }: SignUpFormProps) => { const { toast } = useToast(); const analytics = useAnalytics(); + const router = useRouter(); const form = useForm({ values: { @@ -61,10 +63,12 @@ export const SignUpForm = ({ className }: SignUpFormProps) => { try { await signup({ name, email, password, signature }); - await signIn('credentials', { - email, - password, - callbackUrl: '/', + router.push('/signin'); + + toast({ + title: 'Registration Successful', + description: 'You have successfully registered. Please sign in to continue.', + 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 3b9492807..4c529d113 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -10,6 +10,7 @@ import GoogleProvider from 'next-auth/providers/google'; import { prisma } from '@documenso/prisma'; +import { ONE_DAY } from '../constants/time'; import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble'; import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa'; import { getUserByEmail } from '../server-only/user/get-user-by-email'; @@ -69,6 +70,17 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { } } + const userCreationDate = user?.createdAt; + const createdWithinLast72Hours = userCreationDate > new Date(Date.now() - ONE_DAY * 3); + + /* + avoid messing with the users who signed up before the email verification requirement + the error is thrown only if the user doesn't have a verified email and the account was created within the last 72 hours + */ + if (!user.emailVerified && createdWithinLast72Hours) { + 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-user-by-email.ts b/packages/lib/server-only/user/get-user-by-email.ts index 0a2ef8d16..8c61202a2 100644 --- a/packages/lib/server-only/user/get-user-by-email.ts +++ b/packages/lib/server-only/user/get-user-by-email.ts @@ -9,5 +9,8 @@ export const getUserByEmail = async ({ email }: GetUserByEmailOptions) => { where: { email: email.toLowerCase(), }, + include: { + VerificationToken: true, + }, }); }; diff --git a/packages/lib/server-only/user/get-user-by-verification-token.ts b/packages/lib/server-only/user/get-user-by-verification-token.ts new file mode 100644 index 000000000..b33506d6e --- /dev/null +++ b/packages/lib/server-only/user/get-user-by-verification-token.ts @@ -0,0 +1,17 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetUserByVerificationTokenOptions { + token: string; +} + +export const getUserByVerificationToken = async ({ token }: GetUserByVerificationTokenOptions) => { + return await prisma.user.findFirstOrThrow({ + where: { + VerificationToken: { + some: { + token, + }, + }, + }, + }); +}; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 4dcf4ca93..79c67ed0c 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,7 +1,9 @@ import { TRPCError } from '@trpc/server'; import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; +import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; +import { getUserByVerificationToken } from '@documenso/lib/server-only/user/get-user-by-verification-token'; import { resetPassword } from '@documenso/lib/server-only/user/reset-password'; import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token'; import { updatePassword } from '@documenso/lib/server-only/user/update-password'; @@ -12,7 +14,9 @@ import { ZConfirmEmailMutationSchema, ZForgotPasswordFormSchema, ZResetPasswordFormSchema, + ZRetrieveUserByEmailMutationSchema, ZRetrieveUserByIdQuerySchema, + ZRetrieveUserByVerificationTokenQuerySchema, ZUpdatePasswordMutationSchema, ZUpdateProfileMutationSchema, } from './schema'; @@ -31,6 +35,36 @@ export const profileRouter = router({ } }), + getUserByEmail: procedure + .input(ZRetrieveUserByEmailMutationSchema) + .mutation(async ({ input }) => { + try { + const { email } = input; + + return await getUserByEmail({ email }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to retrieve the specified account. Please try again.', + }); + } + }), + + getUserFromVerificationToken: procedure + .input(ZRetrieveUserByVerificationTokenQuerySchema) + .query(async ({ input }) => { + try { + const { token } = input; + + return await getUserByVerificationToken({ token }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to retrieve the specified account. Please try again.', + }); + } + }), + updateProfile: authenticatedProcedure .input(ZUpdateProfileMutationSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index ef9ca2a14..671756e94 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -4,6 +4,14 @@ export const ZRetrieveUserByIdQuerySchema = z.object({ id: z.number().min(1), }); +export const ZRetrieveUserByEmailMutationSchema = z.object({ + email: z.string().email().min(1), +}); + +export const ZRetrieveUserByVerificationTokenQuerySchema = z.object({ + token: z.string().min(1), +}); + export const ZUpdateProfileMutationSchema = z.object({ name: z.string().min(1), signature: z.string(),