From 95ed783832e36702fa6fa2ab7e1c9100d9da76ae Mon Sep 17 00:00:00 2001 From: May Han Date: Fri, 23 Aug 2024 01:57:05 +0800 Subject: [PATCH] feat: add password reset process pages Added (static) password reset process pages: - implemented "Forgot Password" and "Reset Password" forms - created Success Reset page to confirm successful password reset - added appropriate user guidance messages throughout the reset process - added related unit tests for the new pages and components Resolves CP-28 --- src/app/(auth)/forgot-password/page.tsx | 7 + src/app/(auth)/reset-password/page.tsx | 7 + src/graphql/auth.ts | 20 +++ src/graphql/codegen/gql.ts | 16 ++ src/graphql/codegen/graphql.ts | 152 ++++++++++++++++++ src/hooks/userHooks.ts | 6 +- .../ForgotPasswordFormGroup.test.tsx | 84 ++++++++++ .../ForgotPasswordFormGroup.tsx | 48 ++++++ .../forgot-password-form/FormLayout.test.tsx | 87 ++++++++++ .../auth/forgot-password-form/FormLayout.tsx | 82 ++++++++++ .../forgot-password-form.component.test.tsx | 63 ++++++++ .../forgot-password-form.component.tsx | 34 ++++ src/module/auth/login-form/LoginFormGroup.tsx | 2 +- .../reset-password-form/FormLayout.test.tsx | 87 ++++++++++ .../auth/reset-password-form/FormLayout.tsx | 100 ++++++++++++ .../ResetPasswordFormGroup.test.tsx | 125 ++++++++++++++ .../ResetPasswordFormGroup.tsx | 139 ++++++++++++++++ .../ResetPasswordSuccess.test.tsx | 47 ++++++ .../ResetPasswordSuccess.tsx | 37 +++++ .../reset-password-form.component.tsx | 20 +++ src/routes/routeConfig.ts | 19 +++ 21 files changed, 1180 insertions(+), 2 deletions(-) create mode 100644 src/app/(auth)/forgot-password/page.tsx create mode 100644 src/app/(auth)/reset-password/page.tsx create mode 100644 src/module/auth/forgot-password-form/ForgotPasswordFormGroup.test.tsx create mode 100644 src/module/auth/forgot-password-form/ForgotPasswordFormGroup.tsx create mode 100644 src/module/auth/forgot-password-form/FormLayout.test.tsx create mode 100644 src/module/auth/forgot-password-form/FormLayout.tsx create mode 100644 src/module/auth/forgot-password-form/forgot-password-form.component.test.tsx create mode 100644 src/module/auth/forgot-password-form/forgot-password-form.component.tsx create mode 100644 src/module/auth/reset-password-form/FormLayout.test.tsx create mode 100644 src/module/auth/reset-password-form/FormLayout.tsx create mode 100644 src/module/auth/reset-password-form/ResetPasswordFormGroup.test.tsx create mode 100644 src/module/auth/reset-password-form/ResetPasswordFormGroup.tsx create mode 100644 src/module/auth/reset-password-form/ResetPasswordSuccess.test.tsx create mode 100644 src/module/auth/reset-password-form/ResetPasswordSuccess.tsx create mode 100644 src/module/auth/reset-password-form/reset-password-form.component.tsx diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..5766d73 --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,7 @@ +import ForgotPasswordForm from 'module/auth/forgot-password-form/forgot-password-form.component'; + +const ForgotPassword = () => { + return ; +}; + +export default ForgotPassword; diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..716ed4a --- /dev/null +++ b/src/app/(auth)/reset-password/page.tsx @@ -0,0 +1,7 @@ +import ResetPasswordForm from 'module/auth/reset-password-form/reset-password-form.component'; + +const ResetPassword = () => { + return ; +}; + +export default ResetPassword; diff --git a/src/graphql/auth.ts b/src/graphql/auth.ts index 5a1cb33..951bd2b 100644 --- a/src/graphql/auth.ts +++ b/src/graphql/auth.ts @@ -18,3 +18,23 @@ export const USER_REGISTER = gql(` } } `); + +export const USER_FORGOT_PASSWORD = gql(` + mutation ForgotPassword($email: String!){ + forgotPassword(email: $email) { + code + message + data + } + } +`); + +export const USER_RESET_PASSWORD = gql(` + mutation resetPassword($input: ResetPasswordInput!){ + resetPassword(input: $input) { + code + message + data + } + } +`); diff --git a/src/graphql/codegen/gql.ts b/src/graphql/codegen/gql.ts index 38cac59..a126c4e 100644 --- a/src/graphql/codegen/gql.ts +++ b/src/graphql/codegen/gql.ts @@ -17,6 +17,10 @@ const documents = { types.LoginDocument, '\n mutation Register($input: CreateUserInput!) {\n register(input: $input) {\n code\n message\n data\n }\n }\n': types.RegisterDocument, + '\n mutation ForgotPassword($email: String!){\n forgotPassword(email: $email) {\n code\n message\n data\n }\n }\n': + types.ForgotPasswordDocument, + '\n mutation resetPassword($input: ResetPasswordInput!){\n resetPassword(input: $input) {\n code\n message\n data\n }\n }\n': + types.ResetPasswordDocument, '\n query getUserInfo {\n getUserInfo {\n id\n displayName\n }\n }\n': types.GetUserInfoDocument, '\n query getUserById($id: String!) {\n getUserById(id: $id) {\n id\n email\n realName\n displayName\n mobile\n }\n }\n': @@ -51,6 +55,18 @@ export function gql( export function gql( source: '\n mutation Register($input: CreateUserInput!) {\n register(input: $input) {\n code\n message\n data\n }\n }\n' ): (typeof documents)['\n mutation Register($input: CreateUserInput!) {\n register(input: $input) {\n code\n message\n data\n }\n }\n']; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql( + source: '\n mutation ForgotPassword($email: String!){\n forgotPassword(email: $email) {\n code\n message\n data\n }\n }\n' +): (typeof documents)['\n mutation ForgotPassword($email: String!){\n forgotPassword(email: $email) {\n code\n message\n data\n }\n }\n']; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql( + source: '\n mutation resetPassword($input: ResetPasswordInput!){\n resetPassword(input: $input) {\n code\n message\n data\n }\n }\n' +): (typeof documents)['\n mutation resetPassword($input: ResetPasswordInput!){\n resetPassword(input: $input) {\n code\n message\n data\n }\n }\n']; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/src/graphql/codegen/graphql.ts b/src/graphql/codegen/graphql.ts index d53c2b1..89471da 100644 --- a/src/graphql/codegen/graphql.ts +++ b/src/graphql/codegen/graphql.ts @@ -79,14 +79,20 @@ export type Mutation = { deleteUser: Scalars['Boolean']['output']; /** Hard delete an user key */ deleteUserKey: Scalars['Boolean']['output']; + /** Forgot password */ + forgotPassword: Result; /** User login */ login: Result; /** User register */ register: Result; + /** Reset password */ + resetPassword: Result; /** Update exchange key info */ updateExchangeKey: Scalars['Boolean']['output']; /** Update user info */ updateUser: Scalars['Boolean']['output']; + /** Email Verification */ + verifyEmail: Result; }; export type MutationChangePasswordArgs = { @@ -105,6 +111,10 @@ export type MutationDeleteUserArgs = { id: Scalars['String']['input']; }; +export type MutationForgotPasswordArgs = { + email: Scalars['String']['input']; +}; + export type MutationLoginArgs = { email: Scalars['String']['input']; password: Scalars['String']['input']; @@ -114,6 +124,10 @@ export type MutationRegisterArgs = { input: CreateUserInput; }; +export type MutationResetPasswordArgs = { + input: ResetPasswordInput; +}; + export type MutationUpdateExchangeKeyArgs = { input: UpdateExchangeKeyInput; }; @@ -123,6 +137,11 @@ export type MutationUpdateUserArgs = { input: UpdateUserInput; }; +export type MutationVerifyEmailArgs = { + email: Scalars['String']['input']; + token: Scalars['String']['input']; +}; + export type Query = { __typename?: 'Query'; /** Find exchange key by id */ @@ -149,6 +168,13 @@ export type QueryGetUserByIdArgs = { id: Scalars['String']['input']; }; +export type ResetPasswordInput = { + /** New Password */ + newPassword: Scalars['String']['input']; + /** Reset Token */ + resetToken: Scalars['String']['input']; +}; + export type Result = { __typename?: 'Result'; code: Scalars['Int']['output']; @@ -177,6 +203,8 @@ export type UpdatePasswordInput = { export type UpdateUserInput = { /** User display name */ displayName?: InputMaybe; + /** is Email Verified */ + isEmailVerified?: InputMaybe; /** Mobile number */ mobile?: InputMaybe; /** Password */ @@ -185,6 +213,8 @@ export type UpdateUserInput = { realName?: InputMaybe; /** User is referred by */ ref?: InputMaybe; + /** Verification Token */ + verificationToken?: InputMaybe; }; export type UserType = { @@ -195,6 +225,8 @@ export type UserType = { email: Scalars['String']['output']; /** User ID */ id: Scalars['String']['output']; + /** is Email Verified */ + isEmailVerified: Scalars['Boolean']['output']; /** Mobile number */ mobile: Scalars['String']['output']; /** QQ */ @@ -203,6 +235,10 @@ export type UserType = { realName: Scalars['String']['output']; /** User is referred by */ ref: Scalars['String']['output']; + /** Reset Password Token */ + resetPasswordToken?: Maybe; + /** Verification Token */ + verificationToken?: Maybe; /** Wechat */ wechat: Scalars['String']['output']; }; @@ -226,6 +262,34 @@ export type RegisterMutation = { register: { __typename?: 'Result'; code: number; message?: string | null; data?: string | null }; }; +export type ForgotPasswordMutationVariables = Exact<{ + email: Scalars['String']['input']; +}>; + +export type ForgotPasswordMutation = { + __typename?: 'Mutation'; + forgotPassword: { + __typename?: 'Result'; + code: number; + message?: string | null; + data?: string | null; + }; +}; + +export type ResetPasswordMutationVariables = Exact<{ + input: ResetPasswordInput; +}>; + +export type ResetPasswordMutation = { + __typename?: 'Mutation'; + resetPassword: { + __typename?: 'Result'; + code: number; + message?: string | null; + data?: string | null; + }; +}; + export type GetUserInfoQueryVariables = Exact<{ [key: string]: never }>; export type GetUserInfoQuery = { @@ -357,6 +421,94 @@ export const RegisterDocument = { }, ], } as unknown as DocumentNode; +export const ForgotPasswordDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'ForgotPassword' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'email' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'forgotPassword' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'email' }, + value: { kind: 'Variable', name: { kind: 'Name', value: 'email' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'code' } }, + { kind: 'Field', name: { kind: 'Name', value: 'message' } }, + { kind: 'Field', name: { kind: 'Name', value: 'data' } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const ResetPasswordDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'resetPassword' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'input' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ResetPasswordInput' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'resetPassword' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'input' }, + value: { kind: 'Variable', name: { kind: 'Name', value: 'input' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'code' } }, + { kind: 'Field', name: { kind: 'Name', value: 'message' } }, + { kind: 'Field', name: { kind: 'Name', value: 'data' } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const GetUserInfoDocument = { kind: 'Document', definitions: [ diff --git a/src/hooks/userHooks.ts b/src/hooks/userHooks.ts index 89a4580..755b169 100644 --- a/src/hooks/userHooks.ts +++ b/src/hooks/userHooks.ts @@ -41,7 +41,11 @@ 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('/reset-password') && + typeof window !== 'undefined' + ) { router.push(`/login?orgUrl=${pathName}`); } }, diff --git a/src/module/auth/forgot-password-form/ForgotPasswordFormGroup.test.tsx b/src/module/auth/forgot-password-form/ForgotPasswordFormGroup.test.tsx new file mode 100644 index 0000000..6c357a0 --- /dev/null +++ b/src/module/auth/forgot-password-form/ForgotPasswordFormGroup.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { FormProvider, useForm } from 'react-hook-form'; +import ForgotPasswordFormGroup from './ForgotPasswordFormGroup'; + +const localStorageMock = (() => { + let store: { [key: string]: string } = {}; + return { + getItem: jest.fn((key) => store[key] || null), + setItem: jest.fn((key, value) => { + store[key] = value.toString(); + }), + clear: jest.fn(() => { + store = {}; + }), + }; +})(); +Object.defineProperty(window, 'localStorage', { value: localStorageMock }); + +const Wrapper = ({ children }: { children: React.ReactNode }) => { + const methods = useForm(); + return {children}; +}; + +describe('ForgotPasswordFormGroup', () => { + beforeEach(() => { + localStorageMock.clear(); + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + const { getByPlaceholderText } = render(, { wrapper: Wrapper }); + expect(getByPlaceholderText('Email')).toBeInTheDocument(); + }); + + it('uses email from localStorage if available', () => { + localStorageMock.getItem.mockReturnValue('test@example.com'); + const { getByPlaceholderText } = render(, { wrapper: Wrapper }); + expect(getByPlaceholderText('Email')).toHaveAttribute('value', 'test@example.com'); + }); + + it('displays an error for empty email', async () => { + const { getByPlaceholderText, findByText } = render(, { + wrapper: Wrapper, + }); + const emailInput = getByPlaceholderText('Email'); + + fireEvent.change(emailInput, { target: { value: '' } }); + fireEvent.blur(emailInput); + + await waitFor(() => { + expect(findByText('This is required field')).resolves.toBeInTheDocument(); + }); + }); + + it('displays an error for invalid email format', async () => { + const { getByPlaceholderText, findByText } = render(, { + wrapper: Wrapper, + }); + const emailInput = getByPlaceholderText('Email'); + + fireEvent.change(emailInput, { target: { value: 'invalidemail' } }); + fireEvent.blur(emailInput); + + await waitFor(() => { + expect(findByText('Entered value does not match email format')).resolves.toBeInTheDocument(); + }); + }); + + it('accepts valid email input', async () => { + const { getByPlaceholderText, queryByText } = render(, { + wrapper: Wrapper, + }); + const emailInput = getByPlaceholderText('Email'); + + fireEvent.change(emailInput, { target: { value: 'valid@example.com' } }); + fireEvent.blur(emailInput); + + await waitFor(() => { + expect(queryByText('This is required field')).not.toBeInTheDocument(); + expect(queryByText('Entered value does not match email format')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/module/auth/forgot-password-form/ForgotPasswordFormGroup.tsx b/src/module/auth/forgot-password-form/ForgotPasswordFormGroup.tsx new file mode 100644 index 0000000..60df2e4 --- /dev/null +++ b/src/module/auth/forgot-password-form/ForgotPasswordFormGroup.tsx @@ -0,0 +1,48 @@ +import { + FormGroupField, + FormGroupIcon, + FormGroupLabel, +} from '@/shared/components/form/FormElements'; +import { LastFormGroup } from '@/shared/components/account/AccountElements'; +import AccountOutlineIcon from 'mdi-react/AccountOutlineIcon'; +import FormField from '@/shared/components/form/FormHookField'; +import { emailPattern } from '@/shared/utils/helpers'; +import { EMAIL } from '@/shared/constants/storage'; +import { useFormContext } from 'react-hook-form'; + +export default function ForgotPasswordFormGroup() { + const { + control, + formState: { errors }, + } = useFormContext(); + const localEmail = (typeof window !== 'undefined' ? localStorage.getItem(EMAIL) : '') || ''; + + return ( + <> + + Email + + + + + + + + + ); +} diff --git a/src/module/auth/forgot-password-form/FormLayout.test.tsx b/src/module/auth/forgot-password-form/FormLayout.test.tsx new file mode 100644 index 0000000..64ad73a --- /dev/null +++ b/src/module/auth/forgot-password-form/FormLayout.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/client/testing'; +import { USER_FORGOT_PASSWORD } from '@/graphql/auth'; +import FormLayout from './FormLayout'; +import { act } from 'react-dom/test-utils'; + +jest.mock('next/link', () => { + return ({ children }: { children: React.ReactNode }) => children; +}); + +jest.mock('@/hooks/useTitle', () => ({ + useTitle: jest.fn(), +})); + +const mocks = [ + { + request: { + query: USER_FORGOT_PASSWORD, + variables: { email: 'test@example.com' }, + }, + result: { + data: { + forgotPassword: { + code: 200, + message: 'Password reset email sent', + data: null, + }, + }, + }, + }, +]; + +describe('FormLayout', () => { + it('renders without crashing', () => { + render( + + + + ); + const loginLink = screen.getByText((content, element) => { + if ( + element && + element.tagName.toLowerCase() === 'p' && + content.includes('Back to') && + content.includes('Login') + ) { + return true; + } + return false; + }); + expect(loginLink).toBeInTheDocument(); + expect(screen.getByText('Submit')).toBeInTheDocument(); + }); + + it('displays form fields correctly', () => { + render( + + + + ); + expect(screen.getByPlaceholderText('Email')).toBeInTheDocument(); + }); + + it('submits form and displays success message', async () => { + render( + + + + ); + + fireEvent.change(screen.getByPlaceholderText('Email'), { + target: { value: 'test@example.com' }, + }); + + await act(async () => { + fireEvent.click(screen.getByText('Submit')); + }); + + await waitFor( + () => { + expect(screen.getByText(/If the email is associated with an account/)).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); +}); diff --git a/src/module/auth/forgot-password-form/FormLayout.tsx b/src/module/auth/forgot-password-form/FormLayout.tsx new file mode 100644 index 0000000..84d4449 --- /dev/null +++ b/src/module/auth/forgot-password-form/FormLayout.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation } from '@apollo/client'; +import { AccountButton, AccountHaveAccount } from '@/shared/components/account/AccountElements'; +import { FormContainer } from '@/shared/components/form/FormElements'; +import { FormProvider, useForm } from 'react-hook-form'; +import Link from 'next/link'; +import styled from 'styled-components'; +import { colorBlue } from '@/styles/palette'; +import { USER_FORGOT_PASSWORD } from '@/graphql/auth'; +import { useTitle } from '@/hooks/useTitle'; +import ForgotPasswordFormGroup from './ForgotPasswordFormGroup'; + +type FormData = { email: string }; + +// region STYLES +const BackToLogin = styled(AccountHaveAccount)` + margin-top: 0; +`; + +const ResponseMessage = styled.div` + color: ${colorBlue}; + margin-bottom: 14px; +`; + +const FormLayout = () => { + useTitle('Forgot Password - BeeQuant'); + + const [responseMessage, setResponseMessage] = useState(''); + const [forgotPassword] = useMutation(USER_FORGOT_PASSWORD); + + const methods = useForm({ + defaultValues: { + email: '', + }, + }); + const { handleSubmit } = methods; + + const onSubmit = async (data: FormData) => { + try { + await forgotPassword({ + variables: { + email: data.email, + }, + }); + setResponseMessage( + 'If the email is associated with an account, a reset email will be sent shortly. Please check your mailbox.' + ); + } catch (err) { + console.error('Error during password reset request:', err); + setResponseMessage( + 'An error occurred while processing your request. Please try again later.' + ); + } + }; + + return ( + + + {responseMessage && ( + +
{responseMessage}
+
+ )} + + + Submit + +
+ +

+ Back to + + Login +

+
+
+ ); +}; + +export default FormLayout; diff --git a/src/module/auth/forgot-password-form/forgot-password-form.component.test.tsx b/src/module/auth/forgot-password-form/forgot-password-form.component.test.tsx new file mode 100644 index 0000000..8bae164 --- /dev/null +++ b/src/module/auth/forgot-password-form/forgot-password-form.component.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import ForgotPasswordForm from './forgot-password-form.component'; + +interface IconProps { + className?: string; +} + +jest.mock('mdi-react/TrendingUpIcon', () => { + return { + __esModule: true, + default: ({ className }: IconProps) =>
Mocked Trending Up Icon
, + }; +}); + +jest.mock('mdi-react/TrendingDownIcon', () => { + return { + __esModule: true, + default: ({ className }: IconProps) => ( +
Mocked Trending Down Icon
+ ), + }; +}); + +jest.mock('mdi-react/AccountOutlineIcon', () => { + return { + __esModule: true, + default: () =>
Mocked Account Outline Icon
, + }; +}); + +jest.mock('./FormLayout', () => ({ + __esModule: true, + default: () => ( +
+ + +
+ ), +})); + +describe('ForgotPasswordForm', () => { + it('should render the forgot password page without crashing', () => { + const { container } = render( + + + + ); + expect(container).toBeTruthy(); + }); + + it('should render the correct elements and text', () => { + render( + + + + ); + + expect(screen.getByText(/Enter Your Email/i)).toBeInTheDocument(); + expect(screen.getByText(/BeeQuant AI/i)).toBeInTheDocument(); + expect(screen.getByText(/Trading smart, trading with BeeQuant AI/i)).toBeInTheDocument(); + }); +}); diff --git a/src/module/auth/forgot-password-form/forgot-password-form.component.tsx b/src/module/auth/forgot-password-form/forgot-password-form.component.tsx new file mode 100644 index 0000000..03e6f7a --- /dev/null +++ b/src/module/auth/forgot-password-form/forgot-password-form.component.tsx @@ -0,0 +1,34 @@ +'use client'; +import { + AccountWrap, + AccountContent, + AccountCard, + AccountHead, + AccountLogo, + AccountLogoAccent, + AccountTitle, +} from '@/shared/components/account/AccountElements'; +import FormLayout from './FormLayout'; + +export default function ForgotPasswordForm() { + return ( + + + + + + Enter Your Email +
+ + BeeQuant + AI + +
+

Trading smart, trading with BeeQuant AI

+
+ +
+
+
+ ); +} diff --git a/src/module/auth/login-form/LoginFormGroup.tsx b/src/module/auth/login-form/LoginFormGroup.tsx index 5321662..f184398 100644 --- a/src/module/auth/login-form/LoginFormGroup.tsx +++ b/src/module/auth/login-form/LoginFormGroup.tsx @@ -68,7 +68,7 @@ export default function LoginFormGroup() { defaultValue="" /> - Forgot a password? + Forgot password? diff --git a/src/module/auth/reset-password-form/FormLayout.test.tsx b/src/module/auth/reset-password-form/FormLayout.test.tsx new file mode 100644 index 0000000..8526a9c --- /dev/null +++ b/src/module/auth/reset-password-form/FormLayout.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/client/testing'; +import { USER_RESET_PASSWORD } from '@/graphql/auth'; +import FormLayout from './FormLayout'; + +jest.mock('next/link', () => { + return ({ children }: { children: React.ReactNode }) => { + return children; + }; +}); + +jest.mock('@/hooks/useTitle', () => ({ + useTitle: jest.fn(), +})); + +jest.mock('./ResetPasswordSuccess', () => { + return function ResetPasswordSuccess() { + return
Reset Password Success
; + }; +}); + +const mocks = [ + { + request: { + query: USER_RESET_PASSWORD, + variables: { + input: { + newPassword: 'newPassword123', + resetToken: 'validToken', + }, + }, + }, + result: { + data: { + resetPassword: { + code: 200, + message: 'Password reset successful', + data: null, + }, + }, + }, + }, +]; + +describe('FormLayout', () => { + beforeEach(() => { + if (window.location) { + delete (window as any).location; + } + window.location = { search: '?token=validToken' } as Location; + }); + + it('renders without crashing', () => { + render( + + + + ); + const loginLink = screen.getByText((content, element) => { + if ( + element && + element.tagName.toLowerCase() === 'p' && + content.includes('Back to') && + content.includes('Login') + ) { + return true; + } + return false; + }); + expect(loginLink).toBeInTheDocument(); + expect(screen.getByText('Reset Password')).toBeInTheDocument(); + expect(screen.getByText('BeeQuant')).toBeInTheDocument(); + expect(screen.getByText('AI')).toBeInTheDocument(); + expect(screen.getByText('Trading smart, trading with BeeQuant AI')).toBeInTheDocument(); + }); + + it('displays form fields correctly', () => { + render( + + + + ); + expect(screen.getByPlaceholderText('Password')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Confirm Password')).toBeInTheDocument(); + }); +}); diff --git a/src/module/auth/reset-password-form/FormLayout.tsx b/src/module/auth/reset-password-form/FormLayout.tsx new file mode 100644 index 0000000..696a077 --- /dev/null +++ b/src/module/auth/reset-password-form/FormLayout.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + AccountHaveAccount, + AccountHead, + AccountLogo, + AccountLogoAccent, + AccountTitle, +} from '@/shared/components/account/AccountElements'; +import { useMutation } from '@apollo/client'; +import { USER_RESET_PASSWORD } from '@/graphql/auth'; +import { useTitle } from '@/hooks/useTitle'; +import Link from 'next/link'; +import styled from 'styled-components'; +import { colorBlue } from '@/styles/palette'; +import ResetPasswordFormGroup from './ResetPasswordFormGroup'; +import ResetPasswordSuccess from './ResetPasswordSuccess'; + +// region STYLES +const BackToLogin = styled(AccountHaveAccount)` + margin-top: 0; +`; + +const FailMessage = styled.div` + color: ${colorBlue}; + margin-bottom: 14px; +`; + +export default function FormLayout() { + const [resetPassword] = useMutation(USER_RESET_PASSWORD); + const [step, setStep] = useState('form'); + const [token, setToken] = useState(''); + const [failMessage, setFailMessage] = useState(''); + + useTitle('Reset Password - BeeQuant'); + + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const extractedToken = urlParams.get('token'); + if (extractedToken) { + setToken(extractedToken); + } + }, []); + + const onSuccess = async (data: { password: string }) => { + try { + const response = await resetPassword({ + variables: { + input: { + newPassword: data.password, + resetToken: token, + }, + }, + }); + + if (response.data?.resetPassword?.code === 200) { + setStep('success'); + } + const message = response.data?.resetPassword?.message; + setFailMessage(message || 'Password reset request failed.'); + } catch (e) { + console.log('error:', e); + setFailMessage('Password reset request failed.'); + } + }; + + if (step === 'success') { + return ; + } + + return ( + <> + + + Reset Password +
+ + BeeQuant + AI + +
+

Trading smart, trading with BeeQuant AI

+
+ {failMessage && ( + +
{failMessage}
+
+ )} + + +

+ Back to + + Login +

+
+ + ); +} diff --git a/src/module/auth/reset-password-form/ResetPasswordFormGroup.test.tsx b/src/module/auth/reset-password-form/ResetPasswordFormGroup.test.tsx new file mode 100644 index 0000000..1231c4b --- /dev/null +++ b/src/module/auth/reset-password-form/ResetPasswordFormGroup.test.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { FormProvider, useForm } from 'react-hook-form'; +import ResetPasswordFormGroup from './ResetPasswordFormGroup'; + +const Wrapper = ({ children }: { children: React.ReactNode }) => { + const methods = useForm(); + return {children}; +}; + +describe('ResetPasswordFormGroup', () => { + const mockOnSuccess = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + const { getByPlaceholderText } = render(, { + wrapper: Wrapper, + }); + expect(getByPlaceholderText('Password')).toBeInTheDocument(); + expect(getByPlaceholderText('Confirm Password')).toBeInTheDocument(); + }); + + it('displays an error for empty password', async () => { + const { getByPlaceholderText, findByText } = render( + , + { wrapper: Wrapper } + ); + const passwordInput = getByPlaceholderText('Password'); + + fireEvent.change(passwordInput, { target: { value: '' } }); + fireEvent.blur(passwordInput); + + await waitFor(() => { + expect(findByText('This field is required')).resolves.toBeInTheDocument(); + }); + }); + + it('displays an error for invalid password format', async () => { + const { getByPlaceholderText, findByText } = render( + , + { wrapper: Wrapper } + ); + const passwordInput = getByPlaceholderText('Password'); + + fireEvent.change(passwordInput, { target: { value: 'weak' } }); + fireEvent.blur(passwordInput); + + await waitFor(() => { + expect( + findByText( + 'must contain 8 to 32 characters, including letter, number and special character' + ) + ).resolves.toBeInTheDocument(); + }); + }); + + it('displays an error when passwords do not match', async () => { + const { getByPlaceholderText, getByText, findByText } = render( + , + { wrapper: Wrapper } + ); + const passwordInput = getByPlaceholderText('Password'); + const confirmPasswordInput = getByPlaceholderText('Confirm Password'); + + fireEvent.change(passwordInput, { target: { value: 'ValidPass1!' } }); + fireEvent.change(confirmPasswordInput, { target: { value: 'DifferentPass1!' } }); + fireEvent.click(getByText('Submit')); + + await waitFor(() => { + expect(findByText('The passwords do not match')).resolves.toBeInTheDocument(); + }); + }); + + it('calls onSuccess when form is submitted with valid data', async () => { + const { getByPlaceholderText, getByText } = render( + , + { wrapper: Wrapper } + ); + const passwordInput = getByPlaceholderText('Password'); + const confirmPasswordInput = getByPlaceholderText('Confirm Password'); + + fireEvent.change(passwordInput, { target: { value: 'ValidPass1!' } }); + fireEvent.change(confirmPasswordInput, { target: { value: 'ValidPass1!' } }); + fireEvent.click(getByText('Submit')); + + await waitFor(() => { + expect(mockOnSuccess).toHaveBeenCalledWith({ + password: 'ValidPass1!', + confirmPassword: 'ValidPass1!', + }); + }); + }); + + it('shows password requirements when password field is focused', () => { + const { getByPlaceholderText, getByText } = render( + , + { wrapper: Wrapper } + ); + const passwordInput = getByPlaceholderText('Password'); + + fireEvent.focus(passwordInput); + + expect( + getByText(': 8 to 32 characters, including letter, number and special character') + ).toBeInTheDocument(); + }); + + it('hides password requirements when password field loses focus', () => { + const { getByPlaceholderText, queryByText } = render( + , + { wrapper: Wrapper } + ); + const passwordInput = getByPlaceholderText('Password'); + + fireEvent.focus(passwordInput); + fireEvent.blur(passwordInput); + + expect( + queryByText(': 8 to 32 characters, including letter, number and special character') + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/module/auth/reset-password-form/ResetPasswordFormGroup.tsx b/src/module/auth/reset-password-form/ResetPasswordFormGroup.tsx new file mode 100644 index 0000000..c9ecada --- /dev/null +++ b/src/module/auth/reset-password-form/ResetPasswordFormGroup.tsx @@ -0,0 +1,139 @@ +import { passwordPatten } from '@/shared/utils/helpers'; +import PasswordField from '@/shared/components/form/Password'; +import { useForm, Controller, SubmitHandler } from 'react-hook-form'; +import { + FormGroup, + FormGroupField, + FormGroupLabel, + FormContainer, +} from '@/shared/components/form/FormElements'; +import { AccountButton, LastFormGroup } from '@/shared/components/account/AccountElements'; +import { useState } from 'react'; + +interface FormValues { + password: string; + confirmPassword: string; +} + +type ResetPasswordFormProps = { + onSuccess: (data: any) => void; +}; + +type IsFocused = { + password: boolean; + confirmPassword: boolean; +}; + +const ResetPasswordFormGroup = ({ onSuccess }: ResetPasswordFormProps) => { + const { handleSubmit, control, watch } = useForm({ + mode: 'onSubmit', + }); + + const onSubmit: SubmitHandler = (data) => { + if (data.password !== data.confirmPassword) { + alert('Passwords do not match.'); + return; + } + onSuccess(data); + }; + + const [isFocused, setIsFocused] = useState({ + password: false, + confirmPassword: false, + }); + + const handleFocus = (fieldName: string) => { + setIsFocused((prevIsFocused) => ({ + ...prevIsFocused, + [fieldName]: true, + })); + }; + + const handleBlur = (fieldName: string) => { + setIsFocused((prevIsFocused) => ({ + ...prevIsFocused, + [fieldName]: false, + })); + }; + + return ( + + + + Password + + {isFocused.password && + ': 8 to 32 characters, including letter, number and special character'} + + + + ( + handleBlur(field.name) }} + meta={{ + touched: !!fieldState.error, + error: fieldState.error?.message, + }} + placeholder="Password" + keyIcon + isAboveError + onFocus={() => handleFocus(field.name)} + /> + )} + defaultValue="" + /> + + + + + Confirm Password + {isFocused.confirmPassword && ''} + + + { + const { password } = watch(); + return password === value || 'The passwords do not match'; + }, + }, + }} + render={({ field, fieldState }) => ( + + )} + defaultValue="" + /> + + + + Submit + + + ); +}; + +export default ResetPasswordFormGroup; diff --git a/src/module/auth/reset-password-form/ResetPasswordSuccess.test.tsx b/src/module/auth/reset-password-form/ResetPasswordSuccess.test.tsx new file mode 100644 index 0000000..becce3e --- /dev/null +++ b/src/module/auth/reset-password-form/ResetPasswordSuccess.test.tsx @@ -0,0 +1,47 @@ +import { screen, render } from '@testing-library/react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import ResetPasswordSuccess from './ResetPasswordSuccess'; + +jest.mock('@/shared/components/account/AccountElements', () => ({ + AccountButton: (props: any) => , + AccountHead: (props: any) =>
{props.children}
, + AccountLogo: (props: any) =>
{props.children}
, + AccountLogoAccent: (props: any) => {props.children}, + AccountTitle: (props: any) =>

{props.children}

, +})); + +describe('ResetPasswordSuccess component', () => { + it('should render successfully and show success message', () => { + render( + + + + ); + + const successMessage = screen.getByText(/Your password has been reset/i); + expect(successMessage).toBeInTheDocument(); + }); + + it('should render success image', () => { + render( + + + + ); + const image = screen.getByAltText('success'); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', 'img/success.png'); + }); + + it('should render button with correct link', () => { + const { getByText } = render( + + + + ); + const button = getByText('Back to Login'); + expect(button).toBeInTheDocument(); + const linkElement = button.closest('a'); + expect(linkElement).toHaveAttribute('href', '/login'); + }); +}); diff --git a/src/module/auth/reset-password-form/ResetPasswordSuccess.tsx b/src/module/auth/reset-password-form/ResetPasswordSuccess.tsx new file mode 100644 index 0000000..851f23d --- /dev/null +++ b/src/module/auth/reset-password-form/ResetPasswordSuccess.tsx @@ -0,0 +1,37 @@ +import styled from 'styled-components'; +import Link from 'next/link'; +import { + AccountButton, + AccountHead, + AccountLogo, + AccountLogoAccent, + AccountTitle, +} from '@/shared/components/account/AccountElements'; + +const AccountImage = styled.img` + max-width: 500px; + width: 100%; + margin-bottom: 40px; +`; + +const ResetPasswordSuccess = () => ( + <> + + + + + + Cool ! +
+
+
+ Your password has been reset +
+
+ + Back to Login + + +); + +export default ResetPasswordSuccess; diff --git a/src/module/auth/reset-password-form/reset-password-form.component.tsx b/src/module/auth/reset-password-form/reset-password-form.component.tsx new file mode 100644 index 0000000..ee7c164 --- /dev/null +++ b/src/module/auth/reset-password-form/reset-password-form.component.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { + AccountWrap, + AccountContent, + AccountCard, +} from '@/shared/components/account/AccountElements'; +import FormLayout from './FormLayout'; + +export default function ResetPasswordForm() { + return ( + + + + + + + + ); +} diff --git a/src/routes/routeConfig.ts b/src/routes/routeConfig.ts index 11968d1..a3cb16a 100644 --- a/src/routes/routeConfig.ts +++ b/src/routes/routeConfig.ts @@ -13,6 +13,8 @@ import ExchangeDetails from 'app/(protected)/crypto/exchange/details/page'; import PriceDetails from 'app/(protected)/crypto/price/details/page'; import BotDetail from 'app/(protected)/bot/details/page'; import BotCreate from 'app/(protected)/bot/create/page'; +import ForgotPassword from 'app/(auth)/forgot-password/page'; +import ResetPassword from 'app/(auth)/reset-password/page'; interface IRoute { path: string; @@ -30,6 +32,8 @@ export const ROUTE_KEY = { LOGIN: 'login', REGISTER: 'register', SETTINGS: 'settings', + FORGOT_PASSWORD: 'forgot_password', + RESET_PASSWORD: 'reset_password', BOT_DASHBOARD: 'bot_dashboard', BOT_MANAGEMENT: 'bot_management', BOT_CREATE: 'bot_create', @@ -53,6 +57,21 @@ export const PUBLIC_ROUTE_CONFIG: Record = { title: 'Register - BeeQuant', component: Register, }, + + [ROUTE_KEY.FORGOT_PASSWORD]: { + path: '/forgot', + name: 'Forgot Password', + title: 'Forgot Password - BeeQuant', + component: ForgotPassword, + }, + + [ROUTE_KEY.RESET_PASSWORD]: { + path: '/reset-password', + name: 'Reset Password', + title: 'Reset Password - BeeQuant', + component: ResetPassword, + }, + [ROUTE_KEY.PAGE_404]: { path: '/404', name: '404',