{
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"
/>
+