diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 2ff16e994..4a4c1bc51 100755 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -69,6 +69,7 @@ module.exports = { ['/guide/graphql-get', 'Use GET for GraphQL Queries'], ['/guide/configuration', 'Configuration'], ['/guide/override-queries', 'Override queries'], + ['/guide/recaptcha', 'ReCaptcha'], ['/guide/testing', 'Testing'] ] }, diff --git a/docs/api-reference/magento-api.createproductreview.md b/docs/api-reference/magento-api.createproductreview.md index dbe5a814b..0dbe94249 100644 --- a/docs/api-reference/magento-api.createproductreview.md +++ b/docs/api-reference/magento-api.createproductreview.md @@ -7,5 +7,5 @@ Signature: ```typescript -_default: (context: Context, input: CreateProductReviewMutationVariables, customQuery?: CustomQuery) => Promise> +_default: (context: Context, input: CreateProductReviewInput, customQuery?: CustomQuery) => Promise> ``` diff --git a/docs/guide/recaptcha.md b/docs/guide/recaptcha.md new file mode 100644 index 000000000..874cc9f60 --- /dev/null +++ b/docs/guide/recaptcha.md @@ -0,0 +1,52 @@ +# reCaptcha + +You can activate the reCaptchta feature using this Guidelines. + +## Activate reCaptcha module + +Uncomment the line below in the `nuxt.config.js` file to activate the module. + +```js +... + '@vue-storefront/middleware/nuxt', + '@nuxtjs/html-validator', + // '@nuxtjs/recaptcha', + ], + recaptcha: { +... + +``` + +## Configure the reCaptcha + +On the `config` folder update the config file (`dev.json` for example) with your configurations. + +```json5 +{ + ... + "recaptchaEnabled": "{YOUR_RECAPTCHA_ENABLED}", // true or false, default value is false + "recaptchaHideBadge": "{YOUR_RECAPTCHA_HIDE_BADGE}", // true or false, default value is false + "recaptchaSize": "{YOUR_RECAPTCHA_SIZE}", // Size: 'compact', 'normal', 'invisible' (v2), default value is 'invisible' + "recaptchaSiteKey": "{YOUR_RECAPTCHA_SITE_KEY}", // Site key for requests, default value is '' + "recaptchaSecretkey": "{YOUR_RECAPTCHA_SECRET_KEY}", // Secret key for requests, default value is '' + "recaptchaVersion": "{YOUR_RECAPTCHA_VERSION}", // Version 2 or 3, default value is 3 + "recaptchaMinScore": "{YOUR_RECAPTCHA_MIN_SCORE}" // The min score used for v3, default value is 0.5 + ... +} +``` + +### Sample configuration + +```json5 +{ + ... + "recaptchaEnabled": true, + "recaptchaHideBadge": false, + "recaptchaSize": "invisible", + "recaptchaSiteKey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "recaptchaSecretkey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "recaptchaVersion": 3, + "recaptchaMinScore": 0.5 + ... +} +``` diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 00ee49e3a..ad39b9f12 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -57,4 +57,4 @@ "engines": { "node": ">=16.x" } -} +} \ No newline at end of file diff --git a/packages/api-client/src/api/createCustomer/index.ts b/packages/api-client/src/api/createCustomer/index.ts index f47e00908..a3f45e4a4 100644 --- a/packages/api-client/src/api/createCustomer/index.ts +++ b/packages/api-client/src/api/createCustomer/index.ts @@ -1,5 +1,7 @@ import { FetchResult } from '@apollo/client/core'; import { CustomQuery } from '@vue-storefront/core'; +import { GraphQLError } from 'graphql'; +import recaptchaValidator from '../../helpers/recaptcha/recaptchaValidator'; import { CreateCustomerMutation, CreateCustomerMutationVariables, @@ -14,12 +16,30 @@ export default async ( customQuery: CustomQuery = { createCustomer: 'createCustomer' }, ): Promise> => { try { + const { + recaptchaToken, ...variables + } = input; + + if (context.config.recaptcha.isEnabled) { + /** + * recaptcha token verification + */ + const response = await recaptchaValidator(context, recaptchaToken); + + if (!response.success) { + return { + errors: [new GraphQLError('Error during reCaptcha verification. Please try again.')], + data: null, + }; + } + } + const { createCustomer: createCustomerGQL } = context.extendQuery( customQuery, { createCustomer: { query: createCustomer, - variables: { input }, + variables: { input: variables }, }, }, ); diff --git a/packages/api-client/src/api/createProductReview/index.ts b/packages/api-client/src/api/createProductReview/index.ts index bf9bd4fb4..f227a98d4 100644 --- a/packages/api-client/src/api/createProductReview/index.ts +++ b/packages/api-client/src/api/createProductReview/index.ts @@ -1,25 +1,45 @@ import { FetchResult } from '@apollo/client/core'; import { CustomQuery } from '@vue-storefront/core'; -import { CreateProductReviewMutation, CreateProductReviewMutationVariables } from '../../types/GraphQL'; +import { GraphQLError } from 'graphql'; +import { CreateProductReviewMutation, CreateProductReviewInput } from '../../types/GraphQL'; import createProductReview from './createProductReview'; import { Context } from '../../types/context'; +import recaptchaValidator from '../../helpers/recaptcha/recaptchaValidator'; export default async ( context: Context, - input: CreateProductReviewMutationVariables, + input: CreateProductReviewInput, customQuery: CustomQuery = { createProductReview: 'createProductReview' }, ): Promise> => { + const { + recaptchaToken, ...variables + } = input; + + if (context.config.recaptcha.isEnabled) { + /** + * recaptcha token verification + */ + const response = await recaptchaValidator(context, recaptchaToken); + + if (!response.success) { + return { + errors: [new GraphQLError('Error during reCaptcha verification. Please try again.')], + data: null, + }; + } + } + const { createProductReview: createProductReviewGQL } = context.extendQuery( customQuery, { createProductReview: { query: createProductReview, - variables: { input }, + variables: { input: variables }, }, }, ); - return context.client.mutate({ + return context.client.mutate({ mutation: createProductReviewGQL.query, variables: createProductReviewGQL.variables, }); diff --git a/packages/api-client/src/api/generateCustomerToken/index.ts b/packages/api-client/src/api/generateCustomerToken/index.ts index 4e0dc3e4d..917f33267 100644 --- a/packages/api-client/src/api/generateCustomerToken/index.ts +++ b/packages/api-client/src/api/generateCustomerToken/index.ts @@ -1,5 +1,7 @@ import { FetchResult } from '@apollo/client/core'; import { CustomQuery } from '@vue-storefront/core'; +import { GraphQLError } from 'graphql'; +import recaptchaValidator from '../../helpers/recaptcha/recaptchaValidator'; import generateCustomerToken from './generateCustomerToken'; import { GenerateCustomerTokenMutation, @@ -12,10 +14,25 @@ export default async ( params: { email: string; password: string; + recaptchaToken: string; }, customQuery: CustomQuery = { generateCustomerToken: 'generateCustomerToken' }, ): Promise> => { try { + if (context.config.recaptcha.isEnabled) { + /** + * recaptcha token verification + */ + const response = await recaptchaValidator(context, params.recaptchaToken); + + if (!response.success) { + return { + errors: [new GraphQLError('Error during reCaptcha verification. Please try again.')], + data: null, + }; + } + } + const { generateCustomerToken: generateCustomerTokenGQL } = context.extendQuery( customQuery, { diff --git a/packages/api-client/src/api/requestPasswordResetEmail/index.ts b/packages/api-client/src/api/requestPasswordResetEmail/index.ts index 79bfb01db..4550c46a3 100644 --- a/packages/api-client/src/api/requestPasswordResetEmail/index.ts +++ b/packages/api-client/src/api/requestPasswordResetEmail/index.ts @@ -1,5 +1,7 @@ import { FetchResult } from '@apollo/client/core'; import { CustomQuery, Logger } from '@vue-storefront/core'; +import { GraphQLError } from 'graphql'; +import recaptchaValidator from '../../helpers/recaptcha/recaptchaValidator'; import requestPasswordResetEmailMutation from './requestPasswordResetEmail'; import { RequestPasswordResetEmailMutation, @@ -12,10 +14,28 @@ export default async ( input: RequestPasswordResetEmailMutationVariables, customQuery: CustomQuery = { requestPasswordResetEmail: 'requestPasswordResetEmail' }, ): Promise> => { + const { + recaptchaToken, ...variables + } = input; + + if (context.config.recaptcha.isEnabled) { + /** + * recaptcha token verification + */ + const response = await recaptchaValidator(context, recaptchaToken); + + if (!response.success) { + return { + errors: [new GraphQLError('Error during reCaptcha verification. Please try again.')], + data: null, + }; + } + } + const { requestPasswordResetEmail } = context.extendQuery(customQuery, { requestPasswordResetEmail: { query: requestPasswordResetEmailMutation, - variables: { ...input }, + variables: { ...variables }, }, }); diff --git a/packages/api-client/src/api/resetPassword/index.ts b/packages/api-client/src/api/resetPassword/index.ts index b4b9183f1..acc54945f 100644 --- a/packages/api-client/src/api/resetPassword/index.ts +++ b/packages/api-client/src/api/resetPassword/index.ts @@ -1,22 +1,42 @@ import { FetchResult } from '@apollo/client/core'; import { CustomQuery, Logger } from '@vue-storefront/core'; import gql from 'graphql-tag'; +import { GraphQLError } from 'graphql'; import resetPasswordMutation from './resetPassword'; import { ResetPasswordMutation, ResetPasswordMutationVariables, } from '../../types/GraphQL'; import { Context } from '../../types/context'; +import recaptchaValidator from '../../helpers/recaptcha/recaptchaValidator'; export default async ( context: Context, input: ResetPasswordMutationVariables, customQuery: CustomQuery = { resetPassword: 'resetPassword' }, ): Promise> => { + const { + recaptchaToken, ...variables + } = input; + + if (context.config.recaptcha.isEnabled) { + /** + * recaptcha token verification + */ + const response = await recaptchaValidator(context, recaptchaToken); + + if (!response.success) { + return { + errors: [new GraphQLError('Error during reCaptcha verification. Please try again.')], + data: null, + }; + } + } + const { resetPassword } = context.extendQuery(customQuery, { resetPassword: { query: resetPasswordMutation, - variables: { ...input }, + variables: { ...variables }, }, }); diff --git a/packages/api-client/src/helpers/recaptcha/recaptchaValidator.ts b/packages/api-client/src/helpers/recaptcha/recaptchaValidator.ts new file mode 100644 index 000000000..67da1b09b --- /dev/null +++ b/packages/api-client/src/helpers/recaptcha/recaptchaValidator.ts @@ -0,0 +1,26 @@ +import { Context } from '../../types/context'; + +interface RecaptchaApiResponse { + success: boolean, + challenge_ts: string, + hostname: string, + 'error-codes'?: [any], + score?: number +} + +export default async ( + context: Context, + token: string, +): Promise => { + try { + const { secretkey } = context.config.recaptcha; + const url = `https://www.google.com/recaptcha/api/siteverify?secret=${secretkey}&response=${token}`; + + const result = await fetch(url); + const response = await result.json(); + + return response; + } catch (error) { + throw error.message || error; + } +}; diff --git a/packages/api-client/src/types/API.ts b/packages/api-client/src/types/API.ts index ddfee8602..3ee9c00bd 100644 --- a/packages/api-client/src/types/API.ts +++ b/packages/api-client/src/types/API.ts @@ -295,7 +295,7 @@ export interface MagentoApiMethods { ): Promise>; generateCustomerToken( - params: { email: string, password: string }, + params: { email: string, password: string, recaptchaToken: string }, customQuery?: CustomQuery ): Promise>; diff --git a/packages/api-client/src/types/GraphQL.ts b/packages/api-client/src/types/GraphQL.ts index 80dbb657b..dc5a1e577 100644 --- a/packages/api-client/src/types/GraphQL.ts +++ b/packages/api-client/src/types/GraphQL.ts @@ -2007,6 +2007,8 @@ export interface CreateProductReviewInput { summary: Scalars['String']; /** The review text. */ text: Scalars['String']; + /** The reCaptcha Token. */ + recaptchaToken?: Scalars['String']; } export interface CreateProductReviewOutput { @@ -2532,6 +2534,8 @@ export interface CustomerCreateInput { suffix?: InputMaybe; /** The customer's Tax/VAT number (for corporate customers) */ taxvat?: InputMaybe; + /** The reCaptcha Token */ + recaptchaToken?: InputMaybe; } export interface CustomerDownloadableProduct { @@ -7312,6 +7316,7 @@ export type RemoveProductsFromWishlistMutation = { removeProductsFromWishlist?: export type RequestPasswordResetEmailMutationVariables = Exact<{ email: Scalars['String']; + recaptchaToken?: Scalars['String']; }>; @@ -7321,6 +7326,7 @@ export type ResetPasswordMutationVariables = Exact<{ email: Scalars['String']; newPassword: Scalars['String']; resetPasswordToken: Scalars['String']; + recaptchaToken?: Scalars['String']; }>; @@ -7403,6 +7409,7 @@ export type UpdateCustomerAddressMutation = { updateCustomerAddress?: { id?: num export type UpdateCustomerEmailMutationVariables = Exact<{ email: Scalars['String']; password: Scalars['String']; + recaptchaToken?: Scalars['String']; }>; diff --git a/packages/api-client/src/types/setup.ts b/packages/api-client/src/types/setup.ts index 32305924f..ac480ecc0 100644 --- a/packages/api-client/src/types/setup.ts +++ b/packages/api-client/src/types/setup.ts @@ -71,12 +71,21 @@ export interface ClientConfig { state: ConfigState; } +export interface RecaptchaConfig { + isEnabled: boolean, + sitekey: string, + secretkey: string, + version: number, + score: number, +} + export interface Config extends ClientConfig { client?: ApolloClient; storage: Storage; customOptions?: ApolloClientOptions; customApolloHttpLinkOptions?: HttpOptions; overrides: MagentoApiMethods; + recaptcha: RecaptchaConfig; } export interface ClientInstance extends ApolloClient { diff --git a/packages/composables/src/composables/useForgotPassword/index.ts b/packages/composables/src/composables/useForgotPassword/index.ts index 18134c542..8dba9e13c 100644 --- a/packages/composables/src/composables/useForgotPassword/index.ts +++ b/packages/composables/src/composables/useForgotPassword/index.ts @@ -9,7 +9,7 @@ const factoryParams: UseForgotPasswordFactoryParams = { resetPassword: async (context: Context, params) => { Logger.debug('[Magento]: Reset user password', { params }); - const { data } = await context.$magento.api.requestPasswordResetEmail({ email: params.email }); + const { data } = await context.$magento.api.requestPasswordResetEmail({ email: params.email, recaptchaToken: params.recaptchaToken }); Logger.debug('[Result]:', { data }); @@ -24,6 +24,7 @@ const factoryParams: UseForgotPasswordFactoryParams = { email: params.email, newPassword: params.newPassword, resetPasswordToken: params.tokenValue, + recaptchaToken: params.recaptchaToken, }); Logger.debug('[Result]:', { data }); diff --git a/packages/composables/src/composables/useUser/index.ts b/packages/composables/src/composables/useUser/index.ts index e3585f086..7ab5752f2 100644 --- a/packages/composables/src/composables/useUser/index.ts +++ b/packages/composables/src/composables/useUser/index.ts @@ -2,12 +2,28 @@ import { Context, Logger, useUserFactory, - UseUserFactoryParams, + UseUserFactoryParams as UserUserFactoryParamsBase, } from '@vue-storefront/core'; import { CustomerCreateInput, UpdateCustomerEmailMutationVariables } from '@vue-storefront/magento-api'; +import { CustomQuery } from '@vue-storefront/core/lib/src/types'; import useCart from '../useCart'; import { generateUserData } from '../../helpers/userDataGenerator'; +interface UseUserFactoryParams + extends UserUserFactoryParamsBase { + logIn: (context: Context, params: { + username: string; + password: string; + recaptchaToken?: string; + customQuery?: CustomQuery; + }) => Promise; + + register: (context: Context, params: REGISTER_USER_PARAMS & { + customQuery?: CustomQuery; + recaptchaInstance?: any; + }) => Promise; +} + const factoryParams: UseUserFactoryParams< any, UpdateCustomerEmailMutationVariables, @@ -18,7 +34,7 @@ CustomerCreateInput cart: useCart(), }; }, - load: async (context: Context, params) => { + load: async (context: Context) => { Logger.debug('[Magento] Load user information'); const apiState = context.$magento.config.state; @@ -70,10 +86,20 @@ CustomerCreateInput return data.updateCustomerV2.customer; }, register: async (context: Context, params) => { - const { email, password, ...baseData } = generateUserData(params); + const { + email, + password, + recaptchaToken, + ...baseData + } = generateUserData(params); const { data, errors } = await context.$magento.api.createCustomer( - { email, password, ...baseData }, + { + email, + password, + recaptchaToken, + ...baseData, + }, ); Logger.debug('[Result]:', { data }); @@ -87,9 +113,17 @@ CustomerCreateInput throw new Error('Customer registration error'); } + if (recaptchaToken) { + // generate a new token for the login action + const { recaptchaInstance } = params; + const newRecaptchaToken = await recaptchaInstance.getResponse(); + + return factoryParams.logIn(context, { username: email, password, recaptchaToken: newRecaptchaToken }); + } + return factoryParams.logIn(context, { username: email, password }); }, - logIn: async (context: Context, params) => { + logIn: async (context: Context, params: any) => { Logger.debug('[Magento] Authenticate user'); const apiState = context.$magento.config.state; @@ -97,6 +131,7 @@ CustomerCreateInput { email: params.username, password: params.password, + recaptchaToken: params.recaptchaToken, }, ); @@ -153,5 +188,5 @@ CustomerCreateInput export default useUserFactory< any, UpdateCustomerEmailMutationVariables, -CustomerCreateInput & { email: string; password: string } +CustomerCreateInput & { email: string; password: string, recaptchaToken?: string } >(factoryParams); diff --git a/packages/composables/src/factories/useForgotPasswordFactory.ts b/packages/composables/src/factories/useForgotPasswordFactory.ts index 2247412a9..ae2c50954 100644 --- a/packages/composables/src/factories/useForgotPasswordFactory.ts +++ b/packages/composables/src/factories/useForgotPasswordFactory.ts @@ -14,10 +14,12 @@ interface SetNewPasswordParams { tokenValue: string; newPassword: string; email: string; + recaptchaToken?: string; } interface ResetPasswordParams { email: string; + recaptchaToken?: string; } export interface UseForgotPasswordFactoryParams extends FactoryParams { diff --git a/packages/composables/src/helpers/userDataGenerator.ts b/packages/composables/src/helpers/userDataGenerator.ts index 2de94646b..5d9830de1 100644 --- a/packages/composables/src/helpers/userDataGenerator.ts +++ b/packages/composables/src/helpers/userDataGenerator.ts @@ -35,5 +35,9 @@ export const generateUserData = (userData): CustomerUpdateParameters => { baseData.password = userData.password; } + if (Object.prototype.hasOwnProperty.call(userData, 'recaptchaToken')) { + baseData.recaptchaToken = userData.recaptchaToken; + } + return baseData; }; diff --git a/packages/composables/src/types/composables.ts b/packages/composables/src/types/composables.ts index 1592965fa..3ce8a12f3 100644 --- a/packages/composables/src/types/composables.ts +++ b/packages/composables/src/types/composables.ts @@ -203,7 +203,7 @@ export interface UseForgotPassword { setNew(params: ComposableFunctionArgs<{ tokenValue: string, newPassword: string, email: string }>): Promise; - request(params: ComposableFunctionArgs<{ email: string }>): Promise; + request(params: ComposableFunctionArgs<{ email: string, recaptchaToken?: string }>): Promise; } export interface UseRelatedProducts extends Composable { diff --git a/packages/theme/components/LoginModal.vue b/packages/theme/components/LoginModal.vue index a7ee451df..48908d8a0 100644 --- a/packages/theme/components/LoginModal.vue +++ b/packages/theme/components/LoginModal.vue @@ -56,6 +56,7 @@ class="form__element" /> +
{{ error.login }}
@@ -118,6 +119,7 @@ class="form__element" /> +
{{ $t('It was not possible to request a new password, please check the entered email address.') }}
@@ -244,6 +246,7 @@ class="form__element" /> +
{{ error.register }}
@@ -283,6 +286,7 @@ import { reactive, defineComponent, computed, + useContext, } from '@nuxtjs/composition-api'; import { SfModal, @@ -335,6 +339,9 @@ export default defineComponent({ const isForgotten = ref(false); const isThankYouAfterForgotten = ref(false); const userEmail = ref(''); + const { $recaptcha, $config } = useContext(); + const isRecaptchaEnabled = ref(typeof $recaptcha !== 'undefined' && $config.isRecaptcha); + const { register, login, @@ -388,12 +395,30 @@ export default defineComponent({ const handleForm = (fn) => async () => { resetErrorValues(); - await fn({ - user: { - ...form.value, - is_subscribed: isSubscribed.value, - }, - }); + + if (isRecaptchaEnabled.value) { + $recaptcha.init(); + } + + if (isRecaptchaEnabled.value) { + const recaptchaToken = await $recaptcha.getResponse(); + form.value.recaptchaInstance = $recaptcha; + + await fn({ + user: { + ...form.value, + is_subscribed: isSubscribed.value, + recaptchaToken, + }, + }); + } else { + await fn({ + user: { + ...form.value, + is_subscribed: isSubscribed.value, + }, + }); + } const hasUserErrors = userError.value.register || userError.value.login; if (hasUserErrors) { @@ -402,6 +427,11 @@ export default defineComponent({ return; } toggleLoginModal(); + + if (isRecaptchaEnabled.value) { + // reset recaptcha + $recaptcha.reset(); + } }; const handleRegister = async () => handleForm(register)(); @@ -410,12 +440,28 @@ export default defineComponent({ const handleForgotten = async () => { userEmail.value = form.value.username; - await request({ email: userEmail.value }); + + if (isRecaptchaEnabled.value) { + $recaptcha.init(); + } + + if (isRecaptchaEnabled.value) { + const recaptchaToken = await $recaptcha.getResponse(); + + await request({ email: userEmail.value, recaptchaToken }); + } else { + await request({ email: userEmail.value }); + } if (!forgotPasswordError.value.request) { isThankYouAfterForgotten.value = true; isForgotten.value = false; } + + if (isRecaptchaEnabled.value) { + // reset recaptcha + $recaptcha.reset(); + } }; return { @@ -440,6 +486,7 @@ export default defineComponent({ setIsLoginValue, userEmail, userError, + isRecaptchaEnabled, }; }, }); diff --git a/packages/theme/components/ProductAddReviewForm.vue b/packages/theme/components/ProductAddReviewForm.vue index 53ef516e6..fb535b759 100644 --- a/packages/theme/components/ProductAddReviewForm.vue +++ b/packages/theme/components/ProductAddReviewForm.vue @@ -97,11 +97,13 @@ :cols="60" :rows="10" wrap="soft" + required :valid="!errors[0]" :error-message="errors[0]" /> + Add review @@ -116,6 +118,7 @@ import { onBeforeMount, computed, useRoute, + useContext, } from '@nuxtjs/composition-api'; import { reviewGetters, useReview, userGetters, useUser, @@ -165,6 +168,8 @@ export default defineComponent({ setup(_, { emit }) { const route = useRoute(); const { params: { id } } = route.value; + const { $recaptcha, $config } = useContext(); + const isRecaptchaEnabled = ref(typeof $recaptcha !== 'undefined' && $config.isRecaptcha); const { loading, loadReviewMetadata, @@ -193,10 +198,11 @@ export default defineComponent({ ...form.value, nickname, ratings, + recaptchaToken: '', }; }); - const submitForm = (reset) => () => { + const submitForm = (reset) => async () => { if (!( formSubmitValue.value.ratings[0].value_id || formSubmitValue.value.ratings[0].id @@ -206,11 +212,24 @@ export default defineComponent({ || formSubmitValue.value.text )) return; try { + if (isRecaptchaEnabled.value) { + $recaptcha.init(); + } + + if (isRecaptchaEnabled.value) { + const recaptchaToken = await $recaptcha.getResponse(); + formSubmitValue.value.recaptchaToken = recaptchaToken; + } + reviewSent.value = true; emit('add-review', formSubmitValue.value); reset(); + + if (isRecaptchaEnabled.value) { + $recaptcha.reset(); + } } catch { reviewSent.value = false; } @@ -229,6 +248,7 @@ export default defineComponent({ ratingMetadata, reviewSent, submitForm, + isRecaptchaEnabled, }; }, }); diff --git a/packages/theme/components/__tests__/LoginModal.spec.js b/packages/theme/components/__tests__/LoginModal.spec.js new file mode 100644 index 000000000..e25bdad36 --- /dev/null +++ b/packages/theme/components/__tests__/LoginModal.spec.js @@ -0,0 +1,211 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { ref } from '@nuxtjs/composition-api'; +import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/vue'; +import { useUser, useForgotPassword } from '@vue-storefront/magento'; +import { render, useUserMock, useForgotPasswordMock } from '~/test-utils'; +import useUiState from '~/composables/useUiState.ts'; + +import LoginModal from '../LoginModal'; + +jest.mock('~/composables/useUiState.ts', () => jest.fn()); +jest.mock('@vue-storefront/magento', () => ({ + useUser: jest.fn(), + useForgotPassword: jest.fn(), +})); + +describe('', () => { + it('User can log in', async () => { + useUiState.mockReturnValue({ + isLoginModalOpen: ref(true), + toggleLoginModal: jest.fn(), + }); + const loginMock = jest.fn(); + useForgotPassword.mockReturnValue(useForgotPasswordMock()); + useUser.mockReturnValue(useUserMock({ + login: loginMock, + })); + + const values = { + email: 'james@bond.io', + password: 'J@mesBond007!', + token: 'recaptcha token', + }; + + const $recaptchaInstance = { + init: jest.fn(), + reset: jest.fn(), + getResponse: jest.fn().mockResolvedValue(Promise.resolve(values.token)), + }; + const { getByRole, findByLabelText, queryByTestId } = render(LoginModal, { + mocks: { + $nuxt: { + context: { + $config: { isRecaptcha: true }, + $recaptcha: $recaptchaInstance, + }, + }, + }, + }); + + const recaptchaComponent = queryByTestId('recaptcha'); + expect(recaptchaComponent).toBeTruthy(); + + const emailInput = getByRole('textbox', { name: /your email/i }); + const passwordInput = await findByLabelText('Password'); + + userEvent.type(emailInput, values.email); + userEvent.type(passwordInput, values.password); + + const submitButton = getByRole('button', { name: /login/i }); + userEvent.click(submitButton); + + await waitFor(() => { + expect(loginMock).toHaveBeenCalledTimes(1); + expect(loginMock).toHaveBeenCalledWith({ + user: { + username: values.email, + password: values.password, + is_subscribed: false, + recaptchaInstance: $recaptchaInstance, + recaptchaToken: values.token, + }, + }); + }); + }); + + it('User can register', async () => { + const registerMock = jest.fn(); + useForgotPassword.mockReturnValue(useForgotPasswordMock()); + useUser.mockReturnValue(useUserMock({ + register: registerMock, + })); + useUiState.mockReturnValue({ + isLoginModalOpen: ref(true), + toggleLoginModal: jest.fn(), + }); + + const values = { + email: 'james@bond.io', + firstName: 'James', + lastName: 'Bond', + password: 'J@mesBond007!', + token: 'recaptcha token', + }; + + const $recaptchaInstance = { + init: jest.fn(), + reset: jest.fn(), + getResponse: jest.fn().mockResolvedValue(Promise.resolve(values.token)), + }; + const { + getByRole, + findByRole, + findByLabelText, + queryByTestId, + } = render(LoginModal, { + mocks: { + $nuxt: { + context: { + $config: { isRecaptcha: true }, + $recaptcha: $recaptchaInstance, + }, + }, + }, + }); + + const switchToRegisterButton = getByRole('button', { name: /register today/i }); + userEvent.click(switchToRegisterButton); + + await waitFor(() => findByRole('button', { name: /create an account/i })); + + const recaptchaComponent = queryByTestId('recaptcha'); + expect(recaptchaComponent).toBeTruthy(); + + const emailInput = getByRole('textbox', { name: /your email/i }); + const firstNameInput = getByRole('textbox', { name: /first name/i }); + const lastNameInput = getByRole('textbox', { name: /last name/i }); + const passwordInput = await findByLabelText('Password'); + const newsletterCheckbox = getByRole('checkbox', { name: /sign up for newsletter/i }); + const createAccountCheckbox = getByRole('checkbox', { name: /i want to create an account/i }); + + userEvent.type(emailInput, values.email); + userEvent.type(firstNameInput, values.firstName); + userEvent.type(lastNameInput, values.lastName); + userEvent.type(passwordInput, values.password); + userEvent.click(newsletterCheckbox); + userEvent.click(createAccountCheckbox); + + const submitButton = getByRole('button', { name: /create an account/i }); + userEvent.click(submitButton); + + await waitFor(() => { + expect(registerMock).toHaveBeenCalledTimes(1); + expect(registerMock).toHaveBeenCalledWith({ + user: { + email: values.email, + firstName: values.firstName, + is_subscribed: true, + lastName: values.lastName, + password: values.password, + recaptchaInstance: $recaptchaInstance, + recaptchaToken: values.token, + }, + }); + }); + }); + + it('User can reset his password', async () => { + const requestPasswordMock = jest.fn(); + useForgotPassword.mockReturnValue(useForgotPasswordMock({ + request: requestPasswordMock, + })); + useUser.mockReturnValue(useUserMock()); + useUiState.mockReturnValue({ + isLoginModalOpen: ref(true), + toggleLoginModal: jest.fn(), + }); + + const values = { + email: 'james@bond.io', + token: 'recaptcha token', + }; + + const { getByRole, findByRole, queryByTestId } = render(LoginModal, { + mocks: { + $nuxt: { + context: { + $config: { isRecaptcha: true }, + $recaptcha: { + init: jest.fn(), + reset: jest.fn(), + getResponse: jest.fn().mockResolvedValue(Promise.resolve(values.token)), + }, + }, + }, + }, + }); + + const forgottenPasswordButton = getByRole('button', { name: /forgotten password/i }); + userEvent.click(forgottenPasswordButton); + + await waitFor(() => findByRole('button', { name: /reset password/i })); + + const recaptchaComponent = queryByTestId('recaptcha'); + expect(recaptchaComponent).toBeTruthy(); + + const emailInput = getByRole('textbox', { name: /email/i }); + userEvent.type(emailInput, values.email); + + const submitButton = getByRole('button', { name: /reset password/i }); + userEvent.click(submitButton); + + await waitFor(() => { + expect(requestPasswordMock).toHaveBeenCalledTimes(1); + expect(requestPasswordMock).toHaveBeenCalledWith({ + email: values.email, + recaptchaToken: values.token, + }); + }); + }); +}); diff --git a/packages/theme/components/__tests__/ProductAddReviewForm.spec.js b/packages/theme/components/__tests__/ProductAddReviewForm.spec.js new file mode 100644 index 000000000..36891fd18 --- /dev/null +++ b/packages/theme/components/__tests__/ProductAddReviewForm.spec.js @@ -0,0 +1,133 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { useRoute } from '@nuxtjs/composition-api'; +import { useUser, useReview } from '@vue-storefront/magento'; +import { + render, + useUserMock, + useReviewMock, +} from '~/test-utils'; + +import ProductAddReviewForm from '../ProductAddReviewForm'; + +jest.mock('@vue-storefront/magento', () => { + const originalModule = jest.requireActual('@vue-storefront/magento'); + return { + ...originalModule, + useUser: jest.fn(), + useReview: jest.fn(), + }; +}); + +jest.mock('@nuxtjs/composition-api', () => { + // Require the original module to not be mocked... + const originalModule = jest.requireActual('@nuxtjs/composition-api'); + + return { + ...originalModule, + useRoute: jest.fn(), + }; +}); + +describe('', () => { + it('Form fields are rendered and validated', async () => { + useUser.mockReturnValue(useUserMock()); + useReview.mockReturnValue(useReviewMock()); + useRoute.mockReturnValue({ value: { params: { id: '' } } }); + + const { getByRole, findAllByText, queryByTestId } = render(ProductAddReviewForm); + + // Nickname, title and review fields should be rendered and required + + const recaptchaComponent = queryByTestId('recaptcha'); + expect(recaptchaComponent).toBeNull(); + + const nickname = getByRole('textbox', { name: /name/i }); + expect(nickname).toBeRequired(); + + const summary = getByRole('textbox', { name: /title/i }); + expect(summary).toBeRequired(); + + const text = getByRole('textbox', { name: /review/i }); + expect(text).toBeRequired(); + + const submitButton = getByRole('button', { name: /add review/i }); + userEvent.click(submitButton); + + // should display form errors when field are not filled + const errors = await findAllByText('This field is required'); + expect(errors).toHaveLength(3); + }); + + it('User can submit a review', async () => { + const values = { + nickname: 'nickname value', + rating: '2', + sku: 'sku value', + summary: 'summary value', + text: 'text value', + token: 'token value', + }; + + useRoute.mockReturnValue({ value: { params: { id: values.sku } } }); + useUser.mockReturnValue(useUserMock()); + useReview.mockReturnValue(useReviewMock({ + metadata: { + value: [{ + id: 'rating', + name: 'Product rating', + values: [ + { value_id: '1', value: 'Rating 1' }, + { value_id: '2', value: 'Rating 2' }, + { value_id: '3', value: 'Rating 3' }, + ], + }], + }, + })); + + const { getByRole, emitted, queryByTestId } = render(ProductAddReviewForm, { + mocks: { + $nuxt: { + context: { + $config: { isRecaptcha: true }, + $recaptcha: { + init: jest.fn(), + getResponse: jest.fn().mockResolvedValue(Promise.resolve(values.token)), + }, + }, + }, + }, + }); + + const recaptchaComponent = queryByTestId('recaptcha'); + expect(recaptchaComponent).toBeTruthy(); + + const nickname = getByRole('textbox', { name: /name/i }); + const summary = getByRole('textbox', { name: /title/i }); + const text = getByRole('textbox', { name: /review/i }); + const rating = getByRole('combobox', { name: /product rating/i }); + const submitButton = getByRole('button', { name: /add review/i }); + + // fill the form + userEvent.type(nickname, values.nickname); + userEvent.type(summary, values.summary); + userEvent.selectOptions(rating, values.rating); + userEvent.type(text, values.text); + + // Submit the form + userEvent.click(submitButton); + + await waitFor(() => { + expect(emitted()).toHaveProperty('add-review'); + expect(emitted()['add-review'][0][0]).toEqual({ + nickname: values.nickname, + ratings: [{ id: 'rating', value_id: values.rating }], + sku: values.sku, + summary: values.summary, + text: values.text, + recaptchaToken: values.token, + }); + }); + }); +}); diff --git a/packages/theme/config.js b/packages/theme/config.js index 7aeeca021..905daed48 100644 --- a/packages/theme/config.js +++ b/packages/theme/config.js @@ -73,6 +73,50 @@ const config = convict({ default: process.env.IMAGE_PROVIDER_BASE_URL, env: 'IMAGE_PROVIDER_BASE_URL', }, + // region recaptcha + recaptchaEnabled: { + doc: 'reCaptcha Enabled', + format: Boolean, + default: process.env.RECAPTCHA_ENABLED || false, + env: 'RECAPTCHA_ENABLED', + }, + recaptchaHideBadge: { + doc: 'reCaptcha Hide Badge', + format: Boolean, + default: process.env.RECAPTCHA_HIDE_BADGE || false, + env: 'RECAPTCHA_HIDE_BADGE', + }, + recaptchaVersion: { + doc: 'reCaptcha Version', + format: Number, + default: process.env.RECAPTCHA_VERSION || 3, + env: 'RECAPTCHA_VERSION', + }, + recaptchaSiteKey: { + doc: 'reCaptcha Site Key', + format: String, + default: process.env.RECAPTCHA_SITE_KEY || '', + env: 'RECAPTCHA_SITE_KEY', + }, + recaptchaSecretkey: { + doc: 'reCaptcha Secret Key', + format: String, + default: process.env.RECAPTCHA_SECRET_KEY || '', + env: 'RECAPTCHA_SECRET_KEY', + }, + recaptchaSize: { + doc: 'reCaptcha Size', + format: String, + default: process.env.RECAPTCHA_SIZE || 'invisible', + env: 'RECAPTCHA_SIZE', + }, + recaptchaMinScore: { + doc: 'reCaptcha Minimum Score', + format: Number, + default: process.env.RECAPTCHA_MIN_SCORE || 0.5, + env: 'RECAPTCHA_MIN_SCORE', + }, + // endregion }); const env = config.get('env'); diff --git a/packages/theme/config/example.json b/packages/theme/config/example.json index 3c1f7054b..d41f29d0f 100644 --- a/packages/theme/config/example.json +++ b/packages/theme/config/example.json @@ -8,5 +8,11 @@ "externalCheckoutSyncPath": "/vue/cart/sync", "imageProvider": "ipx", "magentoBaseUrl": "https://magento2-instance.vuestorefront.io/", - "imageProviderBaseUrl": "https://res-4.cloudinary.com/{YOUR_CLOUD_ID}/image/upload/" -} + "imageProviderBaseUrl": "https://res-4.cloudinary.com/{YOUR_CLOUD_ID}/image/upload/", + "recaptchaHideBadge": "{YOUR_RECAPTCHA_BADGE_TYPE}", + "recaptchaSize": "{YOUR_RECAPTCHA_SIZE}", + "recaptchaSiteKey": "{YOUR_RECAPTCHA_SITE_KEY}", + "recaptchaSecretkey": "{YOUR_RECAPTCHA_SECRET_KEY}", + "recaptchaVersion": "{YOUR_RECAPTCHA_VERSION}", + "recaptchaMinScore": "{YOUR_RECAPTCHA_MIN_SCORE}" +} \ No newline at end of file diff --git a/packages/theme/middleware.config.js b/packages/theme/middleware.config.js index ae291e72c..e7e79cd5b 100755 --- a/packages/theme/middleware.config.js +++ b/packages/theme/middleware.config.js @@ -27,6 +27,13 @@ module.exports = { }, magentoBaseUrl: config.get('magentoBaseUrl'), imageProvider: config.get('imageProvider'), + recaptcha: { + isEnabled: config.get('recaptchaEnabled'), + sitekey: config.get('recaptchaSiteKey'), + secretkey: config.get('recaptchaSecretkey'), + version: config.get('recaptchaVersion'), + score: config.get('recaptchaMinScore'), + }, }, }, }, diff --git a/packages/theme/nuxt.config.js b/packages/theme/nuxt.config.js index 92a49f539..1e9618d75 100755 --- a/packages/theme/nuxt.config.js +++ b/packages/theme/nuxt.config.js @@ -113,7 +113,17 @@ export default { 'vue-scrollto/nuxt', '@vue-storefront/middleware/nuxt', '@nuxt/image', + // '@nuxtjs/recaptcha', ], + recaptcha: { + hideBadge: config.get('recaptchaHideBadge'), // Hide badge element (v3 & v2 via size=invisible) + siteKey: config.get('recaptchaSiteKey'), // Site key for requests + version: config.get('recaptchaVersion'), // Version 2 or 3 + size: config.get('recaptchaSize'), // Size: 'compact', 'normal', 'invisible' (v2) + }, + publicRuntimeConfig: { + isRecaptcha: config.get('recaptchaEnabled'), + }, i18n: { country: 'US', strategy: 'prefix', diff --git a/packages/theme/package.json b/packages/theme/package.json index ced7c9efb..0817db03d 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -30,6 +30,7 @@ "@nuxtjs/composition-api": "^0.31.0", "@nuxtjs/google-fonts": "^1.3.0", "@nuxtjs/pwa": "^3.3.5", + "@nuxtjs/recaptcha": "^1.0.4", "@nuxtjs/style-resources": "^1.2.1", "@storefront-ui/vue": "^0.11.5", "@vue-storefront/core": "~2.5.4", @@ -66,6 +67,7 @@ "cypress": "^9.2.1", "cypress-pipe": "^2.0.0", "cypress-tags": "^0.3.0", + "deepmerge": "^4.2.2", "dotenv": "^12.0.1", "ejs": "^3.1.6", "jest": "^27.4.7", @@ -98,4 +100,4 @@ "engines": { "node": ">=16.x" } -} +} \ No newline at end of file diff --git a/packages/theme/pages/Checkout/UserAccount.vue b/packages/theme/pages/Checkout/UserAccount.vue index 36cb42fe8..00810b3ea 100644 --- a/packages/theme/pages/Checkout/UserAccount.vue +++ b/packages/theme/pages/Checkout/UserAccount.vue @@ -110,6 +110,7 @@ class="form__element" :disabled="createUserAccount" /> +
async () => { + if (isRecaptchaEnabled.value) { + $recaptcha.init(); + } + if (!isAuthenticated.value) { + if (isRecaptchaEnabled.value && createUserAccount.value) { + const recaptchaToken = await $recaptcha.getResponse(); + form.value.recaptchaToken = recaptchaToken; + form.value.recaptchaInstance = $recaptcha; + } + await ( !createUserAccount.value ? attachToCart({ email: form.value.email }) @@ -226,10 +239,16 @@ export default defineComponent({ } if (loginUserAccount.value) { + const recaptchaParams = {}; + if (isRecaptchaEnabled.value) { + recaptchaParams.recaptchaToken = await $recaptcha.getResponse(); + } + await login({ user: { username: form.value.email, password: form.value.password, + ...recaptchaParams, }, }); } @@ -249,6 +268,11 @@ export default defineComponent({ title: 'Error', }); } + + if (isRecaptchaEnabled.value) { + // reset recaptcha + $recaptcha.reset(); + } }; onSSR(async () => { @@ -281,6 +305,7 @@ export default defineComponent({ loading, loginUserAccount, user, + isRecaptchaEnabled, }; }, }); diff --git a/packages/theme/pages/Checkout/__tests__/UserAccount.spec.js b/packages/theme/pages/Checkout/__tests__/UserAccount.spec.js index 014f84ba6..1bed0c4c8 100644 --- a/packages/theme/pages/Checkout/__tests__/UserAccount.spec.js +++ b/packages/theme/pages/Checkout/__tests__/UserAccount.spec.js @@ -7,15 +7,18 @@ import { render, useUserMock, useGuestUserMock } from '~/test-utils'; import UserAccount from '../UserAccount'; +jest.mock('~/helpers/asyncLocalStorage', () => ({ + getItem: jest.fn(), + mergeItem: jest.fn(), +})); + jest.mock('@vue-storefront/magento', () => ({ useGuestUser: jest.fn(), useUser: jest.fn(), })); jest.mock('@nuxtjs/composition-api', () => { - // Require the original module to not be mocked... const originalModule = jest.requireActual('@nuxtjs/composition-api'); - return { ...originalModule, useRouter: jest.fn(), @@ -27,10 +30,13 @@ describe('', () => { useUser.mockReturnValue(useUserMock()); useGuestUser.mockReturnValue(useGuestUserMock()); - const { getByRole, findAllByText } = render(UserAccount); + const { getByRole, findAllByText, queryByTestId } = render(UserAccount); // First name, last name and email fields should be rendered and required + const recaptchaComponent = queryByTestId('recaptcha'); + expect(recaptchaComponent).toBeNull(); + const firstNameInput = getByRole('textbox', { name: /first name/i }); expect(firstNameInput).toBeRequired(); @@ -101,11 +107,150 @@ describe('', () => { expect(routerPushMock).toHaveBeenCalledWith('/checkout/shipping'); }); - test.todo('User cannot move to the next step if data is loading'); + it('User can log-in to the store', async () => { + const attachToCartMock = jest.fn(); + const routerPushMock = jest.fn(); + const loginMock = jest.fn(); + + useUser.mockReturnValue(useUserMock({ + login: loginMock, + })); + useGuestUser.mockReturnValue(useGuestUserMock({ + attachToCart: attachToCartMock, + })); + useRouter.mockReturnValue({ + push: routerPushMock, + }); + + const values = { + password: 'J@mesBond007!', + email: 'james@bond.io', + token: 'token value', + }; + + const { findByLabelText, getByRole, queryByTestId } = render(UserAccount, { + mocks: { + $nuxt: { + context: { + $config: { isRecaptcha: true }, + $recaptcha: { + init: jest.fn(), + reset: jest.fn(), + getResponse: jest.fn().mockResolvedValue(Promise.resolve(values.token)), + }, + }, + }, + }, + }); + + const recaptchaComponent = queryByTestId('recaptcha'); + expect(recaptchaComponent).toBeTruthy(); + + const loginCheckbox = getByRole('checkbox', { name: /login on the store/i }); + userEvent.click(loginCheckbox); + + const passwordInput = await findByLabelText('Password'); + const emailInput = getByRole('textbox', { name: /e-mail/i }); + const continueButton = getByRole('button', { name: /continue to shipping/i }); + + userEvent.type(passwordInput, values.password); + userEvent.type(emailInput, values.email); + + // click the continue button + userEvent.click(continueButton); + + await waitFor(() => { + expect(attachToCartMock).toHaveBeenCalledTimes(1); + expect(attachToCartMock).toHaveBeenCalledWith({ + email: 'james@bond.io', + }); - test.todo('User can log-in to the store'); + expect(loginMock).toHaveBeenCalledTimes(1); + expect(loginMock).toHaveBeenCalledWith({ + user: { + username: values.email, + password: values.password, + recaptchaToken: values.token, + }, + }); + }); + expect(routerPushMock).toHaveBeenCalledTimes(1); + expect(routerPushMock).toHaveBeenCalledWith('/checkout/shipping'); + }); - test.todo('User can create an account'); + it('User can create an account', async () => { + const routerPushMock = jest.fn(); + const registerMock = jest.fn(); + + useUser.mockReturnValue(useUserMock({ + register: registerMock, + })); + useRouter.mockReturnValue({ + push: routerPushMock, + }); + + const values = { + password: 'J@mesBond007!', + firstName: 'James', + lastName: 'Bond', + email: 'james@bond.io', + token: 'token value', + }; + + const recaptchaInstance = { + init: jest.fn(), + reset: jest.fn(), + getResponse: jest.fn().mockResolvedValue(Promise.resolve(values.token)), + }; + const { findByLabelText, findByRole, getByRole } = render(UserAccount, { + mocks: { + $nuxt: { + context: { + $config: { isRecaptcha: true }, + $recaptcha: recaptchaInstance, + }, + }, + }, + }); + + const loginCheckbox = getByRole('checkbox', { name: /create an account on the store/i }); + userEvent.click(loginCheckbox); + + const passwordInput = await findByLabelText('Password'); + const newsletterCheckbox = await findByRole('checkbox', { name: /sign up for newsletter/i }); + const firstNameInput = getByRole('textbox', { name: /first name/i }); + const lastNameInput = getByRole('textbox', { name: /last name/i }); + const emailInput = getByRole('textbox', { name: /e-mail/i }); + const continueButton = getByRole('button', { name: /continue to shipping/i }); + + userEvent.click(newsletterCheckbox); + userEvent.type(passwordInput, values.password); + userEvent.type(emailInput, values.email); + userEvent.type(firstNameInput, values.firstName); + userEvent.type(lastNameInput, values.lastName); + + // click the continue button + userEvent.click(continueButton); + + await waitFor(() => { + expect(registerMock).toHaveBeenCalledTimes(1); + expect(registerMock).toHaveBeenCalledWith({ + user: { + email: values.email, + is_subscribed: true, + firstname: values.firstName, + lastname: values.lastName, + recaptchaInstance, + password: values.password, + recaptchaToken: values.token, + }, + }); + }); + expect(routerPushMock).toHaveBeenCalledTimes(1); + expect(routerPushMock).toHaveBeenCalledWith('/checkout/shipping'); + }); + + test.todo('User cannot move to the next step if data is loading'); test.todo('User can subscribe to the newsletter during account creation'); }); diff --git a/packages/theme/pages/ResetPassword.vue b/packages/theme/pages/ResetPassword.vue index b49e6d2e4..911d64815 100644 --- a/packages/theme/pages/ResetPassword.vue +++ b/packages/theme/pages/ResetPassword.vue @@ -61,6 +61,7 @@
{{ passwordMatchError || forgotPasswordError.setNew.message }}
+ forgotPasswordGetters.isPasswordChanged(result.value)); const { token } = context.root.$route.query; + const { $recaptcha, $config } = useContext(); + const isRecaptchaEnabled = ref(typeof $recaptcha !== 'undefined' && $config.isRecaptcha); const setNewPassword = async () => { passwordMatchError.value = false; @@ -151,11 +159,30 @@ export default defineComponent({ return; } - await setNew({ - tokenValue: token, - newPassword: form.value.password, - email: form.value.email, - }); + if (isRecaptchaEnabled.value) { + $recaptcha.init(); + } + + if (isRecaptchaEnabled.value) { + const recaptchaToken = await $recaptcha.getResponse(); + + await setNew({ + tokenValue: token, + newPassword: form.value.password, + email: form.value.email, + recaptchaToken, + }); + } else { + await setNew({ + tokenValue: token, + newPassword: form.value.password, + email: form.value.email, + }); + } + + if (isRecaptchaEnabled.value) { + $recaptcha.reset(); + } }; return { @@ -167,6 +194,7 @@ export default defineComponent({ passwordMatchError, token, result, + isRecaptchaEnabled, }; }, }); diff --git a/packages/theme/pages/__tests__/ResetPassword.spec.js b/packages/theme/pages/__tests__/ResetPassword.spec.js new file mode 100644 index 000000000..12b02a4f1 --- /dev/null +++ b/packages/theme/pages/__tests__/ResetPassword.spec.js @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/vue'; +import { useForgotPassword } from '@vue-storefront/magento'; +import { render, useForgotPasswordMock } from '~/test-utils'; + +import ResetPassword from '../ResetPassword'; + +jest.mock('@vue-storefront/magento', () => { + const originalModule = jest.requireActual('@vue-storefront/magento'); + return { + ...originalModule, + useForgotPassword: jest.fn(), + }; +}); + +describe('', () => { + it('User can change his password', async () => { + const setNewMock = jest.fn(); + useForgotPassword.mockReturnValue(useForgotPasswordMock({ + setNew: setNewMock, + })); + + const values = { + email: 'james@bond.io', + password: 'J@mesBond007!', + token: 'token value', + resetToken: 'reset token value', + }; + + const { getByRole, findByLabelText, queryByTestId } = render(ResetPassword, { + mocks: { + $route: { query: { token: values.resetToken } }, + $nuxt: { + context: { + $config: { isRecaptcha: true }, + $recaptcha: { + init: jest.fn(), + reset: jest.fn(), + getResponse: jest.fn().mockResolvedValue(Promise.resolve(values.token)), + }, + }, + }, + }, + }); + + const recaptchaComponent = queryByTestId('recaptcha'); + expect(recaptchaComponent).toBeTruthy(); + + const emailInput = getByRole('textbox', /your email/i); + const passwordInput = await findByLabelText('Password'); + const repeatPasswordInput = await findByLabelText('Repeat Password'); + + userEvent.type(emailInput, values.email); + userEvent.type(passwordInput, values.password); + userEvent.type(repeatPasswordInput, values.password); + + const continueButton = getByRole('button', { name: /save password/i }); + userEvent.click(continueButton); + + await waitFor(() => { + expect(setNewMock).toHaveBeenCalledTimes(1); + expect(setNewMock).toHaveBeenCalledWith({ + email: values.email, + newPassword: values.password, + recaptchaToken: values.token, + tokenValue: values.resetToken, + }); + }); + }); +}); diff --git a/packages/theme/test-utils.js b/packages/theme/test-utils.js index eb0f16821..49ed92f8b 100644 --- a/packages/theme/test-utils.js +++ b/packages/theme/test-utils.js @@ -1,11 +1,12 @@ import { render } from '@testing-library/vue'; +import deepmerge from 'deepmerge'; const $t = (text) => text; const $n = (text) => text; const $fc = (text) => text; const localePath = (path) => path; // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -const customRender = (component, options = {}, callback = null) => render(component, { +const customRender = (component, options = {}, callback = null) => render(component, deepmerge({ mocks: { $t, $n, @@ -21,16 +22,20 @@ const customRender = (component, options = {}, callback = null) => render(compon }, }, }, - ...options?.mocks, }, stubs: { NuxtImg: { template: 'image', }, + recaptcha: { + template: '
', + }, + i18n: { + template: '
', + }, }, - ...options, // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -}, callback); +}, options), callback); export * from '@testing-library/vue'; export * from '~/test-utils/mocks'; diff --git a/packages/theme/test-utils/mocks/index.js b/packages/theme/test-utils/mocks/index.js index 257d86936..784dbf569 100644 --- a/packages/theme/test-utils/mocks/index.js +++ b/packages/theme/test-utils/mocks/index.js @@ -1,9 +1,11 @@ export * from './useBilling'; export * from './useCart'; export * from './useCountrySearch'; +export * from './useForgotPassword'; export * from './useGuestUser'; export * from './useShipping'; +export * from './useUiState'; export * from './useUser'; export * from './useUserBilling'; -export * from './useUiState'; +export * from './useReview'; export * from './cartGetters'; diff --git a/packages/theme/test-utils/mocks/useForgotPassword.js b/packages/theme/test-utils/mocks/useForgotPassword.js new file mode 100644 index 000000000..17b56ec57 --- /dev/null +++ b/packages/theme/test-utils/mocks/useForgotPassword.js @@ -0,0 +1,11 @@ +import { ref } from '@nuxtjs/composition-api'; + +export const useForgotPasswordMock = (passwordData = {}) => ({ + result: ref({}), + setNew: jest.fn(), + error: ref({}), + loading: ref(false), + ...passwordData, +}); + +export default useForgotPasswordMock; diff --git a/packages/theme/test-utils/mocks/useReview.js b/packages/theme/test-utils/mocks/useReview.js new file mode 100644 index 000000000..4c74a01ac --- /dev/null +++ b/packages/theme/test-utils/mocks/useReview.js @@ -0,0 +1,26 @@ +export const useReviewMock = (reviewData = {}) => ({ + loading: { + value: false, + }, + loadReviewMetadata: jest.fn(), + metadata: { + value: [ + { + id: 'METADATA_ID', + name: 'METADATA_NAME', + values: [ + { + value_id: '1', + value: 'VALUE 1', + }, + ], + }, + ], + }, + error: { + value: {}, + }, + ...reviewData, +}); + +export default useReviewMock; diff --git a/packages/theme/test-utils/mocks/useUser.js b/packages/theme/test-utils/mocks/useUser.js index efb133397..87c9b6c76 100644 --- a/packages/theme/test-utils/mocks/useUser.js +++ b/packages/theme/test-utils/mocks/useUser.js @@ -1,16 +1,12 @@ +import { ref } from '@nuxtjs/composition-api'; + export const useUserMock = (userData = {}) => ({ load: jest.fn(), - loading: { - value: false, - }, - isAuthenticated: { - value: false, - }, - error: { - value: { - register: null, - }, - }, + loading: ref(false), + isAuthenticated: ref(false), + error: ref({ + register: null, + }), ...userData, }); diff --git a/yarn.lock b/yarn.lock index d6b094a81..66143f3b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3213,6 +3213,11 @@ serve-static "^1.14.1" workbox-cdn "^5.1.4" +"@nuxtjs/recaptcha@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@nuxtjs/recaptcha/-/recaptcha-1.0.4.tgz#a12e3faa619c82d3e003b59cb6307516c8416ec3" + integrity sha512-4K9cXaVGZrcXy3ys5OBL1/njkOxTpRjLDKLU/S6qatyISdGLv+tSFLSCJeEKTCO7UHC1fMCTb5UlOWajkQqPdw== + "@nuxtjs/style-resources@^1.0.0", "@nuxtjs/style-resources@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@nuxtjs/style-resources/-/style-resources-1.2.1.tgz#9a2b6580b2ed9b06e930bee488a56b8376a263de"