Commit 54a35a82 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Changes in ReCaptcha behavior (#2829)

* migrate to v2 API URLs for CSV exports

* don't ask user to solve recaptcha for first response; pass token in header as well

* hide "Try again" button if no bypass allowed

* fix tests
parent ad61f745
...@@ -9,6 +9,8 @@ NEXT_PUBLIC_APP_PORT=3000 ...@@ -9,6 +9,8 @@ NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS=true
# Instance ENVs # Instance ENVs
NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP={ "id": "632019", "width": "728", "height": "90" } NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP={ "id": "632019", "width": "728", "height": "90" }
NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE={ "id": "632018", "width": "320", "height": "100" } NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE={ "id": "632018", "width": "320", "height": "100" }
......
...@@ -125,6 +125,28 @@ export const GENERAL_API_ADDRESS_RESOURCES = { ...@@ -125,6 +125,28 @@ export const GENERAL_API_ADDRESS_RESOURCES = {
path: '/api/v2/proxy/3dparty/xname/addresses/:hash', path: '/api/v2/proxy/3dparty/xname/addresses/:hash',
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
}, },
// CSV EXPORTS
address_csv_export_txs: {
path: '/api/v2/addresses/:hash/transactions/csv',
pathParams: [ 'hash' as const ],
},
address_csv_export_internal_txs: {
path: '/api/v2/addresses/:hash/internal-transactions/csv',
pathParams: [ 'hash' as const ],
},
address_csv_export_token_transfers: {
path: '/api/v2/addresses/:hash/token-transfers/csv',
pathParams: [ 'hash' as const ],
},
address_csv_export_logs: {
path: '/api/v2/addresses/:hash/logs/csv',
pathParams: [ 'hash' as const ],
},
address_csv_export_celo_election_rewards: {
path: '/api/v2/addresses/:hash/celo/election-rewards/csv',
pathParams: [ 'hash' as const ],
},
} satisfies Record<string, ApiResource>; } satisfies Record<string, ApiResource>;
export type GeneralApiAddressResourceName = `general:${ keyof typeof GENERAL_API_ADDRESS_RESOURCES }`; export type GeneralApiAddressResourceName = `general:${ keyof typeof GENERAL_API_ADDRESS_RESOURCES }`;
......
...@@ -246,12 +246,6 @@ export const GENERAL_API_MISC_RESOURCES = { ...@@ -246,12 +246,6 @@ export const GENERAL_API_MISC_RESOURCES = {
path: '/api/v2/config/celo', path: '/api/v2/config/celo',
}, },
// CSV EXPORT
csv_export_token_holders: {
path: '/api/v2/tokens/:hash/holders/csv',
pathParams: [ 'hash' as const ],
},
// OTHER // OTHER
api_v2_key: { api_v2_key: {
path: '/api/v2/key', path: '/api/v2/key',
......
...@@ -49,6 +49,10 @@ export const GENERAL_API_TOKEN_RESOURCES = { ...@@ -49,6 +49,10 @@ export const GENERAL_API_TOKEN_RESOURCES = {
filterFields: [ 'q' as const, 'chain_ids' as const ], filterFields: [ 'q' as const, 'chain_ids' as const ],
paginated: true, paginated: true,
}, },
token_csv_export_holders: {
path: '/api/v2/tokens/:hash/holders/csv',
pathParams: [ 'hash' as const ],
},
// TOKEN INSTANCE // TOKEN INSTANCE
token_instance: { token_instance: {
......
...@@ -2,21 +2,6 @@ import type { ApiResource } from '../../types'; ...@@ -2,21 +2,6 @@ import type { ApiResource } from '../../types';
import type { BlockCountdownResponse } from 'types/api/block'; import type { BlockCountdownResponse } from 'types/api/block';
export const GENERAL_API_V1_RESOURCES = { export const GENERAL_API_V1_RESOURCES = {
csv_export_txs: {
path: '/api/v1/transactions-csv',
},
csv_export_internal_txs: {
path: '/api/v1/internal-transactions-csv',
},
csv_export_token_transfers: {
path: '/api/v1/token-transfers-csv',
},
csv_export_logs: {
path: '/api/v1/logs-csv',
},
csv_export_epoch_rewards: {
path: '/api/v1/celo-election-rewards-csv',
},
graphql: { graphql: {
path: '/api/v1/graphql', path: '/api/v1/graphql',
}, },
......
...@@ -54,6 +54,9 @@ export default function useFetch() { ...@@ -54,6 +54,9 @@ export default function useFetch() {
const error = { const error = {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
rateLimits: {
bypassOptions: response.headers.get('bypass-429-option'),
},
}; };
if (meta?.logError && rollbar) { if (meta?.logError && rollbar) {
...@@ -67,18 +70,16 @@ export default function useFetch() { ...@@ -67,18 +70,16 @@ export default function useFetch() {
if (!isJson) { if (!isJson) {
return response.text().then( return response.text().then(
(textError) => Promise.reject({ (textError) => Promise.reject({
...error,
payload: textError, payload: textError,
status: response.status,
statusText: response.statusText,
}), }),
); );
} }
return response.json().then( return response.json().then(
(jsonError) => Promise.reject({ (jsonError) => Promise.reject({
...error,
payload: jsonError as Error, payload: jsonError as Error,
status: response.status,
statusText: response.statusText,
}), }),
() => { () => {
return Promise.reject(error); return Promise.reject(error);
......
...@@ -23,6 +23,8 @@ export default function fetchFactory( ...@@ -23,6 +23,8 @@ export default function fetchFactory(
cookie, cookie,
...pick(_req.headers, [ ...pick(_req.headers, [
'x-csrf-token', 'x-csrf-token',
'recaptcha-v2-response',
'user-agent',
'Authorization', // the old value, just in case 'Authorization', // the old value, just in case
'authorization', // Node.js automatically lowercases headers 'authorization', // Node.js automatically lowercases headers
// feature flags // feature flags
......
...@@ -21,14 +21,24 @@ const handler = async(nextReq: NextApiRequest, nextRes: NextApiResponse) => { ...@@ -21,14 +21,24 @@ const handler = async(nextReq: NextApiRequest, nextRes: NextApiResponse) => {
); );
// proxy some headers from API // proxy some headers from API
const requestId = apiRes.headers.get('x-request-id'); const HEADERS_TO_PROXY = [
requestId && nextRes.setHeader('x-request-id', requestId); 'x-request-id',
'content-type',
'bypass-429-option',
'x-ratelimit-limit',
'x-ratelimit-remaining',
'x-ratelimit-reset',
];
HEADERS_TO_PROXY.forEach((header) => {
const value = apiRes.headers.get(header);
value && nextRes.setHeader(header, value);
});
const setCookie = apiRes.headers.raw()['set-cookie']; const setCookie = apiRes.headers.raw()['set-cookie'];
setCookie?.forEach((value) => { setCookie?.forEach((value) => {
nextRes.appendHeader('set-cookie', value); nextRes.appendHeader('set-cookie', value);
}); });
nextRes.setHeader('content-type', apiRes.headers.get('content-type') || '');
nextRes.status(apiRes.status).send(apiRes.body); nextRes.status(apiRes.status).send(apiRes.body);
}; };
......
...@@ -2,6 +2,10 @@ const useReCaptcha = () => { ...@@ -2,6 +2,10 @@ const useReCaptcha = () => {
return { return {
ref: { current: null }, ref: { current: null },
executeAsync: () => Promise.resolve('recaptcha_token'), executeAsync: () => Promise.resolve('recaptcha_token'),
fetchProtectedResource: async(fetcher) => {
const result = await fetcher();
return result;
},
}; };
}; };
......
...@@ -20,30 +20,36 @@ const ExportCSV = ({ filters }: Props) => { ...@@ -20,30 +20,36 @@ const ExportCSV = ({ filters }: Props) => {
const recaptcha = useReCaptcha(); const recaptcha = useReCaptcha();
const [ isLoading, setIsLoading ] = React.useState(false); const [ isLoading, setIsLoading ] = React.useState(false);
const handleExportCSV = React.useCallback(async() => { const apiFetchFactory = React.useCallback(async(recaptchaToken?: string) => {
try {
setIsLoading(true);
const token = await recaptcha.executeAsync();
if (!token) {
throw new Error('ReCaptcha is not solved');
}
const url = buildUrl('general:advanced_filter_csv', undefined, { const url = buildUrl('general:advanced_filter_csv', undefined, {
...filters, ...filters,
recaptcha_response: token, recaptcha_response: recaptchaToken,
}); });
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'content-type': 'application/octet-stream', 'content-type': 'application/octet-stream',
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
}, },
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(); throw new Error(response.statusText, {
cause: {
status: response.status,
},
});
} }
return response;
}, [ filters ]);
const handleExportCSV = React.useCallback(async() => {
try {
setIsLoading(true);
const response = await recaptcha.fetchProtectedResource(apiFetchFactory);
const blob = await response.blob(); const blob = await response.blob();
const fileName = `export-filtered-txs-${ dayjs().format('YYYY-MM-DD-HH-mm-ss') }.csv`; const fileName = `export-filtered-txs-${ dayjs().format('YYYY-MM-DD-HH-mm-ss') }.csv`;
downloadBlob(blob, fileName); downloadBlob(blob, fileName);
...@@ -56,7 +62,7 @@ const ExportCSV = ({ filters }: Props) => { ...@@ -56,7 +62,7 @@ const ExportCSV = ({ filters }: Props) => {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [ filters, recaptcha ]); }, [ apiFetchFactory, recaptcha ]);
if (!config.services.reCaptchaV2.siteKey) { if (!config.services.reCaptchaV2.siteKey) {
return null; return null;
......
...@@ -39,33 +39,40 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla ...@@ -39,33 +39,40 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
const { handleSubmit, formState } = formApi; const { handleSubmit, formState } = formApi;
const recaptcha = useReCaptcha(); const recaptcha = useReCaptcha();
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => { const apiFetchFactory = React.useCallback((data: FormFields) => {
try { return async(recaptchaToken?: string) => {
const token = await recaptcha.executeAsync();
if (!token) {
throw new Error('ReCaptcha is not solved');
}
const url = buildUrl(resource, { hash } as never, { const url = buildUrl(resource, { hash } as never, {
address_id: hash, address_id: hash,
from_period: exportType !== 'holders' ? dayjs(data.from).toISOString() : null, from_period: exportType !== 'holders' ? dayjs(data.from).toISOString() : null,
to_period: exportType !== 'holders' ? dayjs(data.to).toISOString() : null, to_period: exportType !== 'holders' ? dayjs(data.to).toISOString() : null,
filter_type: filterType, filter_type: filterType,
filter_value: filterValue, filter_value: filterValue,
recaptcha_response: token, recaptcha_response: recaptchaToken,
}); });
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'content-type': 'application/octet-stream', 'content-type': 'application/octet-stream',
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
}, },
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(); throw new Error(response.statusText, {
cause: {
status: response.status,
},
});
} }
return response;
};
}, [ resource, hash, exportType, filterType, filterValue ]);
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => {
try {
const response = await recaptcha.fetchProtectedResource<Response>(apiFetchFactory(data));
const blob = await response.blob(); const blob = await response.blob();
const fileName = exportType === 'holders' ? const fileName = exportType === 'holders' ?
`${ fileNameTemplate }_${ hash }.csv` : `${ fileNameTemplate }_${ hash }.csv` :
...@@ -80,7 +87,7 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla ...@@ -80,7 +87,7 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
}); });
} }
}, [ recaptcha, resource, hash, exportType, filterType, filterValue, fileNameTemplate ]); }, [ recaptcha, apiFetchFactory, exportType, fileNameTemplate, hash, filterType, filterValue ]);
if (!config.services.reCaptchaV2.siteKey) { if (!config.services.reCaptchaV2.siteKey) {
return ( return (
......
...@@ -46,19 +46,21 @@ const MyProfileEmail = ({ profileQuery }: Props) => { ...@@ -46,19 +46,21 @@ const MyProfileEmail = ({ profileQuery }: Props) => {
}, },
}); });
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(formData) => { const authFetchFactory = React.useCallback((email: string) => (recaptchaToken?: string) => {
try { return apiFetch('general:auth_send_otp', {
const token = await recaptcha.executeAsync();
await apiFetch('general:auth_send_otp', {
fetchParams: { fetchParams: {
method: 'POST', method: 'POST',
body: { body: { email, recaptcha_response: recaptchaToken },
email: formData.email, headers: {
recaptcha_response: token, ...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
}, },
}, },
}); });
}, [ apiFetch ]);
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(formData) => {
try {
await recaptcha.fetchProtectedResource(authFetchFactory(formData.email));
mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_LINK_INFO, { mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_LINK_INFO, {
Source: 'Profile', Source: 'Profile',
Status: 'OTP sent', Status: 'OTP sent',
...@@ -72,7 +74,7 @@ const MyProfileEmail = ({ profileQuery }: Props) => { ...@@ -72,7 +74,7 @@ const MyProfileEmail = ({ profileQuery }: Props) => {
description: apiError?.message || getErrorMessage(error) || 'Something went wrong', description: apiError?.message || getErrorMessage(error) || 'Something went wrong',
}); });
} }
}, [ apiFetch, authModal, recaptcha ]); }, [ authFetchFactory, authModal, recaptcha ]);
const hasDirtyFields = Object.keys(formApi.formState.dirtyFields).length > 0; const hasDirtyFields = Object.keys(formApi.formState.dirtyFields).length > 0;
......
...@@ -29,39 +29,39 @@ interface ExportTypeEntity { ...@@ -29,39 +29,39 @@ interface ExportTypeEntity {
const EXPORT_TYPES: Record<CsvExportParams['type'], ExportTypeEntity> = { const EXPORT_TYPES: Record<CsvExportParams['type'], ExportTypeEntity> = {
transactions: { transactions: {
text: 'transactions', text: 'transactions',
resource: 'general:csv_export_txs', resource: 'general:address_csv_export_txs',
fileNameTemplate: 'transactions', fileNameTemplate: 'transactions',
filterType: 'address', filterType: 'address',
filterValues: AddressFromToFilterValues, filterValues: AddressFromToFilterValues,
}, },
'internal-transactions': { 'internal-transactions': {
text: 'internal transactions', text: 'internal transactions',
resource: 'general:csv_export_internal_txs', resource: 'general:address_csv_export_internal_txs',
fileNameTemplate: 'internal_transactions', fileNameTemplate: 'internal_transactions',
filterType: 'address', filterType: 'address',
filterValues: AddressFromToFilterValues, filterValues: AddressFromToFilterValues,
}, },
'token-transfers': { 'token-transfers': {
text: 'token transfers', text: 'token transfers',
resource: 'general:csv_export_token_transfers', resource: 'general:address_csv_export_token_transfers',
fileNameTemplate: 'token_transfers', fileNameTemplate: 'token_transfers',
filterType: 'address', filterType: 'address',
filterValues: AddressFromToFilterValues, filterValues: AddressFromToFilterValues,
}, },
logs: { logs: {
text: 'logs', text: 'logs',
resource: 'general:csv_export_logs', resource: 'general:address_csv_export_logs',
fileNameTemplate: 'logs', fileNameTemplate: 'logs',
filterType: 'topic', filterType: 'topic',
}, },
holders: { holders: {
text: 'holders', text: 'holders',
resource: 'general:csv_export_token_holders', resource: 'general:token_csv_export_holders',
fileNameTemplate: 'holders', fileNameTemplate: 'holders',
}, },
'epoch-rewards': { 'epoch-rewards': {
text: 'epoch rewards', text: 'epoch rewards',
resource: 'general:csv_export_epoch_rewards', resource: 'general:address_csv_export_celo_election_rewards',
fileNameTemplate: 'epoch_rewards', fileNameTemplate: 'epoch_rewards',
}, },
}; };
......
...@@ -7,6 +7,7 @@ import config from 'configs/app'; ...@@ -7,6 +7,7 @@ import config from 'configs/app';
import getErrorCause from 'lib/errors/getErrorCause'; import getErrorCause from 'lib/errors/getErrorCause';
import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode'; import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode'; import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
import getErrorProp from 'lib/errors/getErrorProp';
import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload'; import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload';
import { Button } from 'toolkit/chakra/button'; import { Button } from 'toolkit/chakra/button';
import { Link } from 'toolkit/chakra/link'; import { Link } from 'toolkit/chakra/link';
...@@ -77,7 +78,9 @@ const AppError = ({ error, className }: Props) => { ...@@ -77,7 +78,9 @@ const AppError = ({ error, className }: Props) => {
switch (statusCode) { switch (statusCode) {
case 429: { case 429: {
return <AppErrorTooManyRequests/>; const rateLimits = getErrorProp(error, 'rateLimits');
const bypassOptions = typeof rateLimits === 'object' && rateLimits && 'bypassOptions' in rateLimits ? rateLimits.bypassOptions : undefined;
return <AppErrorTooManyRequests bypassOptions={ typeof bypassOptions === 'string' ? bypassOptions : undefined }/>;
} }
default: { default: {
......
...@@ -12,18 +12,30 @@ import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha'; ...@@ -12,18 +12,30 @@ import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
import AppErrorIcon from '../AppErrorIcon'; import AppErrorIcon from '../AppErrorIcon';
import AppErrorTitle from '../AppErrorTitle'; import AppErrorTitle from '../AppErrorTitle';
const AppErrorTooManyRequests = () => { interface Props {
bypassOptions?: string;
}
const AppErrorTooManyRequests = ({ bypassOptions }: Props) => {
const fetch = useFetch(); const fetch = useFetch();
const recaptcha = useReCaptcha(); const recaptcha = useReCaptcha();
const handleSubmit = React.useCallback(async() => { const handleSubmit = React.useCallback(async() => {
try { try {
const token = await recaptcha.executeAsync(); const token = await recaptcha.executeAsync();
if (!token) {
throw new Error('ReCaptcha is not solved');
}
const url = buildUrl('general:api_v2_key'); const url = buildUrl('general:api_v2_key');
await fetch(url, { await fetch(url, {
method: 'POST', method: 'POST',
body: { recaptcha_response: token }, body: { recaptcha_response: token },
headers: {
'recaptcha-v2-response': token,
},
credentials: 'include', credentials: 'include',
}, { }, {
resource: 'general:api_v2_key', resource: 'general:api_v2_key',
...@@ -52,7 +64,7 @@ const AppErrorTooManyRequests = () => { ...@@ -52,7 +64,7 @@ const AppErrorTooManyRequests = () => {
You have exceeded the request rate for a given time period. Please reduce the number of requests and try again soon. You have exceeded the request rate for a given time period. Please reduce the number of requests and try again soon.
</Text> </Text>
<ReCaptcha { ...recaptcha }/> <ReCaptcha { ...recaptcha }/>
<Button onClick={ handleSubmit } disabled={ recaptcha.isInitError } mt={ 8 }>Try again</Button> { bypassOptions !== 'no_bypass' && <Button onClick={ handleSubmit } disabled={ recaptcha.isInitError } mt={ 8 }>Try again</Button> }
</> </>
); );
}; };
......
import React from 'react'; import React from 'react';
import type ReCAPTCHA from 'react-google-recaptcha'; import type ReCAPTCHA from 'react-google-recaptcha';
import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
export default function useReCaptcha() { export default function useReCaptcha() {
const ref = React.useRef<ReCAPTCHA>(null); const ref = React.useRef<ReCAPTCHA>(null);
const rejectCb = React.useRef<((error: Error) => void) | null>(null); const rejectCb = React.useRef<((error: Error) => void) | null>(null);
...@@ -40,5 +43,31 @@ export default function useReCaptcha() { ...@@ -40,5 +43,31 @@ export default function useReCaptcha() {
}; };
}, [ isOpen, handleContainerClick ]); }, [ isOpen, handleContainerClick ]);
return React.useMemo(() => ({ ref, executeAsync, isInitError, onInitError: handleInitError }), [ ref, executeAsync, isInitError, handleInitError ]); const fetchProtectedResource: <T>(fetcher: (token?: string) => Promise<T>, token?: string) => Promise<T> = React.useCallback(async(fetcher, token) => {
try {
const result = await fetcher(token);
return result;
} catch (error) {
const statusCode = error instanceof Error ? getErrorCauseStatusCode(error) : getErrorObjStatusCode(error);
if (statusCode === 429) {
const token = await executeAsync();
if (!token) {
throw new Error('ReCaptcha is not solved');
}
return fetchProtectedResource(fetcher, token);
}
throw error;
}
}, [ executeAsync ]);
return React.useMemo(() => ({
ref,
executeAsync,
isInitError,
onInitError: handleInitError,
fetchProtectedResource,
}), [ ref, executeAsync, isInitError, handleInitError, fetchProtectedResource ]);
} }
...@@ -35,7 +35,7 @@ const AuthModalScreenConnectWallet = ({ onSuccess, onError, isAuth, source, logi ...@@ -35,7 +35,7 @@ const AuthModalScreenConnectWallet = ({ onSuccess, onError, isAuth, source, logi
onError: handleSignInError, onError: handleSignInError,
source, source,
isAuth, isAuth,
executeRecaptchaAsync: recaptcha.executeAsync, fetchProtectedResource: recaptcha.fetchProtectedResource,
loginToRewards, loginToRewards,
}); });
......
...@@ -37,19 +37,22 @@ const AuthModalScreenEmail = ({ onSubmit, isAuth, mixpanelConfig }: Props) => { ...@@ -37,19 +37,22 @@ const AuthModalScreenEmail = ({ onSubmit, isAuth, mixpanelConfig }: Props) => {
}, },
}); });
const onFormSubmit: SubmitHandler<EmailFormFields> = React.useCallback(async(formData) => { const sendCodeFetchFactory = React.useCallback((email: string) => (recaptchaToken?: string) => {
try { return apiFetch('general:auth_send_otp', {
const token = await recaptcha.executeAsync();
await apiFetch('general:auth_send_otp', {
fetchParams: { fetchParams: {
method: 'POST', method: 'POST',
body: { body: { email, recaptcha_response: recaptchaToken },
email: formData.email, headers: {
recaptcha_response: token, ...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
}, },
}, },
}); });
}, [ apiFetch ]);
const onFormSubmit: SubmitHandler<EmailFormFields> = React.useCallback(async(formData) => {
try {
await recaptcha.fetchProtectedResource(sendCodeFetchFactory(formData.email));
if (isAuth) { if (isAuth) {
mixpanelConfig?.account_link_info.source !== 'Profile' && mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_LINK_INFO, { mixpanelConfig?.account_link_info.source !== 'Profile' && mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_LINK_INFO, {
Source: mixpanelConfig?.account_link_info.source ?? 'Profile dropdown', Source: mixpanelConfig?.account_link_info.source ?? 'Profile dropdown',
...@@ -69,7 +72,7 @@ const AuthModalScreenEmail = ({ onSubmit, isAuth, mixpanelConfig }: Props) => { ...@@ -69,7 +72,7 @@ const AuthModalScreenEmail = ({ onSubmit, isAuth, mixpanelConfig }: Props) => {
description: getErrorObjPayload<{ message: string }>(error)?.message || getErrorMessage(error) || 'Something went wrong', description: getErrorObjPayload<{ message: string }>(error)?.message || getErrorMessage(error) || 'Something went wrong',
}); });
} }
}, [ recaptcha, apiFetch, isAuth, onSubmit, mixpanelConfig?.account_link_info.source ]); }, [ recaptcha, sendCodeFetchFactory, isAuth, onSubmit, mixpanelConfig?.account_link_info.source ]);
return ( return (
<FormProvider { ...formApi }> <FormProvider { ...formApi }>
......
...@@ -68,17 +68,23 @@ const AuthModalScreenOtpCode = ({ email, onSuccess, isAuth }: Props) => { ...@@ -68,17 +68,23 @@ const AuthModalScreenOtpCode = ({ email, onSuccess, isAuth }: Props) => {
}); });
}, [ apiFetch, email, onSuccess, isAuth, formApi ]); }, [ apiFetch, email, onSuccess, isAuth, formApi ]);
const handleResendCodeClick = React.useCallback(async() => { const resendCodeFetchFactory = React.useCallback((recaptchaToken?: string) => {
try { return apiFetch('general:auth_send_otp', {
formApi.clearErrors('code');
setIsCodeSending(true);
const token = await recaptcha.executeAsync();
await apiFetch('general:auth_send_otp', {
fetchParams: { fetchParams: {
method: 'POST', method: 'POST',
body: { email, recaptcha_response: token }, body: { email, recaptcha_response: recaptchaToken },
headers: {
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
},
}, },
}); });
}, [ apiFetch, email ]);
const handleResendCodeClick = React.useCallback(async() => {
try {
formApi.clearErrors('code');
setIsCodeSending(true);
await recaptcha.fetchProtectedResource(resendCodeFetchFactory);
toaster.success({ toaster.success({
title: 'Success', title: 'Success',
...@@ -94,7 +100,7 @@ const AuthModalScreenOtpCode = ({ email, onSuccess, isAuth }: Props) => { ...@@ -94,7 +100,7 @@ const AuthModalScreenOtpCode = ({ email, onSuccess, isAuth }: Props) => {
} finally { } finally {
setIsCodeSending(false); setIsCodeSending(false);
} }
}, [ apiFetch, email, formApi, recaptcha ]); }, [ formApi, recaptcha, resendCodeFetchFactory ]);
return ( return (
<FormProvider { ...formApi }> <FormProvider { ...formApi }>
......
...@@ -41,10 +41,10 @@ interface Props { ...@@ -41,10 +41,10 @@ interface Props {
source?: mixpanel.EventPayload<mixpanel.EventTypes.WALLET_CONNECT>['Source']; source?: mixpanel.EventPayload<mixpanel.EventTypes.WALLET_CONNECT>['Source'];
isAuth?: boolean; isAuth?: boolean;
loginToRewards?: boolean; loginToRewards?: boolean;
executeRecaptchaAsync: () => Promise<string | null>; fetchProtectedResource: <T>(fetcher: (token?: string) => Promise<T>, token?: string) => Promise<T>;
} }
function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, loginToRewards, executeRecaptchaAsync }: Props) { function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, loginToRewards, fetchProtectedResource }: Props) {
const [ isPending, setIsPending ] = React.useState(false); const [ isPending, setIsPending ] = React.useState(false);
const isConnectingWalletRef = React.useRef(false); const isConnectingWalletRef = React.useRef(false);
...@@ -96,24 +96,26 @@ function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, log ...@@ -96,24 +96,26 @@ function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, log
} }
}, [ apiFetch, loginToRewards ]); }, [ apiFetch, loginToRewards ]);
const authFetchFactory = React.useCallback((message: string, signature: string) => (recaptchaToken?: string) => {
const authResource = isAuth ? 'general:auth_link_address' : 'general:auth_siwe_verify';
return apiFetch<typeof authResource, UserInfo, unknown>(authResource, {
fetchParams: {
method: 'POST',
body: { message, signature, recaptcha_response: recaptchaToken },
headers: {
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
},
},
});
}, [ apiFetch, isAuth ]);
const proceedToAuth = React.useCallback(async(address: string) => { const proceedToAuth = React.useCallback(async(address: string) => {
try { try {
await switchChainAsync({ chainId: Number(config.chain.id) }); await switchChainAsync({ chainId: Number(config.chain.id) });
const siweMessage = await getSiweMessage(address); const siweMessage = await getSiweMessage(address);
const signature = await signMessageAsync({ message: siweMessage.message }); const signature = await signMessageAsync({ message: siweMessage.message });
const recaptchaToken = await executeRecaptchaAsync();
if (!recaptchaToken) { const authResponse = await fetchProtectedResource(authFetchFactory(siweMessage.message, signature));
throw new Error('ReCaptcha is not solved');
}
const authResource = isAuth ? 'general:auth_link_address' : 'general:auth_siwe_verify';
const authResponse = await apiFetch<typeof authResource, UserInfo, unknown>(authResource, {
fetchParams: {
method: 'POST',
body: { message: siweMessage.message, signature, recaptcha_response: recaptchaToken },
},
});
const rewardsLoginResponse = siweMessage.type === 'shared' ? const rewardsLoginResponse = siweMessage.type === 'shared' ?
await apiFetch('rewards:login', { await apiFetch('rewards:login', {
...@@ -143,7 +145,7 @@ function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, log ...@@ -143,7 +145,7 @@ function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, log
} finally { } finally {
setIsPending(false); setIsPending(false);
} }
}, [ getSiweMessage, switchChainAsync, signMessageAsync, executeRecaptchaAsync, isAuth, apiFetch, onSuccess, onError ]); }, [ switchChainAsync, getSiweMessage, signMessageAsync, fetchProtectedResource, authFetchFactory, apiFetch, onSuccess, onError ]);
const start = React.useCallback(() => { const start = React.useCallback(() => {
setIsPending(true); setIsPending(true);
......
...@@ -45,16 +45,22 @@ const TokenInstanceMetadataFetcher = ({ hash, id }: Props) => { ...@@ -45,16 +45,22 @@ const TokenInstanceMetadataFetcher = ({ hash, id }: Props) => {
}); });
}, [ setStatus ]); }, [ setStatus ]);
const initializeUpdate = React.useCallback(async(tokenProp?: string) => { const apiFetchFactory = React.useCallback(async(recaptchaToken?: string) => {
try { return apiFetch<'general:token_instance_refresh_metadata', unknown, unknown>('general:token_instance_refresh_metadata', {
const token = tokenProp || await recaptcha.executeAsync();
await apiFetch<'general:token_instance_refresh_metadata', unknown, unknown>('general:token_instance_refresh_metadata', {
pathParams: { hash, id }, pathParams: { hash, id },
fetchParams: { fetchParams: {
method: 'PATCH', method: 'PATCH',
body: { recaptcha_response: token }, body: { recaptcha_response: recaptchaToken },
headers: {
...(recaptchaToken && { 'recaptcha-v2-response': recaptchaToken }),
},
}, },
}); });
}, [ apiFetch, hash, id ]);
const initializeUpdate = React.useCallback(async() => {
try {
await recaptcha.fetchProtectedResource(apiFetchFactory);
setStatus?.('WAITING_FOR_RESPONSE'); setStatus?.('WAITING_FOR_RESPONSE');
toaster.loading({ toaster.loading({
id: TOAST_ID, id: TOAST_ID,
...@@ -72,7 +78,7 @@ const TokenInstanceMetadataFetcher = ({ hash, id }: Props) => { ...@@ -72,7 +78,7 @@ const TokenInstanceMetadataFetcher = ({ hash, id }: Props) => {
setStatus?.('ERROR'); setStatus?.('ERROR');
} }
}, [ apiFetch, handleRefreshError, hash, id, recaptcha, setStatus ]); }, [ apiFetchFactory, handleRefreshError, recaptcha, setStatus ]);
const handleModalClose = React.useCallback(({ open }: { open: boolean }) => { const handleModalClose = React.useCallback(({ open }: { open: boolean }) => {
if (!open) { if (!open) {
......
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