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

UX changes for BS / Merits login (#2541)

* UX changes for BS / Merits login

Fixes #2535

* fix ts

* refetch user balance after unified login

* fixes
parent f9804e57
......@@ -44,6 +44,7 @@ type TRewardsContext = {
isLoginModalOpen: boolean;
openLoginModal: () => void;
closeLoginModal: () => void;
saveApiToken: (token: string | undefined) => void;
login: (refCode: string) => Promise<{ isNewUser?: boolean; invalidRefCodeError?: boolean }>;
claim: () => Promise<void>;
};
......@@ -68,6 +69,7 @@ const initialState = {
isLoginModalOpen: false,
openLoginModal: () => {},
closeLoginModal: () => {},
saveApiToken: () => {},
login: async() => ({}),
claim: async() => {},
};
......@@ -265,6 +267,7 @@ export function RewardsContextProvider({ children }: Props) {
rewardsConfigQuery,
checkUserQuery,
apiToken,
saveApiToken,
isInitialized,
isLoginModalOpen,
openLoginModal: setIsLoginModalOpen.on,
......@@ -274,7 +277,7 @@ export function RewardsContextProvider({ children }: Props) {
};
}, [
isLoginModalOpen, setIsLoginModalOpen, balancesQuery, dailyRewardQuery, checkUserQuery,
apiToken, login, claim, referralsQuery, rewardsConfigQuery, isInitialized,
apiToken, login, claim, referralsQuery, rewardsConfigQuery, isInitialized, saveApiToken,
]);
return (
......
......@@ -7,4 +7,7 @@ export const base: RewardsConfigResponse = {
daily_claim: '10',
referral_share: '0.1',
},
auth: {
shared_siwe_login: true,
},
};
......@@ -5,6 +5,9 @@ export type RewardsConfigResponse = {
daily_claim: string;
referral_share: string;
};
auth: {
shared_siwe_login: boolean;
};
};
export type RewardsCheckRefCodeResponse = {
......@@ -13,6 +16,7 @@ export type RewardsCheckRefCodeResponse = {
export type RewardsNonceResponse = {
nonce: string;
merits_login_nonce?: string;
};
export type RewardsCheckUserResponse = {
......
......@@ -31,7 +31,7 @@ const MyProfile = () => {
useRedirectForInvalidAuthToken();
const handleAddWalletClick = React.useCallback(() => {
setAuthInitialScreen({ type: 'connect_wallet', isAuth: true });
setAuthInitialScreen({ type: 'connect_wallet', isAuth: true, loginToRewards: true });
authModal.onOpen();
}, [ authModal ]);
......
import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, useBoolean, useDisclosure } from '@chakra-ui/react';
import React, { useCallback, useEffect } from 'react';
import type { Screen } from 'ui/snippets/auth/types';
import { useRewardsContext } from 'lib/contexts/rewards';
import useIsMobile from 'lib/hooks/useIsMobile';
import useWallet from 'lib/web3/useWallet';
......@@ -25,7 +27,7 @@ const RewardsLoginModal = () => {
const [ isLoginStep, setIsLoginStep ] = useBoolean(true);
const [ isReferral, setIsReferral ] = useBoolean(false);
const [ isAuth, setIsAuth ] = useBoolean(false);
const [ authModalInitialScreen, setAuthModalInitialScreen ] = React.useState<Screen>();
const authModal = useDisclosure();
useEffect(() => {
......@@ -42,15 +44,18 @@ const RewardsLoginModal = () => {
setIsLoginStep.off();
}, [ setIsLoginStep, setIsReferral ]);
const handleAuthModalOpen = useCallback((isAuth: boolean) => {
setIsAuth[isAuth ? 'on' : 'off']();
const handleAuthModalOpen = useCallback((isAuth: boolean, trySharedLogin?: boolean) => {
setAuthModalInitialScreen({ type: 'connect_wallet', isAuth, loginToRewards: trySharedLogin });
authModal.onOpen();
}, [ authModal, setIsAuth ]);
}, [ authModal, setAuthModalInitialScreen ]);
const handleAuthModalClose = useCallback(() => {
setIsAuth.off();
const handleAuthModalClose = useCallback((isSuccess?: boolean, rewardsApiToken?: string) => {
if (isSuccess && rewardsApiToken) {
closeLoginModal();
}
setAuthModalInitialScreen(undefined);
authModal.onClose();
}, [ authModal, setIsAuth ]);
}, [ authModal, setAuthModalInitialScreen, closeLoginModal ]);
return (
<>
......@@ -74,10 +79,10 @@ const RewardsLoginModal = () => {
</ModalBody>
</ModalContent>
</Modal>
{ authModal.isOpen && (
{ authModal.isOpen && authModalInitialScreen && (
<AuthModal
onClose={ handleAuthModalClose }
initialScreen={{ type: 'connect_wallet', isAuth }}
initialScreen={ authModalInitialScreen }
mixpanelConfig={ MIXPANEL_CONFIG }
closeOnError
/>
......
......@@ -15,7 +15,7 @@ import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
type Props = {
goNext: (isReferral: boolean) => void;
closeModal: () => void;
openAuthModal: (isAuth: boolean) => void;
openAuthModal: (isAuth: boolean, trySharedLogin?: boolean) => void;
};
const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
......@@ -26,7 +26,7 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
const [ isLoading, setIsLoading ] = useBoolean(false);
const [ refCode, setRefCode ] = useState(savedRefCode || '');
const [ refCodeError, setRefCodeError ] = useBoolean(false);
const { login, checkUserQuery } = useRewardsContext();
const { login, checkUserQuery, rewardsConfigQuery } = useRewardsContext();
const profileQuery = useProfileQuery();
const isAddressMismatch = useMemo(() =>
......@@ -43,6 +43,8 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
isConnected && !isAddressMismatch && !checkUserQuery.isFetching && !checkUserQuery.data?.exists,
[ isConnected, isAddressMismatch, checkUserQuery ]);
const canTrySharedLogin = rewardsConfigQuery.data?.auth.shared_siwe_login && checkUserQuery.data?.exists !== false && !isLoggedIntoAccountWithWallet;
const handleRefCodeChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
setRefCode(event.target.value);
}, []);
......@@ -56,7 +58,7 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
setRefCodeError.on();
} else {
if (isNewUser) {
goNext(Boolean(refCode));
goNext(isRefCodeUsed);
} else {
closeModal();
router.push({ pathname: '/account/rewards' }, undefined, { shallow: true });
......@@ -74,15 +76,27 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
}
}, [ refCode, isRefCodeUsed, isSignUp ]); // eslint-disable-line react-hooks/exhaustive-deps
const handleLogin = useCallback(async() => {
const handleButtonClick = React.useCallback(() => {
if (canTrySharedLogin) {
return openAuthModal(Boolean(profileQuery.data?.email), true);
}
if (!isConnected) {
return connect();
}
if (isLoggedIntoAccountWithWallet) {
loginToRewardsProgram();
} else {
openAuthModal(Boolean(profileQuery.data?.email));
return loginToRewardsProgram();
}
}, [ loginToRewardsProgram, openAuthModal, isLoggedIntoAccountWithWallet, profileQuery ]);
return openAuthModal(Boolean(profileQuery.data?.email));
}, [ loginToRewardsProgram, openAuthModal, profileQuery, connect, isConnected, isLoggedIntoAccountWithWallet, canTrySharedLogin ]);
const buttonText = useMemo(() => {
if (canTrySharedLogin) {
return 'Continue with wallet';
}
if (!isConnected) {
return 'Connect wallet';
}
......@@ -90,7 +104,7 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
return isSignUp ? 'Get started' : 'Continue';
}
return profileQuery.data?.email ? 'Add wallet to account' : 'Log in to account';
}, [ isConnected, isLoggedIntoAccountWithWallet, profileQuery.data, isSignUp ]);
}, [ canTrySharedLogin, isConnected, isLoggedIntoAccountWithWallet, profileQuery.data?.email, isSignUp ]);
return (
<>
......@@ -148,7 +162,7 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
w="full"
whiteSpace="normal"
mb={ 4 }
onClick={ isConnected ? handleLogin : connect }
onClick={ handleButtonClick }
isLoading={ isLoading || profileQuery.isLoading || checkUserQuery.isFetching }
loadingText={ isLoading ? 'Sign message in your wallet' : undefined }
isDisabled={ isAddressMismatch || refCodeError }
......
......@@ -7,6 +7,7 @@ import type { Screen, ScreenSuccess } from './types';
import config from 'configs/app';
import { getResourceKey } from 'lib/api/useApiQuery';
import { useRewardsContext } from 'lib/contexts/rewards';
import useGetCsrfToken from 'lib/hooks/useGetCsrfToken';
import * as mixpanel from 'lib/mixpanel';
import IconSvg from 'ui/shared/IconSvg';
......@@ -22,7 +23,7 @@ const feature = config.features.account;
interface Props {
initialScreen: Screen;
onClose: (isSuccess?: boolean) => void;
onClose: (isSuccess?: boolean, rewardsApiToken?: string) => void;
mixpanelConfig?: {
wallet_connect?: {
source: mixpanel.EventPayload<mixpanel.EventTypes.WALLET_CONNECT>['Source'];
......@@ -37,6 +38,9 @@ interface Props {
const AuthModal = ({ initialScreen, onClose, mixpanelConfig, closeOnError }: Props) => {
const [ steps, setSteps ] = React.useState<Array<Screen>>([ initialScreen ]);
const [ isSuccess, setIsSuccess ] = React.useState(false);
const [ rewardsApiToken, setRewardsApiToken ] = React.useState<string | undefined>(undefined);
const { saveApiToken } = useRewardsContext();
const router = useRouter();
const csrfQuery = useGetCsrfToken();
......@@ -87,12 +91,18 @@ const AuthModal = ({ initialScreen, onClose, mixpanelConfig, closeOnError }: Pro
queryClient.setQueryData(getResourceKey('user_info'), () => screen.profile);
await csrfQuery.refetch();
if ('rewardsToken' in screen && screen.rewardsToken) {
setRewardsApiToken(screen.rewardsToken);
saveApiToken(screen.rewardsToken);
}
onNextStep(screen);
}, [ initialScreen, mixpanelConfig?.account_link_info.source, onNextStep, csrfQuery, queryClient ]);
}, [ initialScreen, mixpanelConfig?.account_link_info.source, onNextStep, csrfQuery, queryClient, saveApiToken ]);
const onModalClose = React.useCallback(() => {
onClose(isSuccess);
}, [ isSuccess, onClose ]);
onClose(isSuccess, rewardsApiToken);
}, [ isSuccess, rewardsApiToken, onClose ]);
const header = (() => {
const currentStep = steps[steps.length - 1];
......@@ -122,6 +132,7 @@ const AuthModal = ({ initialScreen, onClose, mixpanelConfig, closeOnError }: Pro
onSuccess={ onAuthSuccess }
onError={ onReset }
isAuth={ currentStep.isAuth }
loginToRewards={ currentStep.loginToRewards }
source={ mixpanelConfig?.wallet_connect?.source }
/>
);
......@@ -153,6 +164,7 @@ const AuthModal = ({ initialScreen, onClose, mixpanelConfig, closeOnError }: Pro
onClose={ onModalClose }
isAuth={ currentStep.isAuth }
profile={ currentStep.profile }
rewardsToken={ currentStep.rewardsToken }
/>
);
}
......
......@@ -15,14 +15,15 @@ interface Props {
onError: (isAuth?: boolean) => void;
isAuth?: boolean;
source?: mixpanel.EventPayload<mixpanel.EventTypes.WALLET_CONNECT>['Source'];
loginToRewards?: boolean;
}
const AuthModalScreenConnectWallet = ({ onSuccess, onError, isAuth, source }: Props) => {
const AuthModalScreenConnectWallet = ({ onSuccess, onError, isAuth, source, loginToRewards }: Props) => {
const isStartedRef = React.useRef(false);
const recaptcha = useReCaptcha();
const handleSignInSuccess = React.useCallback(({ address, profile }: { address: string; profile: UserInfo }) => {
onSuccess({ type: 'success_wallet', address, isAuth, profile });
const handleSignInSuccess = React.useCallback(({ address, profile, rewardsToken }: { address: string; profile: UserInfo; rewardsToken?: string }) => {
onSuccess({ type: 'success_wallet', address, isAuth, profile, rewardsToken });
}, [ onSuccess, isAuth ]);
const handleSignInError = React.useCallback(() => {
......@@ -35,6 +36,7 @@ const AuthModalScreenConnectWallet = ({ onSuccess, onError, isAuth, source }: Pr
source,
isAuth,
executeRecaptchaAsync: recaptcha.executeAsync,
loginToRewards,
});
React.useEffect(() => {
......
......@@ -24,7 +24,7 @@ const AuthModalScreenSelectMethod = ({ onSelectMethod }: Props) => {
Action: 'Wallet',
Source: 'Options selector',
});
onSelectMethod({ type: 'connect_wallet' });
onSelectMethod({ type: 'connect_wallet', loginToRewards: true });
}, [ onSelectMethod ]);
return (
......
......@@ -16,7 +16,7 @@ interface Props {
const AuthModalScreenSuccessEmail = ({ email, onConnectWallet, onClose, isAuth, profile }: Props) => {
const handleConnectWalletClick = React.useCallback(() => {
onConnectWallet({ type: 'connect_wallet', isAuth: true });
onConnectWallet({ type: 'connect_wallet', isAuth: true, loginToRewards: true });
}, [ onConnectWallet ]);
if (isAuth) {
......
......@@ -14,9 +14,10 @@ interface Props {
onClose: () => void;
isAuth?: boolean;
profile: UserInfo | undefined;
rewardsToken?: string;
}
const AuthModalScreenSuccessWallet = ({ address, onAddEmail, onClose, isAuth, profile }: Props) => {
const AuthModalScreenSuccessWallet = ({ address, onAddEmail, onClose, isAuth, profile, rewardsToken }: Props) => {
const handleAddEmailClick = React.useCallback(() => {
onAddEmail({ type: 'email', isAuth: true });
}, [ onAddEmail ]);
......@@ -45,7 +46,8 @@ const AuthModalScreenSuccessWallet = ({ address, onAddEmail, onClose, isAuth, pr
<Text>
Wallet{ ' ' }
<chakra.span fontWeight="700">{ shortenString(address) }</chakra.span>{ ' ' }
has been successfully used to log in to your Blockscout account.
has been successfully used to log in to your Blockscout account
{ Boolean(rewardsToken) && ` and Merits Program` }.
</Text>
{ !profile?.email ? (
<>
......
......@@ -10,12 +10,14 @@ export type ScreenSuccess = {
address: string;
profile: UserInfo;
isAuth?: boolean;
rewardsToken?: string;
};
export type Screen = {
type: 'select_method';
} | {
type: 'connect_wallet';
isAuth?: boolean;
loginToRewards?: boolean;
} | {
type: 'email';
isAuth?: boolean;
......
......@@ -2,9 +2,12 @@ import React from 'react';
import { useSignMessage } from 'wagmi';
import type { UserInfo } from 'types/api/account';
import type { RewardsCheckUserResponse, RewardsConfigResponse, RewardsLoginResponse, RewardsNonceResponse } from 'types/api/rewards';
import config from 'configs/app';
import useApiFetch from 'lib/api/useApiFetch';
import { YEAR } from 'lib/consts';
import * as cookies from 'lib/cookies';
import getErrorMessage from 'lib/errors/getErrorMessage';
import getErrorObj from 'lib/errors/getErrorObj';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
......@@ -12,15 +15,38 @@ import useToast from 'lib/hooks/useToast';
import type * as mixpanel from 'lib/mixpanel';
import useWeb3Wallet from 'lib/web3/useWallet';
function composeMessage(address: string, nonceBlockscout: string, nonceRewards: string) {
const feature = config.features.rewards;
const urlObj = window.location.hostname === 'localhost' && feature.isEnabled ?
new URL(feature.api.endpoint) :
window.location;
return [
`${ urlObj.hostname } wants you to sign in with your Ethereum account:`,
address,
'',
`Sign in/up to Blockscout Account V2 & Blockscout Merits program. Merits nonce: ${ nonceRewards }.`,
'',
`URI: ${ urlObj.origin }`,
'Version: 1',
`Chain ID: ${ config.chain.id }`,
`Nonce: ${ nonceBlockscout }`,
`Issued At: ${ new Date().toISOString() }`,
`Expiration Time: ${ new Date(Date.now() + YEAR).toISOString() }`,
].join('\n');
}
interface Props {
onSuccess?: ({ address, profile }: { address: string; profile: UserInfo }) => void;
onSuccess?: ({ address, profile, rewardsToken }: { address: string; profile: UserInfo; rewardsToken?: string }) => void;
onError?: () => void;
source?: mixpanel.EventPayload<mixpanel.EventTypes.WALLET_CONNECT>['Source'];
isAuth?: boolean;
loginToRewards?: boolean;
executeRecaptchaAsync: () => Promise<string | null>;
}
function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, executeRecaptchaAsync }: Props) {
function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, loginToRewards, executeRecaptchaAsync }: Props) {
const [ isPending, setIsPending ] = React.useState(false);
const isConnectingWalletRef = React.useRef(false);
......@@ -29,27 +55,83 @@ function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, exe
const web3Wallet = useWeb3Wallet({ source });
const { signMessageAsync } = useSignMessage();
const getSiweMessage = React.useCallback(async(address: string) => {
try {
if (!loginToRewards) {
throw new Error('Login to rewards is not enabled');
}
if (cookies.get(cookies.NAMES.REWARDS_API_TOKEN)) {
throw new Error('User already has logged in to rewards');
}
const rewardsConfig = await apiFetch('rewards_config') as RewardsConfigResponse;
if (!rewardsConfig.auth.shared_siwe_login) {
throw new Error('Shared SIWE login is not enabled');
}
const rewardsCheckUser = await apiFetch('rewards_check_user', { pathParams: { address } }) as RewardsCheckUserResponse;
if (!rewardsCheckUser.exists) {
throw new Error('Rewards user does not exist');
}
const nonceConfig = await apiFetch(
'rewards_nonce',
{ queryParams: { blockscout_login_address: address, blockscout_login_chain_id: config.chain.id } },
) as RewardsNonceResponse;
if (!nonceConfig.merits_login_nonce || !nonceConfig.nonce) {
throw new Error('Cannot get merits login nonce');
}
return {
message: composeMessage(address, nonceConfig.nonce, nonceConfig.merits_login_nonce),
authNonce: nonceConfig.nonce,
rewardsNonce: nonceConfig.merits_login_nonce,
type: 'shared',
};
} catch (error) {
const response = await apiFetch('auth_siwe_message', { queryParams: { address } }) as { siwe_message: string };
return {
message: response.siwe_message,
type: 'single',
};
}
}, [ apiFetch, loginToRewards ]);
const proceedToAuth = React.useCallback(async(address: string) => {
try {
const siweMessage = await apiFetch('auth_siwe_message', { queryParams: { address } }) as { siwe_message: string };
const signature = await signMessageAsync({ message: siweMessage.siwe_message });
const siweMessage = await getSiweMessage(address);
const signature = await signMessageAsync({ message: siweMessage.message });
const recaptchaToken = await executeRecaptchaAsync();
if (!recaptchaToken) {
throw new Error('ReCaptcha is not solved');
}
const resource = isAuth ? 'auth_link_address' : 'auth_siwe_verify';
const response = await apiFetch<typeof resource, UserInfo, unknown>(resource, {
const authResource = isAuth ? 'auth_link_address' : 'auth_siwe_verify';
const authResponse = await apiFetch<typeof authResource, UserInfo, unknown>(authResource, {
fetchParams: {
method: 'POST',
body: { message: siweMessage.siwe_message, signature, recaptcha_response: recaptchaToken },
body: { message: siweMessage.message, signature, recaptcha_response: recaptchaToken },
},
});
if (!('name' in response)) {
const rewardsLoginResponse = siweMessage.type === 'shared' ?
await apiFetch('rewards_login', {
fetchParams: {
method: 'POST',
body: {
nonce: siweMessage.authNonce,
message: siweMessage.message,
signature,
},
},
}) as RewardsLoginResponse : undefined;
if (!('name' in authResponse)) {
throw Error('Something went wrong');
}
onSuccess?.({ address, profile: response });
onSuccess?.({ address, profile: authResponse, rewardsToken: rewardsLoginResponse?.token });
} catch (error) {
const errorObj = getErrorObj(error);
const apiErrorMessage = getErrorObjPayload<{ message: string }>(error)?.message;
......@@ -63,7 +145,7 @@ function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, exe
} finally {
setIsPending(false);
}
}, [ apiFetch, isAuth, onError, onSuccess, signMessageAsync, toast, executeRecaptchaAsync ]);
}, [ getSiweMessage, signMessageAsync, executeRecaptchaAsync, isAuth, apiFetch, onSuccess, onError, toast ]);
const start = React.useCallback(() => {
setIsPending(true);
......
......@@ -41,7 +41,7 @@ const UserProfileDesktop = ({ buttonSize, buttonVariant = 'header' }: Props) =>
}
if (router.pathname === '/apps/[id]' && config.features.blockchainInteraction.isEnabled) {
setAuthInitialScreen({ type: 'connect_wallet' });
setAuthInitialScreen({ type: 'connect_wallet', loginToRewards: true });
}
authModal.onOpen();
......@@ -53,7 +53,7 @@ const UserProfileDesktop = ({ buttonSize, buttonVariant = 'header' }: Props) =>
}, [ authModal ]);
const handleAddAddressClick = React.useCallback(() => {
setAuthInitialScreen({ type: 'connect_wallet', isAuth: true });
setAuthInitialScreen({ type: 'connect_wallet', isAuth: true, loginToRewards: true });
authModal.onOpen();
}, [ authModal ]);
......
......@@ -35,7 +35,7 @@ const UserProfileMobile = () => {
}
if (router.pathname === '/apps/[id]' && config.features.blockchainInteraction.isEnabled) {
setAuthInitialScreen({ type: 'connect_wallet' });
setAuthInitialScreen({ type: 'connect_wallet', loginToRewards: true });
}
authModal.onOpen();
......@@ -47,7 +47,7 @@ const UserProfileMobile = () => {
}, [ authModal ]);
const handleAddAddressClick = React.useCallback(() => {
setAuthInitialScreen({ type: 'connect_wallet', isAuth: true });
setAuthInitialScreen({ type: 'connect_wallet', isAuth: true, loginToRewards: true });
authModal.onOpen();
}, [ authModal ]);
......
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