From 348fb48e61a7b6d2e9d75428fcec2c81f0cd1a31 Mon Sep 17 00:00:00 2001 From: Ray Wang Date: Thu, 6 Jun 2024 08:26:38 +1000 Subject: [PATCH] feat: verification email --- package.json | 2 +- .../(auth)/login/_components/LogInForm.tsx | 9 +- src/app/(auth)/login/page.tsx | 70 ++++++------ .../_components/RegisterSuccess.test.tsx | 45 ++++++-- .../register/_components/RegisterSuccess.tsx | 60 ++++++----- src/app/(auth)/register/page.tsx | 9 ++ .../_components/VerifyFail.test.tsx | 71 ++++++++++++ .../verify-email/_components/VerifyFail.tsx | 62 +++++++++++ .../_components/VerifySuccess.test.tsx | 65 +++++++++++ .../_components/VerifySuccess.tsx | 62 +++++++++++ src/app/verify-email/page.test.tsx | 102 ++++++++++++++++++ src/app/verify-email/page.tsx | 63 +++++++++++ src/graphql/verifyEmail.ts | 10 ++ src/hooks/userHooks.ts | 14 ++- .../components/account/AccountElements.tsx | 5 + 15 files changed, 578 insertions(+), 71 deletions(-) create mode 100644 src/app/verify-email/_components/VerifyFail.test.tsx create mode 100644 src/app/verify-email/_components/VerifyFail.tsx create mode 100644 src/app/verify-email/_components/VerifySuccess.test.tsx create mode 100644 src/app/verify-email/_components/VerifySuccess.tsx create mode 100644 src/app/verify-email/page.test.tsx create mode 100644 src/app/verify-email/page.tsx create mode 100644 src/graphql/verifyEmail.ts diff --git a/package.json b/package.json index 1288012..ec3c6a3 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "version": "0.0.1", "type": "module", "scripts": { - "dev": "PORT=5173 next dev ", + "dev": "set PORT=5173 && next dev ", "build": "next build", "lint": "next lint", "mock": "node ./mock/index.js", diff --git a/src/app/(auth)/login/_components/LogInForm.tsx b/src/app/(auth)/login/_components/LogInForm.tsx index 6b936f4..2a7fc7c 100644 --- a/src/app/(auth)/login/_components/LogInForm.tsx +++ b/src/app/(auth)/login/_components/LogInForm.tsx @@ -5,6 +5,7 @@ import { useForm, Controller } from 'react-hook-form'; import AccountOutlineIcon from 'mdi-react/AccountOutlineIcon'; import { Alert } from 'react-bootstrap'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import PasswordField from '@/shared/components/form/Password'; import { FormGroup, @@ -35,6 +36,10 @@ const LogInForm = ({ onSubmit, error = '' }: LogInFormProps) => { formState: { errors }, } = useForm(); const rememberMe = watch('rememberMe'); + const router = useRouter(); + const handleRegisterButtonClick = () => { + router.push('/register'); + }; useEffect(() => { if (rememberMe !== undefined && typeof window !== 'undefined') { @@ -122,8 +127,8 @@ const LogInForm = ({ onSubmit, error = '' }: LogInFormProps) => { Sign In - - Create Account + + Create Account ); diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 791585c..10b8cba 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -21,6 +21,7 @@ import { USER_LOGIN } from '@/graphql/auth'; import { AUTH_TOKEN, EMAIL } from '@/shared/constants/storage'; import { useSearchParams } from '@/hooks/useSearchParams'; import { useRouter } from 'next/navigation'; +import Loading from '@/shared/components/Loading'; import LogInForm from './_components/LogInForm'; const Login = () => { @@ -28,8 +29,10 @@ const Login = () => { const [error, setError] = useState(''); const originUrl = useSearchParams().get('orgUrl'); const [login] = useMutation(USER_LOGIN); + const [isLoading, setIsLoading] = useState(false); const onSubmit = async (data: { email: string; password: string; remember_me: boolean }) => { + setIsLoading(true); const result = await login({ variables: data, }); @@ -56,42 +59,47 @@ const Login = () => { } else { // for login failed setError(`Login failed: ${result.data.login.message}`); + setIsLoading(false); } }; return ( - - - - - Welcome to -
- - BeeQuant - AI - -
-

Trading smart, trading with BeeQuant AI

-
- - -

Or Easily Using

-
- - {/* @ts-ignore - Ignoring because of complex union types incorrectly inferred */} - - - - - - - -
-
+ {isLoading ? ( + + ) : ( + + + + + Welcome to +
+ + BeeQuant + AI + +
+

Trading smart, trading with BeeQuant AI

+
+ + +

Or Easily Using

+
+ + {/* @ts-ignore - Ignoring because of complex union types incorrectly inferred */} + + + + + + + +
+
+ )}
); }; diff --git a/src/app/(auth)/register/_components/RegisterSuccess.test.tsx b/src/app/(auth)/register/_components/RegisterSuccess.test.tsx index e2e8577..909d48c 100644 --- a/src/app/(auth)/register/_components/RegisterSuccess.test.tsx +++ b/src/app/(auth)/register/_components/RegisterSuccess.test.tsx @@ -1,12 +1,17 @@ -import { screen, render } from '@testing-library/react'; +import { screen, render, fireEvent, waitFor } from '@testing-library/react'; import { BrowserRouter as Router } from 'react-router-dom'; import { useUserContext } from '@/hooks/userHooks'; +import { useRouter } from 'next/navigation'; import RegisterSuccess from './RegisterSuccess'; jest.mock('@/containers/Layout/topbar/BasicTopbarComponents', () => ({ TopbarDownIcon: () =>
Mocked TopbarDownIcon
, })); +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})); + const mockLocalStorage = (() => { let store: Record = {}; return { @@ -26,6 +31,15 @@ Object.defineProperty(window, 'localStorage', { value: mockLocalStorage, }); +interface MockAppRouterInstance { + push: jest.Mock; + back?: jest.Mock; + forward?: jest.Mock; + refresh?: jest.Mock; + replace?: jest.Mock; + prefetch?: jest.Mock; +} + jest.mock('@/hooks/userHooks', () => ({ useUserContext: jest.fn(), })); @@ -52,7 +66,7 @@ describe('RegisterSuccess component', () => { ); - const successMessage = screen.getByText(/Your registration is successful/i); + const successMessage = screen.getByText(/We have sent you a verification email/i); expect(successMessage).toBeInTheDocument(); }); @@ -66,14 +80,25 @@ describe('RegisterSuccess component', () => { expect(image).toBeInTheDocument(); }); - it('should render button with correct link', () => { - const { getByText } = render( - - - + it('triggers router push on button click', async () => { + const pushMock = jest.fn(); + + const mockRouterInstance: MockAppRouterInstance = { + push: pushMock, + back: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + }; + + (useRouter as jest.MockedFunction).mockReturnValue( + mockRouterInstance as unknown as ReturnType ); - const button = getByText('Back to Login'); - expect(button).toBeInTheDocument(); - expect(button).toHaveAttribute('href', '/login'); + const { getByText } = render(); + fireEvent.click(getByText('Finished sign-up. Ready to trade')); + await waitFor(() => { + expect(pushMock).toHaveBeenCalledWith('/login'); + }); }); }); diff --git a/src/app/(auth)/register/_components/RegisterSuccess.tsx b/src/app/(auth)/register/_components/RegisterSuccess.tsx index c87937e..c835345 100644 --- a/src/app/(auth)/register/_components/RegisterSuccess.tsx +++ b/src/app/(auth)/register/_components/RegisterSuccess.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import styled from 'styled-components'; -import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { AccountButton, AccountCard, @@ -12,30 +12,40 @@ import { AccountWrap, } from '@/shared/components/account/AccountElements'; -const RegisterSuccess = () => ( - - - - - - - - - Congratulations ! -
-
-
- Your registration is successful -
-
- {/* @ts-ignore - Ignoring because of complex union types that are not correctly inferred */} - - Back to Login - -
-
-
-); +const RegisterSuccess = () => { + const router = useRouter(); + const handleFinishedButtonClick = () => { + router.push('/login'); + }; + + return ( + + + + + + + + + Please check your mailbox +
+
+
+ We have sent you a verification email +
+
+ {/* + @ts-ignore + - Ignoring because of complex union types that are not correctly inferred + */} + + Finished sign-up. Ready to trade + +
+
+
+ ); +}; export default RegisterSuccess; diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 3c05641..4ea2a1a 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -15,11 +15,13 @@ import { useMutation } from '@apollo/client'; import { USER_REGISTER } from '@/graphql/auth'; import { useTitle } from '@/hooks/useTitle'; import Link from 'next/link'; +import Loading from '@/shared/components/Loading'; import RegisterForm from './_components/RegisterForm'; import RegisterSuccess from './_components/RegisterSuccess'; const Register = () => { const [register] = useMutation(USER_REGISTER); + const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); const [isRegistered, setIsRegistered] = useState(false); @@ -31,6 +33,7 @@ const Register = () => { displayName: string; ref: string; }) => { + setIsLoading(true); const result = await register({ variables: { input: data, @@ -39,15 +42,21 @@ const Register = () => { if (result.data.register.code === 200) { setIsRegistered(true); + setIsLoading(false); } // for register failed setError(`Register failed: ${result.data.register.message}`); + setIsLoading(false); }; if (isRegistered) { return ; } + if (isLoading) { + return ; + } + return ( diff --git a/src/app/verify-email/_components/VerifyFail.test.tsx b/src/app/verify-email/_components/VerifyFail.test.tsx new file mode 100644 index 0000000..6fbeb7d --- /dev/null +++ b/src/app/verify-email/_components/VerifyFail.test.tsx @@ -0,0 +1,71 @@ +import { screen, render, fireEvent, waitFor } from '@testing-library/react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { useRouter } from 'next/navigation'; +import VerifyFail from './VerifyFail'; + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})); + +interface MockAppRouterInstance { + push: jest.Mock; + back?: jest.Mock; + forward?: jest.Mock; + refresh?: jest.Mock; + replace?: jest.Mock; + prefetch?: jest.Mock; +} + +describe('VerifyFail component', () => { + it('should render successfully and show verification failed message', () => { + render( + + + + ); + + const errorMessage = screen.getByText(/Link expired/i); + expect(errorMessage).toBeInTheDocument(); + + const titleMessage = screen.getByText(/Verification failed/i); + expect(titleMessage).toBeInTheDocument(); + }); + + it('should render image', () => { + render( + + + + ); + const image = screen.getByAltText('404'); + expect(image).toBeInTheDocument(); + }); + + it('triggers router push on button click', async () => { + const pushMock = jest.fn(); + + const mockRouterInstance: MockAppRouterInstance = { + push: pushMock, + back: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + }; + + (useRouter as jest.MockedFunction).mockReturnValue( + mockRouterInstance as unknown as ReturnType + ); + + render( + + + + ); + + fireEvent.click(screen.getByText('Back to Login')); + await waitFor(() => { + expect(pushMock).toHaveBeenCalledWith('/login'); + }); + }); +}); diff --git a/src/app/verify-email/_components/VerifyFail.tsx b/src/app/verify-email/_components/VerifyFail.tsx new file mode 100644 index 0000000..3012850 --- /dev/null +++ b/src/app/verify-email/_components/VerifyFail.tsx @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import styled from 'styled-components'; +import { useRouter } from 'next/navigation'; +import { + AccountButton, + AccountCard, + AccountContent, + AccountHead, + AccountLogo, + AccountLogoError, + AccountTitle, + AccountWrap, +} from '@/shared/components/account/AccountElements'; + +const VerifyFail = ({ error }: { error: string }) => { + const router = useRouter(); + const handleBackToLoginButtonClick = () => { + router.push('/login'); + }; + + return ( + + + + + + + + + Opps ! +
+ Verification failed +
+
+
+ {error} +
+
+ {/* + @ts-ignore + - Ignoring because of complex union types that are not correctly inferred + */} + + Back to Login + +
+
+
+ ); +}; + +export default VerifyFail; + +// region STYLES + +const AccountImage = styled.img` + max-width: 500px; + width: 100%; + margin-bottom: 40px; +`; + +// endregion diff --git a/src/app/verify-email/_components/VerifySuccess.test.tsx b/src/app/verify-email/_components/VerifySuccess.test.tsx new file mode 100644 index 0000000..f4eaab7 --- /dev/null +++ b/src/app/verify-email/_components/VerifySuccess.test.tsx @@ -0,0 +1,65 @@ +import { screen, render, fireEvent, waitFor } from '@testing-library/react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { useRouter } from 'next/navigation'; +import VerifySuccess from './VerifySuccess'; + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})); + +describe('VerifySuccess component', () => { + it('should render successfully and show verification success message', () => { + render( + + + + ); + + const titleMessage1 = screen.getByText(/Congratulations !/i); + expect(titleMessage1).toBeInTheDocument(); + + const titleMessage2 = screen.getByText(/Email Verified/i); + expect(titleMessage2).toBeInTheDocument(); + + const successMessage = screen.getByText(/Your registration is successful/i); + expect(successMessage).toBeInTheDocument(); + }); + + it('should render image', () => { + render( + + + + ); + const image = screen.getByAltText('success'); + expect(image).toBeInTheDocument(); + }); + + it('triggers router push on button click', async () => { + const pushMock = jest.fn(); + + const mockRouterInstance = { + push: pushMock, + back: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + }; + + (useRouter as jest.MockedFunction).mockReturnValue( + mockRouterInstance as unknown as ReturnType + ); + + render( + + + + ); + + fireEvent.click(screen.getByText('Back to Login')); + await waitFor(() => { + expect(pushMock).toHaveBeenCalledWith('/login'); + }); + }); +}); diff --git a/src/app/verify-email/_components/VerifySuccess.tsx b/src/app/verify-email/_components/VerifySuccess.tsx new file mode 100644 index 0000000..1656ba4 --- /dev/null +++ b/src/app/verify-email/_components/VerifySuccess.tsx @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import styled from 'styled-components'; +import { useRouter } from 'next/navigation'; +import { + AccountButton, + AccountCard, + AccountContent, + AccountHead, + AccountLogo, + AccountLogoAccent, + AccountTitle, + AccountWrap, +} from '@/shared/components/account/AccountElements'; + +const VerifySuccess = () => { + const router = useRouter(); + const handleBackToLoginButtonClick = () => { + router.push('/login'); + }; + + return ( + + + + + + + + + Congratulations ! +
+ Email Verified +
+
+
+ Your registration is successful +
+
+ {/* + @ts-ignore + - Ignoring because of complex union types that are not correctly inferred + */} + + Back to Login + +
+
+
+ ); +}; + +export default VerifySuccess; + +// region STYLES + +const AccountImage = styled.img` + max-width: 500px; + width: 100%; + margin-bottom: 40px; +`; + +// endregion diff --git a/src/app/verify-email/page.test.tsx b/src/app/verify-email/page.test.tsx new file mode 100644 index 0000000..b15f9a6 --- /dev/null +++ b/src/app/verify-email/page.test.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/client/testing'; +import { VERIFY_EMAIL } from '@/graphql/verifyEmail'; +import Page from './page'; + +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + useSearchParams: jest.fn(() => ({ + get: jest.fn((key) => { + if (key === 'email') return 'test@example.com'; + if (key === 'token') return 'testtoken123'; + return null; + }), + })), + useRouter: jest.fn(), +})); + +const mockMutationResult = (code: number) => ({ + data: { + verifyEmail: { + code, + message: code === 200 ? 'Your registration is successful' : 'Verification failed', + }, + }, +}); + +describe('Email Verification Page', () => { + it('should render success message on successful email verification', async () => { + const mocks = [ + { + request: { + query: VERIFY_EMAIL, + variables: { + email: 'test@example.com', + token: 'testtoken123', + }, + }, + result: mockMutationResult(200), + }, + ]; + + const { getByText } = render( + + + + ); + + await waitFor(() => { + expect(getByText('Your registration is successful')).toBeInTheDocument(); + }); + }); + + it('should render failure message on failed email verification', async () => { + const failMocks = [ + { + request: { + query: VERIFY_EMAIL, + variables: { + email: 'test@example.com', + token: 'testtoken123', + }, + }, + result: mockMutationResult(10008), + }, + ]; + const { getByText } = render( + + + + ); + + await waitFor(() => { + expect(getByText('Verification failed')).toBeInTheDocument(); + }); + }); + + it('should render error message if an error occurs during verification', async () => { + const errorMocks = [ + { + request: { + query: VERIFY_EMAIL, + variables: { + email: 'test@example.com', + token: 'testtoken123', + }, + }, + error: new Error('An error occurred'), + }, + ]; + + const { getByText } = render( + + + + ); + + await waitFor(() => { + expect(getByText('An error occurred')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/verify-email/page.tsx b/src/app/verify-email/page.tsx new file mode 100644 index 0000000..c3dd6f3 --- /dev/null +++ b/src/app/verify-email/page.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useState, useEffect, Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { VERIFY_EMAIL } from '@/graphql/verifyEmail'; +import { useMutation } from '@apollo/client'; +import Loading from '@/shared/components/Loading'; +import VerifySuccess from './_components/VerifySuccess'; +import VerifyFail from './_components/VerifyFail'; + +const Search = () => { + const searchParams = useSearchParams(); + const email = searchParams.get('email'); + const token = searchParams.get('token'); + const [verifyEmail] = useMutation(VERIFY_EMAIL); + const [mutationError, setMutationError] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isVerified, setIsVerified] = useState(false); + + useEffect(() => { + const fetchAndVerifyEmail = async () => { + try { + const result = await verifyEmail({ + variables: { + email, + token, + }, + }); + + if (result.data.verifyEmail.code === 200) { + setIsVerified(true); + setIsLoading(false); + } else { + setMutationError(`${result.data.verifyEmail.message}`); + setIsLoading(false); + } + } catch (e) { + setMutationError('An error occurred'); + setIsLoading(false); + } + }; + + fetchAndVerifyEmail(); + }, []); + + if (isLoading) { + return ; + } + + if (isVerified) { + return ; + } + + return ; +}; + +export default function Page() { + return ( + + + + ); +} diff --git a/src/graphql/verifyEmail.ts b/src/graphql/verifyEmail.ts new file mode 100644 index 0000000..6ad8099 --- /dev/null +++ b/src/graphql/verifyEmail.ts @@ -0,0 +1,10 @@ +import { gql } from '@apollo/client'; + +export const VERIFY_EMAIL = gql` + mutation VerifyEmail($email: String!, $token: String!) { + verifyEmail(email: $email, token: $token) { + code + message + } + } +`; diff --git a/src/hooks/userHooks.ts b/src/hooks/userHooks.ts index 89a4580..1501da7 100644 --- a/src/hooks/userHooks.ts +++ b/src/hooks/userHooks.ts @@ -31,7 +31,12 @@ export const useLoadUser = () => { displayName, refetchHandler: refetch, }); - if (pathName.match('/login') && typeof window !== 'undefined') { + if ( + pathName.match('/login') && + pathName.match('/register') && + !pathName.match('/verify-email') && + typeof window !== 'undefined' + ) { router.push('/dashboard'); } } @@ -41,7 +46,12 @@ export const useLoadUser = () => { refetchHandler: refetch, }); console.error('failed retrieving user info, backing to login'); - if (!pathName.match('/login') && typeof window !== 'undefined') { + if ( + !pathName.match('/login') && + !pathName.match('/register') && + !pathName.match('/verify-email') && + typeof window !== 'undefined' + ) { router.push(`/login?orgUrl=${pathName}`); } }, diff --git a/src/shared/components/account/AccountElements.tsx b/src/shared/components/account/AccountElements.tsx index dc32112..a0db4f4 100644 --- a/src/shared/components/account/AccountElements.tsx +++ b/src/shared/components/account/AccountElements.tsx @@ -8,6 +8,7 @@ import { colorBlueHover, colorDarkRed, colorDustyWhite, + colorLightRed, colorLightText, colorVeryLightRed, colorWhite, @@ -73,6 +74,10 @@ export const AccountLogoAccent = styled.span` color: ${colorBlue}; `; +export const AccountLogoError = styled.span` + color: ${colorLightRed}; +`; + export const AccountOr = styled.div` text-align: center; margin-top: 35px;