From 4aefb809894c3ff7f98eb572b458df2edb6c9d40 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 16 Jan 2024 14:25:05 +0200 Subject: [PATCH 01/14] feat: restrict app access for unverified users --- .../unverified-account/page.tsx | 80 +++++++++++++++++++ apps/web/src/components/forms/signin.tsx | 17 ++++ apps/web/src/components/forms/signup.tsx | 14 ++-- packages/lib/next-auth/auth-options.ts | 12 +++ packages/lib/next-auth/error-codes.ts | 1 + .../lib/server-only/user/get-user-by-email.ts | 3 + .../user/get-user-by-verification-token.ts | 17 ++++ packages/trpc/server/profile-router/router.ts | 34 ++++++++ packages/trpc/server/profile-router/schema.ts | 8 ++ 9 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/app/(unauthenticated)/unverified-account/page.tsx create mode 100644 packages/lib/server-only/user/get-user-by-verification-token.ts 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(), From 49ecfc1a2cf01e3bcdf195819657b463faf7e890 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Thu, 25 Jan 2024 15:42:40 +0200 Subject: [PATCH 02/14] chore: refactor --- .../unverified-account/page.tsx | 16 +++------------- apps/web/src/components/forms/signin.tsx | 9 ++++----- apps/web/src/components/forms/signup.tsx | 5 ++++- packages/lib/next-auth/auth-options.ts | 10 +--------- .../lib/server-only/user/get-user-by-email.ts | 3 --- .../user/get-user-by-verification-token.ts | 17 ----------------- packages/trpc/server/profile-router/router.ts | 19 +------------------ 7 files changed, 13 insertions(+), 66 deletions(-) delete mode 100644 packages/lib/server-only/user/get-user-by-verification-token.ts diff --git a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx index 7a0a9c78d..456971a9f 100644 --- a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx +++ b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx @@ -20,25 +20,15 @@ export default function UnverifiedAccount() { 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 }); + // TODO: decrypt email and send it + + await sendConfirmationEmail({ email: token ?? '' }); toast({ title: 'Success', diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index c79021396..4e3701c84 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -62,6 +62,8 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) = useState(false); const router = useRouter(); + const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation(); + const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState< 'totp' | 'backup' >('totp'); @@ -76,8 +78,6 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) = resolver: zodResolver(ZSignInFormSchema), }); - const { mutateAsync: getUser } = trpc.profile.getUserByEmail.useMutation(); - const isSubmitting = form.formState.isSubmitting; const onCloseTwoFactorAuthenticationDialog = () => { @@ -132,10 +132,9 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: 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; + const encryptedEmail = await encryptSecondaryData({ data: email }); - router.push(`/unverified-account?t=${token}`); + router.push(`/unverified-account?t=${encryptedEmail}`); return; } diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index 6258dcdee..190084226 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -62,12 +62,15 @@ export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) = const isSubmitting = form.formState.isSubmitting; const { mutateAsync: signup } = trpc.auth.signup.useMutation(); + const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation(); const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => { try { await signup({ name, email, password, signature }); - router.push('/signin'); + const encryptedEmail = await encryptSecondaryData({ data: email }); + + router.push(`/unverified-account?t=${encryptedEmail}`); toast({ title: 'Registration Successful', diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index ed4aeaf44..37f1ed864 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -11,7 +11,6 @@ import GoogleProvider from 'next-auth/providers/google'; import { prisma } from '@documenso/prisma'; import { IdentityProvider } from '@documenso/prisma/client'; -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'; @@ -71,14 +70,7 @@ 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) { + if (!user.emailVerified) { throw new Error(ErrorCode.UNVERIFIED_EMAIL); } 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 8c61202a2..0a2ef8d16 100644 --- a/packages/lib/server-only/user/get-user-by-email.ts +++ b/packages/lib/server-only/user/get-user-by-email.ts @@ -9,8 +9,5 @@ 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 deleted file mode 100644 index b33506d6e..000000000 --- a/packages/lib/server-only/user/get-user-by-verification-token.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 79c67ed0c..09ee0351f 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -3,7 +3,6 @@ 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'; @@ -16,7 +15,6 @@ import { ZResetPasswordFormSchema, ZRetrieveUserByEmailMutationSchema, ZRetrieveUserByIdQuerySchema, - ZRetrieveUserByVerificationTokenQuerySchema, ZUpdatePasswordMutationSchema, ZUpdateProfileMutationSchema, } from './schema'; @@ -50,21 +48,6 @@ export const profileRouter = router({ } }), - 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 }) => { @@ -153,7 +136,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.'; From 311c8da8fc8ad5ded8f8ed11146a517e740f07b5 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Thu, 25 Jan 2024 17:24:37 +0200 Subject: [PATCH 03/14] chore: encrypt and decrypt email addr --- .../src/app/(unauthenticated)/unverified-account/page.tsx | 6 ++---- packages/trpc/server/profile-router/router.ts | 5 ++++- packages/trpc/server/profile-router/schema.ts | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx index 456971a9f..5199249e0 100644 --- a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx +++ b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx @@ -18,7 +18,7 @@ export default function UnverifiedAccount() { const searchParams = useSearchParams(); const { toast } = useToast(); - const token = searchParams?.get('t') ?? ''; + const encryptedEmail = searchParams?.get('t') ?? ''; // TODO: choose a better name instead of t const { mutateAsync: sendConfirmationEmail } = trpc.profile.sendConfirmationEmail.useMutation(); @@ -26,9 +26,7 @@ export default function UnverifiedAccount() { try { setIsButtonDisabled(true); - // TODO: decrypt email and send it - - await sendConfirmationEmail({ email: token ?? '' }); + await sendConfirmationEmail({ email: encryptedEmail }); toast({ title: 'Success', diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 09ee0351f..510e2a6fd 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server'; +import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; 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'; @@ -136,7 +137,9 @@ export const profileRouter = router({ try { const { email } = input; - return await sendConfirmationToken({ email }); + const decryptedEmail = decryptSecondaryData(email); + + return await sendConfirmationToken({ email: decryptedEmail ?? '' }); // TODO: fix this tomorrow } catch (err) { let message = 'We were unable to send a confirmation email. Please try again.'; diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index 671756e94..5aa9844ca 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -30,9 +30,9 @@ export const ZResetPasswordFormSchema = z.object({ password: z.string().min(6), token: z.string().min(1), }); - +// TODO: revisit this export const ZConfirmEmailMutationSchema = z.object({ - email: z.string().email().min(1), + email: z.string().min(1), }); export type TRetrieveUserByIdQuerySchema = z.infer; From e2fa01509dc602aedc9d9764a0f0f24e0e8208c5 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Thu, 25 Jan 2024 17:33:35 +0200 Subject: [PATCH 04/14] chore: avoid returning unnecessary info --- packages/lib/server-only/user/send-confirmation-token.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/lib/server-only/user/send-confirmation-token.ts b/packages/lib/server-only/user/send-confirmation-token.ts index 5206d202e..6c070125b 100644 --- a/packages/lib/server-only/user/send-confirmation-token.ts +++ b/packages/lib/server-only/user/send-confirmation-token.ts @@ -37,5 +37,12 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => { throw new Error(`Failed to create the verification token`); } - return sendConfirmationEmail({ userId: user.id }); + // TODO: Revisit tomorrow + try { + await sendConfirmationEmail({ userId: user.id }); + + return { success: true }; + } catch (err) { + throw new Error(`Failed to send the confirmation email`); + } }; From b2cca9afb677da0505189aa5e0f68e57dc1250e5 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 26 Jan 2024 13:27:36 +0200 Subject: [PATCH 05/14] chore: refactor --- .../app/(unauthenticated)/unverified-account/page.tsx | 4 ++-- apps/web/src/components/forms/signin.tsx | 2 +- apps/web/src/components/forms/signup.tsx | 2 +- .../lib/server-only/user/send-confirmation-token.ts | 1 - packages/trpc/server/profile-router/router.ts | 10 +++++++--- packages/trpc/server/profile-router/schema.ts | 4 ++-- 6 files changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx index 5199249e0..dc98044ae 100644 --- a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx +++ b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx @@ -18,7 +18,7 @@ export default function UnverifiedAccount() { const searchParams = useSearchParams(); const { toast } = useToast(); - const encryptedEmail = searchParams?.get('t') ?? ''; // TODO: choose a better name instead of t + const encryptedEmail = searchParams?.get('token') ?? ''; const { mutateAsync: sendConfirmationEmail } = trpc.profile.sendConfirmationEmail.useMutation(); @@ -26,7 +26,7 @@ export default function UnverifiedAccount() { try { setIsButtonDisabled(true); - await sendConfirmationEmail({ email: encryptedEmail }); + await sendConfirmationEmail({ encryptedEmail }); toast({ title: 'Success', diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 4e3701c84..0353333cf 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -134,7 +134,7 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) = if (result.error === ErrorCode.UNVERIFIED_EMAIL) { const encryptedEmail = await encryptSecondaryData({ data: email }); - router.push(`/unverified-account?t=${encryptedEmail}`); + router.push(`/unverified-account?token=${encryptedEmail}`); return; } diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index 190084226..bc7ee0ce5 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -70,7 +70,7 @@ export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) = const encryptedEmail = await encryptSecondaryData({ data: email }); - router.push(`/unverified-account?t=${encryptedEmail}`); + router.push(`/unverified-account?token=${encryptedEmail}`); toast({ title: 'Registration Successful', diff --git a/packages/lib/server-only/user/send-confirmation-token.ts b/packages/lib/server-only/user/send-confirmation-token.ts index 6c070125b..af4a97a48 100644 --- a/packages/lib/server-only/user/send-confirmation-token.ts +++ b/packages/lib/server-only/user/send-confirmation-token.ts @@ -37,7 +37,6 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => { throw new Error(`Failed to create the verification token`); } - // TODO: Revisit tomorrow try { await sendConfirmationEmail({ userId: user.id }); diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 510e2a6fd..44d0f59bd 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -135,11 +135,15 @@ export const profileRouter = router({ .input(ZConfirmEmailMutationSchema) .mutation(async ({ input }) => { try { - const { email } = input; + const { encryptedEmail } = input; - const decryptedEmail = decryptSecondaryData(email); + const decryptedEmail = decryptSecondaryData(encryptedEmail); - return await sendConfirmationToken({ email: decryptedEmail ?? '' }); // TODO: fix this tomorrow + if (!decryptedEmail) { + throw new Error('Email is required'); + } + + return await sendConfirmationToken({ email: decryptedEmail }); } catch (err) { let message = 'We were unable to send a confirmation email. Please try again.'; diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index 5aa9844ca..897a4912d 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -30,9 +30,9 @@ export const ZResetPasswordFormSchema = z.object({ password: z.string().min(6), token: z.string().min(1), }); -// TODO: revisit this + export const ZConfirmEmailMutationSchema = z.object({ - email: z.string().min(1), + encryptedEmail: z.string().min(1), }); export type TRetrieveUserByIdQuerySchema = z.infer; From f514d55d27bc23866dd70b6b7c7005bce5891851 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Mon, 29 Jan 2024 09:41:02 +0200 Subject: [PATCH 06/14] chore: removed unused schema --- packages/trpc/server/profile-router/schema.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index 897a4912d..dfde69796 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -8,10 +8,6 @@ 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(), From 1676f5bf6cbcdc63b6cae10ee0be61e1a4be5d52 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Mon, 29 Jan 2024 09:43:38 +0200 Subject: [PATCH 07/14] chore: removed unused code --- packages/trpc/server/profile-router/router.ts | 17 ----------------- packages/trpc/server/profile-router/schema.ts | 4 ---- 2 files changed, 21 deletions(-) diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 44d0f59bd..1faa3c8e6 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -2,7 +2,6 @@ import { TRPCError } from '@trpc/server'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; 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 { resetPassword } from '@documenso/lib/server-only/user/reset-password'; import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token'; @@ -14,7 +13,6 @@ import { ZConfirmEmailMutationSchema, ZForgotPasswordFormSchema, ZResetPasswordFormSchema, - ZRetrieveUserByEmailMutationSchema, ZRetrieveUserByIdQuerySchema, ZUpdatePasswordMutationSchema, ZUpdateProfileMutationSchema, @@ -34,21 +32,6 @@ 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.', - }); - } - }), - 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 dfde69796..135d0d1e8 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -4,10 +4,6 @@ export const ZRetrieveUserByIdQuerySchema = z.object({ id: z.number().min(1), }); -export const ZRetrieveUserByEmailMutationSchema = z.object({ - email: z.string().email().min(1), -}); - export const ZUpdateProfileMutationSchema = z.object({ name: z.string().min(1), signature: z.string(), From cc090adce0918def56279c44d76cecaa21bf5fe5 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 30 Jan 2024 12:54:48 +0200 Subject: [PATCH 08/14] chore: refactor --- .../unverified-account/page.tsx | 53 ++----------------- apps/web/src/components/forms/signin.tsx | 10 ++-- apps/web/src/components/forms/signup.tsx | 8 ++- packages/lib/next-auth/auth-options.ts | 10 ++++ .../lib/server-only/user/get-user-by-email.ts | 3 ++ .../user/send-confirmation-token.ts | 4 ++ packages/trpc/server/profile-router/router.ts | 11 +--- packages/trpc/server/profile-router/schema.ts | 2 +- 8 files changed, 32 insertions(+), 69 deletions(-) diff --git a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx index dc98044ae..9b636f7cf 100644 --- a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx +++ b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx @@ -1,51 +1,8 @@ -'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; +import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-email'; export default function UnverifiedAccount() { - const [isButtonDisabled, setIsButtonDisabled] = useState(false); - const searchParams = useSearchParams(); - const { toast } = useToast(); - - const encryptedEmail = searchParams?.get('token') ?? ''; - - const { mutateAsync: sendConfirmationEmail } = trpc.profile.sendConfirmationEmail.useMutation(); - - const onResendConfirmationEmail = async () => { - try { - setIsButtonDisabled(true); - - await sendConfirmationEmail({ encryptedEmail }); - - 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 (
@@ -55,13 +12,11 @@ export default function UnverifiedAccount() {

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. + To gain access to your account, please confirm your email address by clicking on the + confirmation link from your inbox.

- +
); diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 0353333cf..d0b5e1b60 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -11,7 +11,6 @@ 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'; @@ -62,8 +61,6 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) = useState(false); const router = useRouter(); - const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation(); - const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState< 'totp' | 'backup' >('totp'); @@ -132,9 +129,12 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) = const errorMessage = ERROR_MESSAGES[result.error]; if (result.error === ErrorCode.UNVERIFIED_EMAIL) { - const encryptedEmail = await encryptSecondaryData({ data: email }); + router.push(`/unverified-account`); - router.push(`/unverified-account?token=${encryptedEmail}`); + toast({ + title: 'Unable to sign in', + description: errorMessage ?? 'An unknown error occurred', + }); return; } diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index bc7ee0ce5..4520e00ca 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -62,19 +62,17 @@ export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) = const isSubmitting = form.formState.isSubmitting; const { mutateAsync: signup } = trpc.auth.signup.useMutation(); - const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation(); const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => { try { await signup({ name, email, password, signature }); - const encryptedEmail = await encryptSecondaryData({ data: email }); - - router.push(`/unverified-account?token=${encryptedEmail}`); + router.push(`/unverified-account}`); toast({ title: 'Registration Successful', - description: 'You have successfully registered. Please sign in to continue.', + description: + 'You have successfully registered. Please verify your account by clicking on the link you received in the email.', duration: 5000, }); diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 37f1ed864..1dedfe12b 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -14,6 +14,7 @@ import { IdentityProvider } from '@documenso/prisma/client'; 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'; +import { sendConfirmationToken } from '../server-only/user/send-confirmation-token'; import { ErrorCode } from './error-codes'; export const NEXT_AUTH_OPTIONS: AuthOptions = { @@ -71,6 +72,15 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { } if (!user.emailVerified) { + const totalUserVerificationTokens = user.VerificationToken.length; + const lastUserVerificationToken = user.VerificationToken[totalUserVerificationTokens - 1]; + const expiredToken = + DateTime.fromJSDate(lastUserVerificationToken.expires) <= DateTime.now(); + + if (totalUserVerificationTokens < 1 || expiredToken) { + await sendConfirmationToken({ email }); + } + throw new Error(ErrorCode.UNVERIFIED_EMAIL); } 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/send-confirmation-token.ts b/packages/lib/server-only/user/send-confirmation-token.ts index af4a97a48..a399dd9fc 100644 --- a/packages/lib/server-only/user/send-confirmation-token.ts +++ b/packages/lib/server-only/user/send-confirmation-token.ts @@ -20,6 +20,10 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => { throw new Error('User not found'); } + if (user.emailVerified) { + throw new Error('Email verified'); + } + const createdToken = await prisma.verificationToken.create({ data: { identifier: IDENTIFIER, diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 1faa3c8e6..3d765372b 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,6 +1,5 @@ import { TRPCError } from '@trpc/server'; -import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; import { resetPassword } from '@documenso/lib/server-only/user/reset-password'; @@ -118,15 +117,9 @@ export const profileRouter = router({ .input(ZConfirmEmailMutationSchema) .mutation(async ({ input }) => { try { - const { encryptedEmail } = input; + const { email } = input; - const decryptedEmail = decryptSecondaryData(encryptedEmail); - - if (!decryptedEmail) { - throw new Error('Email is required'); - } - - return await sendConfirmationToken({ email: decryptedEmail }); + return await sendConfirmationToken({ email }); } catch (err) { let message = 'We were unable to send a confirmation email. Please try again.'; diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index 135d0d1e8..ef9ca2a14 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -24,7 +24,7 @@ export const ZResetPasswordFormSchema = z.object({ }); export const ZConfirmEmailMutationSchema = z.object({ - encryptedEmail: z.string().min(1), + email: z.string().email().min(1), }); export type TRetrieveUserByIdQuerySchema = z.infer; From 6053a4a40a55db36c815dbc2bdbf3c140f62860d Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 30 Jan 2024 12:56:32 +0200 Subject: [PATCH 09/14] chore: refactor --- .../forms/send-confirmation-email.tsx | 93 +++++++++++++++++++ apps/web/src/components/forms/signup.tsx | 2 +- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/forms/send-confirmation-email.tsx 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..9e669539e --- /dev/null +++ b/apps/web/src/components/forms/send-confirmation-email.tsx @@ -0,0 +1,93 @@ +'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, +} 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/signup.tsx b/apps/web/src/components/forms/signup.tsx index 4520e00ca..7bfe07968 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -67,7 +67,7 @@ export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) = try { await signup({ name, email, password, signature }); - router.push(`/unverified-account}`); + router.push(`/unverified-account`); toast({ title: 'Registration Successful', From 1852aa4b05717b474089382e9682c3a572fc1296 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 30 Jan 2024 12:57:56 +0200 Subject: [PATCH 10/14] chore: add info --- .../web/src/app/(unauthenticated)/unverified-account/page.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx index 9b636f7cf..f4b8b90d7 100644 --- a/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx +++ b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx @@ -16,6 +16,10 @@ export default function UnverifiedAccount() { confirmation link from your inbox.

+

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

+ From c432261dd8ff7756ad7bc9b1059944d7478fa1c1 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 30 Jan 2024 14:49:31 +0200 Subject: [PATCH 11/14] chore: disable button while form is submitting --- apps/web/src/components/forms/send-confirmation-email.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/forms/send-confirmation-email.tsx b/apps/web/src/components/forms/send-confirmation-email.tsx index 9e669539e..ee073d063 100644 --- a/apps/web/src/components/forms/send-confirmation-email.tsx +++ b/apps/web/src/components/forms/send-confirmation-email.tsx @@ -83,7 +83,7 @@ export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFo )} /> - From 149f416be76ef555f91a5e9bebacc5659ec826b0 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 13 Feb 2024 07:50:22 +0200 Subject: [PATCH 12/14] chore: refactor code --- .../forms/send-confirmation-email.tsx | 50 ++++++++++--------- packages/lib/next-auth/auth-options.ts | 14 ++++-- .../user/get-last-verification-token.ts | 27 ++++++++++ .../lib/server-only/user/get-user-by-email.ts | 3 -- 4 files changed, 64 insertions(+), 30 deletions(-) create mode 100644 packages/lib/server-only/user/get-last-verification-token.ts diff --git a/apps/web/src/components/forms/send-confirmation-email.tsx b/apps/web/src/components/forms/send-confirmation-email.tsx index ee073d063..33247bf9f 100644 --- a/apps/web/src/components/forms/send-confirmation-email.tsx +++ b/apps/web/src/components/forms/send-confirmation-email.tsx @@ -13,6 +13,7 @@ import { 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'; @@ -63,31 +64,32 @@ export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFo }; return ( -
-
- -
- ( - - Email address - - - - - )} - /> -
- -
- -
+ + + ); }; diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index d28506ca3..723b9cd7b 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -13,6 +13,7 @@ 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 { getLastVerificationToken } from '../server-only/user/get-last-verification-token'; 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'; @@ -92,12 +93,19 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { } if (!user.emailVerified) { - const totalUserVerificationTokens = user.VerificationToken.length; - const lastUserVerificationToken = user.VerificationToken[totalUserVerificationTokens - 1]; + const [lastUserVerificationToken] = await getLastVerificationToken({ userId: user.id }); + + if (!lastUserVerificationToken) { + await sendConfirmationToken({ email }); + throw new Error(ErrorCode.UNVERIFIED_EMAIL); + } + const expiredToken = DateTime.fromJSDate(lastUserVerificationToken.expires) <= DateTime.now(); + const lastSentToken = DateTime.fromJSDate(lastUserVerificationToken.createdAt); + const sentWithinLastHour = DateTime.now().minus({ hours: 1 }) <= lastSentToken; - if (totalUserVerificationTokens < 1 || expiredToken) { + if (expiredToken || !sentWithinLastHour) { await sendConfirmationToken({ email }); } diff --git a/packages/lib/server-only/user/get-last-verification-token.ts b/packages/lib/server-only/user/get-last-verification-token.ts new file mode 100644 index 000000000..279a1fcfd --- /dev/null +++ b/packages/lib/server-only/user/get-last-verification-token.ts @@ -0,0 +1,27 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetLastVerificationTokenOptions { + userId: number; +} + +export const getLastVerificationToken = async ({ userId }: GetLastVerificationTokenOptions) => { + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + include: { + VerificationToken: { + select: { + expires: true, + createdAt: true, + }, + orderBy: { + createdAt: 'desc', + }, + take: 1, + }, + }, + }); + + return user.VerificationToken; +}; 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 8c61202a2..0a2ef8d16 100644 --- a/packages/lib/server-only/user/get-user-by-email.ts +++ b/packages/lib/server-only/user/get-user-by-email.ts @@ -9,8 +9,5 @@ export const getUserByEmail = async ({ email }: GetUserByEmailOptions) => { where: { email: email.toLowerCase(), }, - include: { - VerificationToken: true, - }, }); }; From 4878cf388f35feb857fd1c701868b5f97bd46476 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 13 Feb 2024 07:53:36 +0200 Subject: [PATCH 13/14] chore: add the missing signIn function --- apps/web/src/components/forms/signup.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index 087e71fbe..7082bcee3 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; +import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { FcGoogle } from 'react-icons/fc'; import { z } from 'zod'; From d052f0201325d590d5066efafeefbc8f2e6a1f69 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 13 Feb 2024 06:01:25 +0000 Subject: [PATCH 14/14] chore: refactor code --- packages/lib/next-auth/auth-options.ts | 22 +++++++-------- .../user/get-last-verification-token.ts | 27 ------------------- ...st-recent-verification-token-by-user-id.ts | 18 +++++++++++++ .../user/send-confirmation-token.ts | 20 +++++++++++++- 4 files changed, 46 insertions(+), 41 deletions(-) delete mode 100644 packages/lib/server-only/user/get-last-verification-token.ts create mode 100644 packages/lib/server-only/user/get-most-recent-verification-token-by-user-id.ts diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 723b9cd7b..b944b6e7b 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -13,7 +13,7 @@ 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 { getLastVerificationToken } from '../server-only/user/get-last-verification-token'; +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'; @@ -93,19 +93,15 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { } if (!user.emailVerified) { - const [lastUserVerificationToken] = await getLastVerificationToken({ userId: user.id }); + const mostRecentToken = await getMostRecentVerificationTokenByUserId({ + userId: user.id, + }); - if (!lastUserVerificationToken) { - await sendConfirmationToken({ email }); - throw new Error(ErrorCode.UNVERIFIED_EMAIL); - } - - const expiredToken = - DateTime.fromJSDate(lastUserVerificationToken.expires) <= DateTime.now(); - const lastSentToken = DateTime.fromJSDate(lastUserVerificationToken.createdAt); - const sentWithinLastHour = DateTime.now().minus({ hours: 1 }) <= lastSentToken; - - if (expiredToken || !sentWithinLastHour) { + if ( + !mostRecentToken || + mostRecentToken.expires.valueOf() <= Date.now() || + DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5 + ) { await sendConfirmationToken({ email }); } diff --git a/packages/lib/server-only/user/get-last-verification-token.ts b/packages/lib/server-only/user/get-last-verification-token.ts deleted file mode 100644 index 279a1fcfd..000000000 --- a/packages/lib/server-only/user/get-last-verification-token.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { prisma } from '@documenso/prisma'; - -export interface GetLastVerificationTokenOptions { - userId: number; -} - -export const getLastVerificationToken = async ({ userId }: GetLastVerificationTokenOptions) => { - const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, - include: { - VerificationToken: { - select: { - expires: true, - createdAt: true, - }, - orderBy: { - createdAt: 'desc', - }, - take: 1, - }, - }, - }); - - return user.VerificationToken; -}; 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 a399dd9fc..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({ @@ -24,6 +31,17 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => { 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,