Commit a8bb817e authored by tom goriunov's avatar tom goriunov Committed by GitHub

ReCaptcha: migrate back to v2 solution (#2446)

* show recaptcha in the site footer

* replace badge in footer with text

* add invisible reCaptcha v2

* handle unsolved reCaptcha case

* migrate the rest of components to invisible reCaptcha v2

* remove unused code

* refactoring

* add env for demo

* fix tests

* update values for demo

* update link to secret in vault

* mock recaptcha for tests

* ohhh victor victor

* fix token metadata update test

* deprecate variable for ReCaptcha v3
parent cd27e8fc
NEXT_PUBLIC_ROLLBAR_CLIENT_TOKEN=xxx
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=xxx
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=UA-XXXXXX-X
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN=xxx
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY=xxx
......
......@@ -6,11 +6,11 @@ import { getEnvValue } from '../utils';
const title = 'My account';
const config: Feature<{ isEnabled: true; recaptchaSiteKey: string }> = (() => {
if (getEnvValue('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED') === 'true' && services.reCaptchaV3.siteKey) {
if (getEnvValue('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED') === 'true' && services.reCaptchaV2.siteKey) {
return Object.freeze({
title,
isEnabled: true,
recaptchaSiteKey: services.reCaptchaV3.siteKey,
recaptchaSiteKey: services.reCaptchaV2.siteKey,
});
}
......
......@@ -5,12 +5,12 @@ import services from '../services';
const title = 'Export data to CSV file';
const config: Feature<{ reCaptcha: { siteKey: string } }> = (() => {
if (services.reCaptchaV3.siteKey) {
if (services.reCaptchaV2.siteKey) {
return Object.freeze({
title,
isEnabled: true,
reCaptcha: {
siteKey: services.reCaptchaV3.siteKey,
siteKey: services.reCaptchaV2.siteKey,
},
});
}
......
......@@ -9,7 +9,7 @@ const apiHost = getEnvValue('NEXT_PUBLIC_ADMIN_SERVICE_API_HOST');
const title = 'Public tag submission';
const config: Feature<{ api: { endpoint: string; basePath: string } }> = (() => {
if (services.reCaptchaV3.siteKey && addressMetadata.isEnabled && apiHost) {
if (services.reCaptchaV2.siteKey && addressMetadata.isEnabled && apiHost) {
return Object.freeze({
title,
isEnabled: true,
......
import { getEnvValue } from './utils';
export default Object.freeze({
reCaptchaV3: {
siteKey: getEnvValue('NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY'),
reCaptchaV2: {
siteKey: getEnvValue('NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY'),
},
});
......@@ -49,4 +49,4 @@ NEXT_PUBLIC_AUTH0_CLIENT_ID=xxx
NEXT_PUBLIC_STATS_API_HOST=https://localhost:3004
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://localhost:3005
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://localhost:3006
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=xxx
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
......@@ -52,7 +52,7 @@ NEXT_PUBLIC_CONTRACT_INFO_API_HOST=http://localhost:3005
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=http://localhost:3006
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=http://localhost:3007
NEXT_PUBLIC_NAME_SERVICE_API_HOST=http://localhost:3008
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=xxx
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32']
NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX=tom
......
......@@ -143,7 +143,7 @@ function printDeprecationWarning(envsMap: Record<string, string>) {
if (envsMap.NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY && envsMap.NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY) {
console.log('❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗');
// eslint-disable-next-line max-len
console.warn('The NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY variables are now deprecated and will be removed in the next release. Please migrate to the NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY variable.');
console.warn('The NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY variable is now deprecated and will be removed in the next release. Please migrate to the NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY variable.');
console.log('❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗\n');
}
......@@ -182,9 +182,9 @@ function printDeprecationWarning(envsMap: Record<string, string>) {
function checkDeprecatedEnvs(envsMap: Record<string, string>) {
!silent && console.log(`🌀 Checking deprecated environment variables...`);
if (envsMap.NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY && !envsMap.NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY) {
if (!envsMap.NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY && envsMap.NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY) {
// eslint-disable-next-line max-len
console.log('🚨 The NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY variable is no longer supported. Please pass NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY or remove it completely.');
console.log('🚨 The NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY variable is no longer supported. Please pass NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY or remove it completely.');
throw new Error();
}
......
......@@ -889,8 +889,8 @@ const schema = yup
// 6. External services envs
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(),
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY: yup.string(), // DEPRECATED
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY: yup.string(),
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY: yup.string(),
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY: yup.string(), // DEPRECATED
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: yup.string(),
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: yup.string(),
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: yup.string(),
......
......@@ -4,5 +4,5 @@ NEXT_PUBLIC_VIEWS_CONTRACT_EXTRA_VERIFICATION_METHODS=none
NEXT_PUBLIC_HOMEPAGE_STATS=[]
NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32']
NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX=foo
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=deprecated
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=xxx
\ No newline at end of file
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=deprecated
\ No newline at end of file
......@@ -3,7 +3,7 @@ NEXT_PUBLIC_AUTH_URL=https://example.com
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_LOGOUT_URL=https://example.com
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=xxx
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=UA-XXXXXX-X
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN=xxx
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY=xxx
......
......@@ -76,4 +76,4 @@ frontend:
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID
NEXT_PUBLIC_OG_IMAGE_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/base-mainnet.png
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY: ref+vault://deployment-values/blockscout/eth-sepolia/testnet?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/RE_CAPTCHA_CLIENT_KEY
......@@ -77,11 +77,12 @@ frontend:
NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED: true
NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES: "['/apps']"
PROMETHEUS_METRICS_ENABLED: true
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED: true
envFromSecret:
NEXT_PUBLIC_AUTH0_CLIENT_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_AUTH0_CLIENT_ID
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY: ref+vault://deployment-values/blockscout/eth-sepolia/testnet?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/RE_CAPTCHA_CLIENT_KEY
NEXT_PUBLIC_ROLLBAR_CLIENT_TOKEN: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_ROLLBAR_CLIENT_TOKEN
......@@ -12,4 +12,3 @@
| NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL | `string` | Network governance token symbol | - | - | `GNO` | v1.12.0 | v1.29.0 | Replaced by NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL |
| NEXT_PUBLIC_SWAP_BUTTON_URL | `string` | Application ID in the marketplace or website URL | - | - | `uniswap` | v1.24.0 | v1.31.0 | Replaced by NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS |
| NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME | `boolean` | Set to false if average block time is useless for the network | - | `true` | `false` | v1.0.x+ | v1.35.0 | Replaced by NEXT_PUBLIC_HOMEPAGE_STATS |
| NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY | `string` | Google reCAPTCHA v2 site key | - | - | `<your-secret>` | v1.0.x+ | v1.36.0 | Replaced by NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY |
......@@ -349,7 +349,7 @@ Settings for meta tags, OG tags and SEO
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED | `boolean` | Set to true if network has account feature | Required | - | `true` | v1.0.x+ |
| NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY | `boolean` | See [below](ENVS.md#google-recaptcha) | Required | - | `<your-secret>` | v1.36.0+ |
| NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY | `boolean` | See [below](ENVS.md#google-recaptcha) | Required | - | `<your-secret>` | v1.0.x+ |
| NEXT_PUBLIC_AUTH0_CLIENT_ID | `string` | **DEPRECATED** Client id for [Auth0](https://auth0.com/) provider | - | - | `<your-secret>` | v1.0.x+ |
| NEXT_PUBLIC_AUTH_URL | `string` | **DEPRECATED** Account auth base url; it is used for building login URL (`${ NEXT_PUBLIC_AUTH_URL }/auth/auth0`) and logout return URL (`${ NEXT_PUBLIC_AUTH_URL }/auth/logout`); if not provided the base app URL will be used instead | - | - | `https://blockscout.com` | v1.0.x+ |
| NEXT_PUBLIC_LOGOUT_URL | `string` | **DEPRECATED** Account logout url. Required if account is supported for the app instance. | - | - | `https://blockscoutcom.us.auth0.com/v2/logout` | v1.0.x+ |
......@@ -452,7 +452,7 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY | `string` | See [below](ENVS.md#google-recaptcha) | true | - | `<your-secret>` | v1.36.0+ |
| NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY | `string` | See [below](ENVS.md#google-recaptcha) | true | - | `<your-secret>` | v1.0.x+ |
&nbsp;
......@@ -614,6 +614,7 @@ This feature allows you to submit an application with a public address tag.
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_METADATA_SERVICE_API_HOST | `string` | Metadata Service API endpoint url | Required | - | `https://metadata.services.blockscout.com` | v1.30.0+ |
| NEXT_PUBLIC_ADMIN_SERVICE_API_HOST | `string` | Admin Service API endpoint url | Required | - | `https://admin-rs.services.blockscout.com` | v1.1.0+ |
| NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY | `string` | See [below](ENVS.md#google-recaptcha) | true | - | `<your-secret>` | v1.0.x+ |
&nbsp;
......@@ -848,5 +849,5 @@ For obtaining the variables values please refer to [reCAPTCHA documentation](htt
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY | `string` | Google reCAPTCHA v3 site key | - | - | `<your-secret>` | v1.36.0+ |
| NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY | `string` | **DEPRECATED** Google reCAPTCHA v2 site key | - | - | `<your-secret>` | v1.0.x+ |
| NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY | `string` | **DEPRECATED** Google reCAPTCHA v3 site key | - | - | `<your-secret>` | v1.36.0+ |
| NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY | `string` | Google reCAPTCHA v2 site key | - | - | `<your-secret>` | v1.0.x+ |
\ No newline at end of file
......@@ -3,7 +3,7 @@ import type CspDev from 'csp-dev';
import config from 'configs/app';
export function googleReCaptcha(): CspDev.DirectiveDescriptor {
if (!config.services.reCaptchaV3.siteKey) {
if (!config.services.reCaptchaV2.siteKey) {
return {};
}
......
......@@ -78,6 +78,9 @@ const config: PlaywrightTestConfig = defineConfig({
// Mock for growthbook to test feature flags
{ find: 'lib/growthbook/useFeatureValue', replacement: './playwright/mocks/lib/growthbook/useFeatureValue.js' },
// Mock for reCaptcha hook
{ find: 'ui/shared/reCaptcha/useReCaptcha', replacement: './playwright/mocks/ui/shared/recaptcha/useReCaptcha.js' },
// The createWeb3Modal() function from web3modal/wagmi/react somehow pollutes the global styles which causes the tests to fail
// We don't call this function in TestApp and since we use useWeb3Modal() and useWeb3ModalState() hooks in the code, we have to mock the module
// Otherwise it will complain that createWeb3Modal() is no called before the hooks are used
......
const useReCaptcha = () => {
return {
ref: { current: null },
executeAsync: () => Promise.resolve('recaptcha_token'),
};
};
export default useReCaptcha;
const styles = () => {
return {
'.grecaptcha-badge': {
zIndex: 'toast',
visibility: 'hidden',
},
'div:has(div):has(iframe[title="recaptcha challenge expires in two minutes"])': {
'&::after': {
content: `" "`,
display: 'block',
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
zIndex: 100000,
bgColor: 'blackAlpha.300',
},
},
};
};
......
import { Alert, Button, chakra, Flex } from '@chakra-ui/react';
import React from 'react';
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
......@@ -13,7 +12,8 @@ import type { ResourceName } from 'lib/api/resources';
import dayjs from 'lib/date/dayjs';
import downloadBlob from 'lib/downloadBlob';
import useToast from 'lib/hooks/useToast';
import FormFieldReCaptcha from 'ui/shared/forms/fields/FormFieldReCaptcha';
import ReCaptcha from 'ui/shared/reCaptcha/ReCaptcha';
import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
import CsvExportFormField from './CsvExportFormField';
......@@ -36,16 +36,23 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
});
const { handleSubmit, formState } = formApi;
const toast = useToast();
const recaptcha = useReCaptcha();
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => {
try {
const token = await recaptcha.executeAsync();
if (!token) {
throw new Error('ReCaptcha is not solved');
}
const url = buildUrl(resource, { hash } as never, {
address_id: hash,
from_period: exportType !== 'holders' ? data.from : null,
to_period: exportType !== 'holders' ? data.to : null,
filter_type: filterType,
filter_value: filterValue,
recaptcha_v3_response: data.reCaptcha,
recaptcha_response: token,
});
const response = await fetch(url, {
......@@ -76,9 +83,9 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
});
}
}, [ resource, hash, exportType, filterType, filterValue, fileNameTemplate, toast ]);
}, [ recaptcha, resource, hash, exportType, filterType, filterValue, fileNameTemplate, toast ]);
if (!config.services.reCaptchaV3.siteKey) {
if (!config.services.reCaptchaV2.siteKey) {
return (
<Alert status="error">
CSV export is not available at the moment since reCaptcha is not configured for this application.
......@@ -88,31 +95,29 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
}
return (
<GoogleReCaptchaProvider reCaptchaKey={ config.services.reCaptchaV3.siteKey }>
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ handleSubmit(onFormSubmit) }
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ handleSubmit(onFormSubmit) }
>
<Flex columnGap={ 5 } rowGap={ 3 } flexDir={{ base: 'column', lg: 'row' }} alignItems={{ base: 'flex-start', lg: 'center' }} flexWrap="wrap">
{ exportType !== 'holders' && <CsvExportFormField name="from" formApi={ formApi }/> }
{ exportType !== 'holders' && <CsvExportFormField name="to" formApi={ formApi }/> }
</Flex>
<ReCaptcha ref={ recaptcha.ref }/>
<Button
variant="solid"
size="lg"
type="submit"
mt={ 8 }
isLoading={ formState.isSubmitting }
loadingText="Download"
isDisabled={ Boolean(formState.errors.from || formState.errors.to) }
>
<Flex columnGap={ 5 } rowGap={ 3 } flexDir={{ base: 'column', lg: 'row' }} alignItems={{ base: 'flex-start', lg: 'center' }} flexWrap="wrap">
{ exportType !== 'holders' && <CsvExportFormField name="from" formApi={ formApi }/> }
{ exportType !== 'holders' && <CsvExportFormField name="to" formApi={ formApi }/> }
<FormFieldReCaptcha/>
</Flex>
<Button
variant="solid"
size="lg"
type="submit"
mt={ 8 }
isLoading={ formState.isSubmitting }
loadingText="Download"
isDisabled={ Boolean(formState.errors.from || formState.errors.to) }
>
Download
</Button>
</chakra.form>
</FormProvider>
</GoogleReCaptchaProvider>
Download
</Button>
</chakra.form>
</FormProvider>
);
};
......
export interface FormFields {
from: string;
to: string;
reCaptcha: string;
}
import { Button, chakra, Heading, useDisclosure } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
......@@ -14,8 +13,9 @@ import getErrorMessage from 'lib/errors/getErrorMessage';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import useToast from 'lib/hooks/useToast';
import * as mixpanel from 'lib/mixpanel';
import FormFieldReCaptcha from 'ui/shared/forms/fields/FormFieldReCaptcha';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import ReCaptcha from 'ui/shared/reCaptcha/ReCaptcha';
import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
import AuthModal from 'ui/snippets/auth/AuthModal';
import MyProfileFieldsEmail from './fields/MyProfileFieldsEmail';
......@@ -34,6 +34,7 @@ const MyProfileEmail = ({ profileQuery }: Props) => {
const authModal = useDisclosure();
const apiFetch = useApiFetch();
const toast = useToast();
const recaptcha = useReCaptcha();
const formApi = useForm<FormFields>({
mode: 'onBlur',
......@@ -45,12 +46,14 @@ const MyProfileEmail = ({ profileQuery }: Props) => {
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(formData) => {
try {
const token = await recaptcha.executeAsync();
await apiFetch('auth_send_otp', {
fetchParams: {
method: 'POST',
body: {
email: formData.email,
recaptcha_v3_response: formData.reCaptcha,
recaptcha_response: token,
},
},
});
......@@ -68,7 +71,7 @@ const MyProfileEmail = ({ profileQuery }: Props) => {
description: apiError?.message || getErrorMessage(error) || 'Something went wrong',
});
}
}, [ apiFetch, authModal, toast ]);
}, [ apiFetch, authModal, toast, recaptcha ]);
const hasDirtyFields = Object.keys(formApi.formState.dirtyFields).length > 0;
......@@ -82,15 +85,11 @@ const MyProfileEmail = ({ profileQuery }: Props) => {
>
<FormFieldText<FormFields> name="name" placeholder="Name" isReadOnly mb={ 3 }/>
<MyProfileFieldsEmail
isReadOnly={ !config.services.reCaptchaV3.siteKey || Boolean(profileQuery.data?.email) }
isReadOnly={ !config.services.reCaptchaV2.siteKey || Boolean(profileQuery.data?.email) }
defaultValue={ profileQuery.data?.email || undefined }
/>
{ config.services.reCaptchaV3.siteKey && !profileQuery.data?.email && (
<GoogleReCaptchaProvider reCaptchaKey={ config.services.reCaptchaV3.siteKey }>
<FormFieldReCaptcha/>
</GoogleReCaptchaProvider>
) }
{ config.services.reCaptchaV3.siteKey && !profileQuery.data?.email && (
{ config.services.reCaptchaV2.siteKey && !profileQuery.data?.email && <ReCaptcha ref={ recaptcha.ref }/> }
{ config.services.reCaptchaV2.siteKey && !profileQuery.data?.email && (
<Button
mt={ 6 }
size="sm"
......
export interface FormFields {
email: string;
name: string;
reCaptcha: string;
}
......@@ -62,12 +62,6 @@ test('metadata update', async({ render, page, createSocket, mockApiResponse, moc
// open the menu, click the button and submit form
await page.getByLabel('Address menu').click();
await page.getByRole('menuitem', { name: 'Refresh metadata' }).click();
await page.evaluate(() => {
const form = document.querySelector('form');
form && (form.style.display = 'block');
});
await page.getByPlaceholder('reCaptcha token').fill('xxx');
await page.getByRole('button', { name: 'Submit' }).click();
// join socket channel
const channel = await socketServer.joinChannel(socket, `token_instances:${ hash.toLowerCase() }`);
......@@ -116,12 +110,6 @@ test('metadata update failed', async({ render, page }) => {
// open the menu, click the button and submit form
await page.getByLabel('Address menu').click();
await page.getByRole('menuitem', { name: 'Refresh metadata' }).click();
await page.evaluate(() => {
const form = document.querySelector('form');
form && (form.style.display = 'block');
});
await page.getByPlaceholder('reCaptcha token').fill('xxx');
await page.getByRole('button', { name: 'Submit' }).click();
// check that button is not disabled
await page.getByLabel('Address menu').click();
......
import { Button, chakra, Grid, GridItem } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
......@@ -15,10 +14,11 @@ import getErrorObj from 'lib/errors/getErrorObj';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import useIsMobile from 'lib/hooks/useIsMobile';
import FormFieldEmail from 'ui/shared/forms/fields/FormFieldEmail';
import FormFieldReCaptcha from 'ui/shared/forms/fields/FormFieldReCaptcha';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import FormFieldUrl from 'ui/shared/forms/fields/FormFieldUrl';
import Hint from 'ui/shared/Hint';
import ReCaptcha from 'ui/shared/reCaptcha/ReCaptcha';
import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
import PublicTagsSubmitFieldAddresses from './fields/PublicTagsSubmitFieldAddresses';
import PublicTagsSubmitFieldTags from './fields/PublicTagsSubmitFieldTags';
......@@ -34,6 +34,7 @@ const PublicTagsSubmitForm = ({ config, userInfo, onSubmitResult }: Props) => {
const isMobile = useIsMobile();
const router = useRouter();
const apiFetch = useApiFetch();
const recaptcha = useReCaptcha();
const formApi = useForm<FormFields>({
mode: 'onBlur',
......@@ -56,13 +57,16 @@ const PublicTagsSubmitForm = ({ config, userInfo, onSubmitResult }: Props) => {
const requestsBody = convertFormDataToRequestsBody(data);
const result = await Promise.all(requestsBody.map(async(body) => {
return apiFetch<'public_tag_application', unknown, { message: string }>('public_tag_application', {
pathParams: { chainId: appConfig.chain.id },
fetchParams: {
method: 'POST',
body: { submission: body },
},
})
return recaptcha.executeAsync()
.then(() => {
return apiFetch<'public_tag_application', unknown, { message: string }>('public_tag_application', {
pathParams: { chainId: appConfig.chain.id },
fetchParams: {
method: 'POST',
body: { submission: body },
},
});
})
.then(() => ({ error: null, payload: body }))
.catch((error: unknown) => {
const errorObj = getErrorObj(error);
......@@ -74,9 +78,9 @@ const PublicTagsSubmitForm = ({ config, userInfo, onSubmitResult }: Props) => {
}));
onSubmitResult(result);
}, [ apiFetch, onSubmitResult ]);
}, [ apiFetch, onSubmitResult, recaptcha ]);
if (!appConfig.services.reCaptchaV3.siteKey) {
if (!appConfig.services.reCaptchaV2.siteKey) {
return null;
}
......@@ -85,69 +89,67 @@ const PublicTagsSubmitForm = ({ config, userInfo, onSubmitResult }: Props) => {
};
return (
<GoogleReCaptchaProvider reCaptchaKey={ appConfig.services.reCaptchaV3.siteKey }>
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ formApi.handleSubmit(onFormSubmit) }
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ formApi.handleSubmit(onFormSubmit) }
>
<Grid
columnGap={ 3 }
rowGap={ 3 }
templateColumns={{ base: '1fr', lg: '1fr 1fr minmax(0, 200px)', xl: '1fr 1fr minmax(0, 250px)' }}
>
<Grid
columnGap={ 3 }
rowGap={ 3 }
templateColumns={{ base: '1fr', lg: '1fr 1fr minmax(0, 200px)', xl: '1fr 1fr minmax(0, 250px)' }}
<GridItem colSpan={{ base: 1, lg: 3 }} as="h2" textStyle="h4">
Company info
</GridItem>
<FormFieldText<FormFields> name="requesterName" isRequired placeholder="Your name" { ...fieldProps }/>
<FormFieldEmail<FormFields> name="requesterEmail" isRequired { ...fieldProps }/>
{ !isMobile && <div/> }
<FormFieldText<FormFields> name="companyName" placeholder="Company name" { ...fieldProps }/>
<FormFieldUrl<FormFields> name="companyWebsite" placeholder="Company website" { ...fieldProps }/>
{ !isMobile && <div/> }
<GridItem colSpan={{ base: 1, lg: 3 }} as="h2" textStyle="h4" mt={{ base: 3, lg: 5 }}>
Public tags/labels
<Hint label="Submit a public tag proposal for our moderation team to review" ml={ 1 } color="link"/>
</GridItem>
<PublicTagsSubmitFieldAddresses/>
<PublicTagsSubmitFieldTags tagTypes={ config?.tagTypes }/>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<FormFieldText<FormFields>
name="description"
isRequired
placeholder={
isMobile ?
'Confirm the connection between addresses and tags.' :
'Provide a comment to confirm the connection between addresses and tags.'
}
maxH="160px"
rules={{ maxLength: 80 }}
asComponent="Textarea"
{ ...fieldProps }
/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 3 }}>
<ReCaptcha ref={ recaptcha.ref }/>
</GridItem>
<Button
variant="solid"
size="lg"
type="submit"
mt={ 3 }
isLoading={ formApi.formState.isSubmitting }
loadingText="Send request"
w="min-content"
>
<GridItem colSpan={{ base: 1, lg: 3 }} as="h2" textStyle="h4">
Company info
</GridItem>
<FormFieldText<FormFields> name="requesterName" isRequired placeholder="Your name" { ...fieldProps }/>
<FormFieldEmail<FormFields> name="requesterEmail" isRequired { ...fieldProps }/>
{ !isMobile && <div/> }
<FormFieldText<FormFields> name="companyName" placeholder="Company name" { ...fieldProps }/>
<FormFieldUrl<FormFields> name="companyWebsite" placeholder="Company website" { ...fieldProps }/>
{ !isMobile && <div/> }
<GridItem colSpan={{ base: 1, lg: 3 }} as="h2" textStyle="h4" mt={{ base: 3, lg: 5 }}>
Public tags/labels
<Hint label="Submit a public tag proposal for our moderation team to review" ml={ 1 } color="link"/>
</GridItem>
<PublicTagsSubmitFieldAddresses/>
<PublicTagsSubmitFieldTags tagTypes={ config?.tagTypes }/>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<FormFieldText<FormFields>
name="description"
isRequired
placeholder={
isMobile ?
'Confirm the connection between addresses and tags.' :
'Provide a comment to confirm the connection between addresses and tags.'
}
maxH="160px"
rules={{ maxLength: 80 }}
asComponent="Textarea"
{ ...fieldProps }
/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 3 }}>
<FormFieldReCaptcha/>
</GridItem>
<Button
variant="solid"
size="lg"
type="submit"
mt={ 3 }
isLoading={ formApi.formState.isSubmitting }
loadingText="Send request"
w="min-content"
>
Send request
</Button>
</Grid>
</chakra.form>
</FormProvider>
</GoogleReCaptchaProvider>
Send request
</Button>
</Grid>
</chakra.form>
</FormProvider>
);
};
......
......@@ -9,7 +9,6 @@ export interface FormFields {
addresses: Array<{ hash: string }>;
tags: Array<FormFieldTag>;
description: string | undefined;
reCaptcha: string;
}
export interface FormFieldTag {
......
......@@ -5,7 +5,6 @@ describe('function convertFormDataToRequestsBody()', () => {
it('should convert form data to requests body', () => {
const formData = {
...mocks.baseFields,
reCaptcha: 'xxx',
addresses: [ { hash: mocks.address1 }, { hash: mocks.address2 } ],
tags: [ convertTagApiFieldsToFormFields(mocks.tag1), convertTagApiFieldsToFormFields(mocks.tag2) ],
};
......
import { Button, Text } from '@chakra-ui/react';
import React from 'react';
import { GoogleReCaptcha, GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import config from 'configs/app';
import buildUrl from 'lib/api/buildUrl';
import useFetch from 'lib/hooks/useFetch';
import useToast from 'lib/hooks/useToast';
import ReCaptcha from 'ui/shared/reCaptcha/ReCaptcha';
import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
import AppErrorIcon from '../AppErrorIcon';
import AppErrorTitle from '../AppErrorTitle';
......@@ -13,19 +14,16 @@ import AppErrorTitle from '../AppErrorTitle';
const AppErrorTooManyRequests = () => {
const toast = useToast();
const fetch = useFetch();
const [ token, setToken ] = React.useState<string | undefined>(undefined);
const handleReCaptchaChange = React.useCallback(async(token: string) => {
setToken(token);
}, [ ]);
const recaptcha = useReCaptcha();
const handleSubmit = React.useCallback(async() => {
try {
const token = await recaptcha.executeAsync();
const url = buildUrl('api_v2_key');
await fetch(url, {
method: 'POST',
body: { recaptcha_v3_response: token },
body: { recaptcha_response: token },
credentials: 'include',
}, {
resource: 'api_v2_key',
......@@ -43,25 +41,22 @@ const AppErrorTooManyRequests = () => {
isClosable: true,
});
}
}, [ token, toast, fetch ]);
}, [ recaptcha, toast, fetch ]);
if (!config.services.reCaptchaV3.siteKey) {
throw new Error('reCAPTCHA V3 site key is not set');
if (!config.services.reCaptchaV2.siteKey) {
throw new Error('reCAPTCHA V2 site key is not set');
}
return (
<GoogleReCaptchaProvider reCaptchaKey={ config.services.reCaptchaV3.siteKey }>
<>
<AppErrorIcon statusCode={ 429 }/>
<AppErrorTitle title="Too many requests"/>
<Text variant="secondary" mt={ 3 }>
You have exceeded the request rate for a given time period. Please reduce the number of requests and try again soon.
</Text>
<GoogleReCaptcha
onVerify={ handleReCaptchaChange }
refreshReCaptcha
/>
<ReCaptcha ref={ recaptcha.ref }/>
<Button onClick={ handleSubmit } mt={ 8 }>Try again</Button>
</GoogleReCaptchaProvider>
</>
);
};
......
import React from 'react';
import { GoogleReCaptcha } from 'react-google-recaptcha-v3';
import { useFormContext } from 'react-hook-form';
const FormFieldReCaptcha = () => {
const { register, unregister, clearErrors, setValue, formState } = useFormContext();
React.useEffect(() => {
register('reCaptcha', { required: true, shouldUnregister: true });
return () => {
unregister('reCaptcha');
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleReCaptchaChange = React.useCallback((token: string) => {
clearErrors('reCaptcha');
setValue('reCaptcha', token, { shouldValidate: true });
}, [ clearErrors, setValue ]);
return (
<GoogleReCaptcha
onVerify={ handleReCaptchaChange }
refreshReCaptcha={ formState.submitCount ?? -1 }
/>
);
};
export default FormFieldReCaptcha;
import React from 'react';
import ReCaptcha from 'react-google-recaptcha';
import config from 'configs/app';
interface Props {
disabledFeatureMessage?: React.ReactNode;
}
const ReCaptchaInvisible = ({ disabledFeatureMessage }: Props, ref: React.Ref<ReCaptcha>) => {
if (!config.services.reCaptchaV2.siteKey) {
return disabledFeatureMessage ?? null;
}
return (
<ReCaptcha
ref={ ref }
sitekey={ config.services.reCaptchaV2.siteKey }
size="invisible"
/>
);
};
export default React.forwardRef(ReCaptchaInvisible);
import React from 'react';
import type ReCAPTCHA from 'react-google-recaptcha';
export default function useReCaptcha() {
const ref = React.useRef<ReCAPTCHA>(null);
const rejectCb = React.useRef<((error: Error) => void) | null>(null);
const [ isOpen, setIsOpen ] = React.useState(false);
const executeAsync = React.useCallback(async() => {
setIsOpen(true);
const tokenPromise = ref.current?.executeAsync() || Promise.reject(new Error('Unable to execute ReCaptcha'));
const modalOpenPromise = new Promise<void>((resolve, reject) => {
rejectCb.current = reject;
});
return Promise.race([ tokenPromise, modalOpenPromise ]);
}, [ ref ]);
const handleContainerClick = React.useCallback(() => {
setIsOpen(false);
rejectCb.current?.(new Error('ReCaptcha is not solved'));
}, []);
React.useEffect(() => {
if (!isOpen) {
return;
}
const container = window.document.querySelector('div:has(div):has(iframe[title="recaptcha challenge expires in two minutes"])');
container?.addEventListener('click', handleContainerClick);
return () => {
container?.removeEventListener('click', handleContainerClick);
};
}, [ isOpen, handleContainerClick ]);
return React.useMemo(() => ({ ref, executeAsync }), [ ref, executeAsync ]);
}
......@@ -2,7 +2,6 @@ import { Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOve
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import type { Screen, ScreenSuccess } from './types';
......@@ -183,9 +182,7 @@ const AuthModal = ({ initialScreen, onClose, mixpanelConfig, closeOnError }: Pro
</ModalHeader>
<ModalCloseButton top={ 6 } right={ 6 } color="gray.400"/>
<ModalBody mb={ 0 }>
<GoogleReCaptchaProvider reCaptchaKey={ feature.recaptchaSiteKey }>
{ content }
</GoogleReCaptchaProvider>
{ content }
</ModalBody>
</ModalContent>
</Modal>
......
import { chakra, Button, Text } from '@chakra-ui/react';
import React from 'react';
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
......@@ -12,6 +11,8 @@ import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import useToast from 'lib/hooks/useToast';
import * as mixpanel from 'lib/mixpanel';
import FormFieldEmail from 'ui/shared/forms/fields/FormFieldEmail';
import ReCaptcha from 'ui/shared/reCaptcha/ReCaptcha';
import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
interface Props {
onSubmit: (screen: Screen) => void;
......@@ -27,7 +28,7 @@ const AuthModalScreenEmail = ({ onSubmit, isAuth, mixpanelConfig }: Props) => {
const apiFetch = useApiFetch();
const toast = useToast();
const { executeRecaptcha } = useGoogleReCaptcha();
const recaptcha = useReCaptcha();
const formApi = useForm<EmailFormFields>({
mode: 'onBlur',
......@@ -38,13 +39,14 @@ const AuthModalScreenEmail = ({ onSubmit, isAuth, mixpanelConfig }: Props) => {
const onFormSubmit: SubmitHandler<EmailFormFields> = React.useCallback(async(formData) => {
try {
const token = await executeRecaptcha?.();
const token = await recaptcha.executeAsync();
await apiFetch('auth_send_otp', {
fetchParams: {
method: 'POST',
body: {
email: formData.email,
recaptcha_v3_response: token,
recaptcha_response: token,
},
},
});
......@@ -68,7 +70,7 @@ const AuthModalScreenEmail = ({ onSubmit, isAuth, mixpanelConfig }: Props) => {
description: getErrorObjPayload<{ message: string }>(error)?.message || getErrorMessage(error) || 'Something went wrong',
});
}
}, [ executeRecaptcha, apiFetch, isAuth, onSubmit, mixpanelConfig?.account_link_info.source, toast ]);
}, [ recaptcha, apiFetch, isAuth, onSubmit, mixpanelConfig?.account_link_info.source, toast ]);
return (
<FormProvider { ...formApi }>
......@@ -93,6 +95,7 @@ const AuthModalScreenEmail = ({ onSubmit, isAuth, mixpanelConfig }: Props) => {
>
Send a code
</Button>
<ReCaptcha ref={ recaptcha.ref }/>
</chakra.form>
</FormProvider>
);
......
import { chakra, Box, Text, Button } from '@chakra-ui/react';
import React from 'react';
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
......@@ -12,6 +11,8 @@ import getErrorMessage from 'lib/errors/getErrorMessage';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import useToast from 'lib/hooks/useToast';
import IconSvg from 'ui/shared/IconSvg';
import ReCaptcha from 'ui/shared/reCaptcha/ReCaptcha';
import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
import AuthModalFieldOtpCode from '../fields/AuthModalFieldOtpCode';
......@@ -25,7 +26,7 @@ const AuthModalScreenOtpCode = ({ email, onSuccess, isAuth }: Props) => {
const apiFetch = useApiFetch();
const toast = useToast();
const { executeRecaptcha } = useGoogleReCaptcha();
const recaptcha = useReCaptcha();
const [ isCodeSending, setIsCodeSending ] = React.useState(false);
const formApi = useForm<OtpCodeFormFields>({
......@@ -72,11 +73,11 @@ const AuthModalScreenOtpCode = ({ email, onSuccess, isAuth }: Props) => {
try {
formApi.clearErrors('code');
setIsCodeSending(true);
const token = await executeRecaptcha?.();
const token = await recaptcha.executeAsync();
await apiFetch('auth_send_otp', {
fetchParams: {
method: 'POST',
body: { email, recaptcha_v3_response: token },
body: { email, recaptcha_response: token },
},
});
......@@ -96,7 +97,7 @@ const AuthModalScreenOtpCode = ({ email, onSuccess, isAuth }: Props) => {
} finally {
setIsCodeSending(false);
}
}, [ apiFetch, email, executeRecaptcha, formApi, toast ]);
}, [ apiFetch, email, formApi, toast, recaptcha ]);
return (
<FormProvider { ...formApi }>
......@@ -110,6 +111,7 @@ const AuthModalScreenOtpCode = ({ email, onSuccess, isAuth }: Props) => {
and enter your code below.
</Text>
<AuthModalFieldOtpCode isDisabled={ isCodeSending }/>
<ReCaptcha ref={ recaptcha.ref }/>
<Button
variant="link"
display="flex"
......
......@@ -164,6 +164,22 @@ const Footer = () => {
m: '0 auto',
};
const renderRecaptcha = (gridArea?: GridProps['gridArea']) => {
if (!config.services.reCaptchaV2.siteKey) {
return <Box gridArea={ gridArea }/>;
}
return (
<Box gridArea={ gridArea } fontSize="xs" lineHeight={ 5 } mt={ 6 } color="text">
<span>This site is protected by reCAPTCHA and the Google </span>
<Link href="https://policies.google.com/privacy" isExternal>Privacy Policy</Link>
<span> and </span>
<Link href="https://policies.google.com/terms" isExternal>Terms of Service</Link>
<span> apply.</span>
</Box>
);
};
if (config.UI.footer.links) {
return (
<Box { ...containerProps }>
......@@ -171,6 +187,7 @@ const Footer = () => {
<div>
{ renderNetworkInfo() }
{ renderProjectInfo() }
{ renderRecaptcha() }
</div>
<Grid
......@@ -212,12 +229,14 @@ const Footer = () => {
lg: `
"network links-top"
"info links-bottom"
"recaptcha links-bottom"
`,
}}
>
{ renderNetworkInfo({ lg: 'network' }) }
{ renderProjectInfo({ lg: 'info' }) }
{ renderRecaptcha({ lg: 'recaptcha' }) }
<Grid
gridArea={{ lg: 'links-bottom' }}
......
import type { ToastId } from '@chakra-ui/react';
import { chakra, Alert, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay, Spinner, Center } from '@chakra-ui/react';
import { Alert, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay, Spinner, Center } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import { GoogleReCaptcha, GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import type { SocketMessage } from 'lib/socket/types';
import type { TokenInstance } from 'types/api/token';
......@@ -11,9 +10,12 @@ import config from 'configs/app';
import useApiFetch from 'lib/api/useApiFetch';
import { getResourceKey } from 'lib/api/useApiQuery';
import { MINUTE, SECOND } from 'lib/consts';
import getErrorMessage from 'lib/errors/getErrorMessage';
import useToast from 'lib/hooks/useToast';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import ReCaptcha from 'ui/shared/reCaptcha/ReCaptcha';
import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
import { useMetadataUpdateContext } from './contexts/metadataUpdate';
......@@ -30,6 +32,7 @@ const TokenInstanceMetadataFetcher = ({ hash, id }: Props) => {
const apiFetch = useApiFetch();
const toast = useToast();
const queryClient = useQueryClient();
const recaptcha = useReCaptcha();
const handleRefreshError = React.useCallback(() => {
setStatus?.('ERROR');
......@@ -42,53 +45,41 @@ const TokenInstanceMetadataFetcher = ({ hash, id }: Props) => {
});
}, [ setStatus, toast ]);
const initializeUpdate = React.useCallback((reCaptchaToken: string) => {
apiFetch<'token_instance_refresh_metadata', unknown, unknown>('token_instance_refresh_metadata', {
pathParams: { hash, id },
fetchParams: {
method: 'PATCH',
body: { recaptcha_v3_response: reCaptchaToken },
},
})
.then(() => {
setStatus?.('WAITING_FOR_RESPONSE');
toastId.current = toast({
title: 'Please wait',
description: 'Refetching metadata request sent',
icon: <Spinner size="sm" mr={ 2 }/>,
status: 'warning',
duration: null,
isClosable: false,
});
timeoutId.current = window.setTimeout(handleRefreshError, 2 * MINUTE);
})
.catch(() => {
toast({
title: 'Error',
description: 'Unable to initialize metadata update',
status: 'warning',
});
setStatus?.('ERROR');
const initializeUpdate = React.useCallback(async(tokenProp?: string) => {
try {
const token = tokenProp || await recaptcha.executeAsync();
await apiFetch<'token_instance_refresh_metadata', unknown, unknown>('token_instance_refresh_metadata', {
pathParams: { hash, id },
fetchParams: {
method: 'PATCH',
body: { recaptcha_response: token },
},
});
}, [ apiFetch, handleRefreshError, hash, id, setStatus, toast ]);
setStatus?.('WAITING_FOR_RESPONSE');
toastId.current = toast({
title: 'Please wait',
description: 'Refetching metadata request sent',
icon: <Spinner size="sm" mr={ 2 }/>,
status: 'warning',
duration: null,
isClosable: false,
});
timeoutId.current = window.setTimeout(handleRefreshError, 2 * MINUTE);
} catch (error) {
toast({
title: 'Error',
description: getErrorMessage(error) || 'Unable to initialize metadata update',
status: 'warning',
});
setStatus?.('ERROR');
}
}, [ apiFetch, handleRefreshError, hash, id, recaptcha, setStatus, toast ]);
const handleModalClose = React.useCallback(() => {
setStatus?.('INITIAL');
}, [ setStatus ]);
const handleReCaptchaChange = React.useCallback((token: string | null) => {
if (token) {
initializeUpdate(token);
}
}, [ initializeUpdate ]);
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = React.useCallback((event) => {
event.preventDefault();
const data = new FormData(event.target as HTMLFormElement);
const token = data.get('recaptcha_token');
typeof token === 'string' && initializeUpdate(token);
}, [ initializeUpdate ]);
const handleSocketMessage: SocketMessage.TokenInstanceMetadataFetched['handler'] = React.useCallback((payload) => {
if (String(payload.token_id) !== id) {
return;
......@@ -141,6 +132,15 @@ const TokenInstanceMetadataFetcher = ({ hash, id }: Props) => {
handler: handleSocketMessage,
});
React.useEffect(() => {
if (status !== 'MODAL_OPENED') {
return;
}
const timeoutId = window.setTimeout(initializeUpdate, 100);
return () => window.clearTimeout(timeoutId);
}, [ status, initializeUpdate ]);
React.useEffect(() => {
return () => {
timeoutId.current && window.clearTimeout(timeoutId.current);
......@@ -161,25 +161,12 @@ const TokenInstanceMetadataFetcher = ({ hash, id }: Props) => {
<ModalHeader fontWeight="500" textStyle="h3" mb={ 4 }>Sending request</ModalHeader>
<ModalCloseButton/>
<ModalBody mb={ 0 } minH="78px">
{ config.services.reCaptchaV3.siteKey ? (
{ config.services.reCaptchaV2.siteKey ? (
<>
<GoogleReCaptchaProvider reCaptchaKey={ config.services.reCaptchaV3.siteKey }>
<Center h="80px">
<Spinner size="lg"/>
</Center>
<GoogleReCaptcha
onVerify={ handleReCaptchaChange }
refreshReCaptcha
/>
</GoogleReCaptchaProvider>
{ /* ONLY FOR TEST PURPOSES */ }
<chakra.form noValidate onSubmit={ handleFormSubmit } display="none">
<chakra.input
name="recaptcha_token"
placeholder="reCaptcha token"
/>
<chakra.button type="submit">Submit</chakra.button>
</chakra.form>
<Center h="80px">
<Spinner size="lg"/>
</Center>
<ReCaptcha ref={ recaptcha.ref }/>
</>
) : (
<Alert status="error">
......
......@@ -15010,7 +15010,7 @@ prompts@^2.0.1:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@^15.5.0, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
......@@ -15252,6 +15252,14 @@ rc@^1.2.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
react-async-script@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/react-async-script/-/react-async-script-1.2.0.tgz#ab9412a26f0b83f5e2e00de1d2befc9400834b21"
integrity sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==
dependencies:
hoist-non-react-statics "^3.3.0"
prop-types "^15.5.0"
react-clientside-effect@^1.2.6:
version "1.2.6"
resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz#29f9b14e944a376b03fb650eed2a754dd128ea3a"
......@@ -15307,12 +15315,13 @@ react-focus-lock@^2.5.2, react-focus-lock@^2.9.4:
use-callback-ref "^1.3.0"
use-sidecar "^1.1.2"
react-google-recaptcha-v3@1.10.1:
version "1.10.1"
resolved "https://registry.yarnpkg.com/react-google-recaptcha-v3/-/react-google-recaptcha-v3-1.10.1.tgz#5b125bc0dec123206431860e8800e188fc735aff"
integrity sha512-K3AYzSE0SasTn+XvV2tq+6YaxM+zQypk9rbCgG4OVUt7Rh4ze9basIKefoBz9sC0CNslJj9N1uwTTgRMJQbQJQ==
react-google-recaptcha@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz#44aaab834495d922b9d93d7d7a7fb2326315b4ab"
integrity sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==
dependencies:
hoist-non-react-statics "^3.3.2"
prop-types "^15.5.0"
react-async-script "^1.2.0"
react-hook-form@7.52.1:
version "7.52.1"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment