diff --git a/src/components/messenger/user-profile/account-management-panel/container.test.tsx b/src/components/messenger/user-profile/account-management-panel/container.test.tsx index 5db0c0e2a..365a9792f 100644 --- a/src/components/messenger/user-profile/account-management-panel/container.test.tsx +++ b/src/components/messenger/user-profile/account-management-panel/container.test.tsx @@ -1,5 +1,5 @@ import { Container } from './container'; -import { Errors, AccountManagementState } from '../../../../store/account-management'; +import { Errors, AccountManagementState, State } from '../../../../store/account-management'; import { RootState } from '../../../../store/reducer'; import { ConnectionStatus } from '../../../../lib/web3'; @@ -57,6 +57,14 @@ describe('Container', () => { }); }); + it('addWalletState', () => { + const props = subject({ + accountManagement: { state: State.NONE } as any, + }); + + expect(props.addWalletState).toEqual(State.NONE); + }); + describe('errors', () => { test('wallets error: unknown error', () => { const props = subject({ diff --git a/src/components/messenger/user-profile/account-management-panel/container.tsx b/src/components/messenger/user-profile/account-management-panel/container.tsx index cfd0b4c94..4b974ba6e 100644 --- a/src/components/messenger/user-profile/account-management-panel/container.tsx +++ b/src/components/messenger/user-profile/account-management-panel/container.tsx @@ -4,7 +4,14 @@ import { RootState } from '../../../../store/reducer'; import { connectContainer } from '../../../../store/redux-container'; import { AccountManagementPanel } from './index'; -import { openAddEmailAccountModal, closeAddEmailAccountModal, Errors } from '../../../../store/account-management'; +import { + openAddEmailAccountModal, + closeAddEmailAccountModal, + reset, + Errors, + addNewWallet, + State, +} from '../../../../store/account-management'; import { currentUserSelector } from '../../../../store/authentication/selectors'; import { ConnectionStatus } from '../../../../lib/web3'; @@ -20,9 +27,12 @@ export interface Properties extends PublicProperties { canAddEmail: boolean; isWalletConnected: boolean; connectedWallet: string; + addWalletState: State; openAddEmailAccountModal: () => void; closeAddEmailAccountModal: () => void; + addNewWallet: () => void; + onReset: () => void; } export class Container extends React.Component { @@ -41,6 +51,7 @@ export class Container extends React.Component { isAddEmailModalOpen: accountManagement.isAddEmailAccountModalOpen, isWalletConnected: status === ConnectionStatus.Connected, connectedWallet: value?.address, + addWalletState: accountManagement.state, currentUser: { userId: currentUser?.id, firstName: currentUser?.profileSummary.firstName, @@ -57,6 +68,8 @@ export class Container extends React.Component { return { openAddEmailAccountModal, closeAddEmailAccountModal, + addNewWallet, + onReset: reset, }; } @@ -81,9 +94,12 @@ export class Container extends React.Component { canAddEmail={this.props.canAddEmail} isWalletConnected={this.props.isWalletConnected} connectedWallet={this.props.connectedWallet} + addWalletState={this.props.addWalletState} onOpenAddEmailModal={() => this.props.openAddEmailAccountModal()} onCloseAddEmailModal={() => this.props.closeAddEmailAccountModal()} + onAddNewWallet={this.props.addNewWallet} onBack={this.props.onClose} + reset={this.props.onReset} /> ); } diff --git a/src/components/messenger/user-profile/account-management-panel/index.test.tsx b/src/components/messenger/user-profile/account-management-panel/index.test.tsx index f461fff24..06fca4a51 100644 --- a/src/components/messenger/user-profile/account-management-panel/index.test.tsx +++ b/src/components/messenger/user-profile/account-management-panel/index.test.tsx @@ -5,6 +5,7 @@ import { PanelHeader } from '../../list/panel-header'; import { bem } from '../../../../lib/bem'; import { Button } from '@zero-tech/zui/components/Button'; import { ConnectButton } from '@rainbow-me/rainbowkit'; +import { State } from '../../../../store/account-management'; const featureFlags = { enableAddWallets: true }; jest.mock('../../../../lib/feature-flags', () => ({ @@ -30,10 +31,13 @@ describe(AccountManagementPanel, () => { canAddEmail: false, isWalletConnected: false, connectedWallet: '', + addWalletState: State.NONE, onBack: () => {}, onOpenAddEmailModal: () => {}, onCloseAddEmailModal: () => {}, + reset: () => {}, + onAddNewWallet: () => {}, ...props, }; @@ -239,5 +243,56 @@ describe(AccountManagementPanel, () => { (linkWalletModal as any).props().onClose(); expect(wrapper.state('isUserLinkingNewWallet')).toEqual(false); }); + + it('does not render Link Wallet modal if error is set', () => { + const wrapper = subject({ + currentUser: { primaryEmail: 'ratik@zero.tech', wallets: [] }, + isWalletConnected: true, + error: 'An error occurred', + }); + wrapper.setState({ isUserLinkingNewWallet: true }); + + const linkWalletModal = wrapper.find('Modal').at(1); + expect(linkWalletModal.exists()).toEqual(false); + }); + + it('does not render Link Wallet modal if wallet is NOT connected', () => { + const wrapper = subject({ + currentUser: { primaryEmail: 'test@zero.tech', wallets: [] }, + isWalletConnected: false, + }); + wrapper.setState({ isUserLinkingNewWallet: true }); + + const linkWalletModal = wrapper.find('Modal').at(1); + expect(linkWalletModal.exists()).toEqual(false); + }); + + it('calls addNewWallet when user clicks on Link Wallet', () => { + const addNewWallet = jest.fn(); + const wrapper = subject({ + currentUser: { primaryEmail: 'ratik@zero.tech', wallets: [] }, + isWalletConnected: true, + addWalletState: State.NONE, + onAddNewWallet: addNewWallet, + }); + wrapper.setState({ isUserLinkingNewWallet: true }); + + const linkWalletModal = wrapper.find('Modal').at(1); + (linkWalletModal as any).props().onPrimary(); + + expect(addNewWallet).toHaveBeenCalled(); + }); + + it('keeps Link Wallet button in loading state while IN_PROGRESS', () => { + const wrapper = subject({ + currentUser: { primaryEmail: 'ratik@zero.tech', wallets: [] }, + isWalletConnected: true, + addWalletState: State.INPROGRESS, + }); + wrapper.setState({ isUserLinkingNewWallet: true }); + + const linkWalletModal = wrapper.find('Modal').at(1); + expect(linkWalletModal.prop('isProcessing')).toEqual(true); + }); }); }); diff --git a/src/components/messenger/user-profile/account-management-panel/index.tsx b/src/components/messenger/user-profile/account-management-panel/index.tsx index ceac169eb..e6be1dcd1 100644 --- a/src/components/messenger/user-profile/account-management-panel/index.tsx +++ b/src/components/messenger/user-profile/account-management-panel/index.tsx @@ -15,6 +15,7 @@ import { ScrollbarContainer } from '../../../scrollbar-container'; import { ConnectButton } from '@rainbow-me/rainbowkit'; import { Color, Modal, Variant } from '../../../modal'; import { featureFlags } from '../../../../lib/feature-flags'; +import { State as AddWalletState } from '../../../../store/account-management'; const cn = bemClassName('account-management-panel'); @@ -26,10 +27,13 @@ export interface Properties { canAddEmail: boolean; isWalletConnected: boolean; connectedWallet: string; + addWalletState: AddWalletState; onBack: () => void; + reset: () => void; // reset saga state onOpenAddEmailModal: () => void; onCloseAddEmailModal: () => void; + onAddNewWallet: () => void; } interface State { @@ -52,6 +56,7 @@ export class AccountManagementPanel extends React.Component { renderAddNewWalletButton = () => { const handleAddWallet = (account, openConnectModal) => { this.setIsUserLinkingNewWallet(true); + this.props.reset(); if (!account?.address) { // Prompt user to connect their wallet if none is connected @@ -178,10 +183,10 @@ export class AccountManagementPanel extends React.Component { secondaryText='Cancel' secondaryVariant={Variant.Secondary} secondaryColor={Color.Red} - onPrimary={() => {}} + onPrimary={this.props.onAddNewWallet} onSecondary={onClose} onClose={onClose} - isProcessing={false} + isProcessing={this.props.addWalletState === AddWalletState.INPROGRESS} >
You have a wallet connected by the address{' '} @@ -196,6 +201,16 @@ export class AccountManagementPanel extends React.Component { ); }; + get isLinkNewWalletModalOpen() { + const { isWalletConnected, error, addWalletState } = this.props; + return ( + this.state.isUserLinkingNewWallet && + isWalletConnected && + !error && + (addWalletState === AddWalletState.NONE || addWalletState === AddWalletState.INPROGRESS) + ); + } + render() { return (
@@ -223,7 +238,7 @@ export class AccountManagementPanel extends React.Component {
{this.renderAddEmailAccountModal()} - {this.state.isUserLinkingNewWallet && this.props.isWalletConnected && this.renderLinkNewWalletModal()} + {this.isLinkNewWalletModalOpen && this.renderLinkNewWalletModal()}
diff --git a/src/store/account-management/api.ts b/src/store/account-management/api.ts new file mode 100644 index 000000000..a371853f9 --- /dev/null +++ b/src/store/account-management/api.ts @@ -0,0 +1,20 @@ +import { post } from '../../lib/api/rest'; + +export async function linkNewWalletToZEROAccount(token) { + try { + const response = await post('/api/v2/accounts/add-wallet').send({ web3Token: token }); + return { + success: true, + response: response.body, + }; + } catch (error: any) { + if (error?.response?.status === 400) { + return { + success: false, + response: error.response.body.code, + error: error.response.body.message, + }; + } + throw error; + } +} diff --git a/src/store/account-management/index.ts b/src/store/account-management/index.ts index d63a44215..c8f7766bd 100644 --- a/src/store/account-management/index.ts +++ b/src/store/account-management/index.ts @@ -1,20 +1,29 @@ import { createAction, createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { Connectors } from '../../lib/web3'; +//import { Connectors } from '../../lib/web3'; export enum SagaActionTypes { AddNewWallet = 'Wallets/addNewWallet', AddEmailAccount = 'Wallets/addEmailAccount', OpenAddEmailAccountModal = 'Wallets/openAddEmailAccountModal', CloseAddEmailAccountModal = 'Wallets/closeAddEmailAccountModal', + Reset = 'Wallets/reset', } export type AccountManagementState = { + state: State; errors: string[]; isAddEmailAccountModalOpen: boolean; successMessage: string; }; +export enum State { + NONE, + INPROGRESS, + LOADED, +} + export const initialState: AccountManagementState = { + state: State.NONE, errors: [], isAddEmailAccountModalOpen: false, successMessage: '', @@ -24,10 +33,11 @@ export enum Errors { UNKNOWN_ERROR = 'UNKNOWN_ERROR', } -export const addNewWallet = createAction<{ connector: Connectors }>(SagaActionTypes.AddNewWallet); +export const addNewWallet = createAction(SagaActionTypes.AddNewWallet); export const addEmailAccount = createAction<{ email: string; password: string }>(SagaActionTypes.AddEmailAccount); export const openAddEmailAccountModal = createAction(SagaActionTypes.OpenAddEmailAccountModal); export const closeAddEmailAccountModal = createAction(SagaActionTypes.CloseAddEmailAccountModal); +export const reset = createAction(SagaActionTypes.Reset); const slice = createSlice({ name: 'accountManagement', @@ -42,11 +52,14 @@ const slice = createSlice({ ) => { state.isAddEmailAccountModalOpen = action.payload; }, + setState: (state, action: PayloadAction) => { + state.state = action.payload; + }, setSuccessMessage: (state, action: PayloadAction) => { state.successMessage = action.payload; }, }, }); -export const { setErrors, setAddEmailAccountModalStatus, setSuccessMessage } = slice.actions; +export const { setErrors, setAddEmailAccountModalStatus, setSuccessMessage, setState } = slice.actions; export const { reducer } = slice; diff --git a/src/store/account-management/saga.test.ts b/src/store/account-management/saga.test.ts index 4b98ccf85..231f62629 100644 --- a/src/store/account-management/saga.test.ts +++ b/src/store/account-management/saga.test.ts @@ -4,13 +4,18 @@ import { closeAddEmailAccountModal, addEmailToZEROAccount, updateCurrentUserPrimaryEmail, + linkNewWalletToZEROAccount, + reset, } from './saga'; import { call } from 'redux-saga/effects'; -import { setAddEmailAccountModalStatus } from '.'; +import { setAddEmailAccountModalStatus, State } from '.'; import { rootReducer } from '../reducer'; import { addEmailAccount } from '../registration/saga'; import { StoreBuilder } from '../test/store'; +import { getSignedToken } from '../web3/saga'; +import { linkNewWalletToZEROAccount as apiLinkNewWalletToZEROAccount } from './api'; +import { throwError } from 'redux-saga-test-plan/providers'; describe('addEmailAccountModal', () => { it('opens the add email account modal', async () => { @@ -113,3 +118,103 @@ describe(addEmailToZEROAccount, () => { expect(authentication.user.data.profileSummary.primaryEmail).toEqual(null); }); }); + +describe(linkNewWalletToZEROAccount, () => { + it('calls reset initially, and sets error if getSignedToken fails', async () => { + const initialState = new StoreBuilder().build(); + + const { + storeState: { accountManagement }, + } = await expectSaga(linkNewWalletToZEROAccount) + .provide([ + [ + call(getSignedToken), + { success: false, error: 'failed to connect to metamask' }, + ], + ]) + .withReducer(rootReducer, initialState) + .call(reset) + .run(); + + expect(accountManagement.state).toEqual(State.LOADED); + expect(accountManagement.errors).toEqual(['failed to connect to metamask']); + }); + + it('calls apiAddNewWallet with token', async () => { + const initialState = new StoreBuilder().build(); + + await expectSaga(linkNewWalletToZEROAccount) + .provide([ + [call(getSignedToken), { success: true, token: 'some_token' }], + [call(apiLinkNewWalletToZEROAccount, 'some_token'), { success: true, response: { wallet: 'some_wallet' } }], + ]) + .withReducer(rootReducer, initialState) + .call(apiLinkNewWalletToZEROAccount, 'some_token') + .run(); + }); + + it('sets API error if apiAddNewWallet fails', async () => { + const initialState = new StoreBuilder() + .withAccountManagement({ state: State.NONE, errors: ['unknown_error_1'] }) + .build(); + + const { + storeState: { accountManagement }, + } = await expectSaga(linkNewWalletToZEROAccount) + .provide([ + [call(getSignedToken), { success: true, token: 'some_token' }], + [call(apiLinkNewWalletToZEROAccount, 'some_token'), { success: false, error: 'failed to link wallet' }], + ]) + .withReducer(rootReducer, initialState) + .run(); + + expect(accountManagement.state).toEqual(State.LOADED); + expect(accountManagement.errors).toEqual(['failed to link wallet']); + }); + + it('sets unknown error if apiAddNewWallet throws an error', async () => { + const initialState = new StoreBuilder().build(); + + const { + storeState: { accountManagement }, + } = await expectSaga(linkNewWalletToZEROAccount) + .provide([ + [call(getSignedToken), { success: true, token: 'some_token' }], + [call(apiLinkNewWalletToZEROAccount, 'some_token'), throwError(new Error('some error'))], + ]) + .withReducer(rootReducer, initialState) + .run(); + + expect(accountManagement.state).toEqual(State.LOADED); + expect(accountManagement.errors).toEqual(['UNKNOWN_ERROR']); + }); + + it('adds wallet to current user state and puts success message if added successfully', async () => { + const initialState = new StoreBuilder() + .withCurrentUser({ + id: 'user-id', + profileSummary: { primaryEmail: 'test@zero.tech', wallets: [] }, + } as any) + .build(); + + const { + storeState: { + accountManagement, + authentication: { user }, + }, + } = await expectSaga(linkNewWalletToZEROAccount) + .provide([ + [call(getSignedToken), { success: true, token: 'some_token' }], + [ + call(apiLinkNewWalletToZEROAccount, 'some_token'), + { success: true, response: { wallet: { id: 'wallet_id', address: 'some_wallet_address' } } }, + ], + ]) + .withReducer(rootReducer, initialState) + .run(); + + expect(accountManagement.errors).toEqual([]); + expect(accountManagement.successMessage).toEqual('Wallet added successfully'); + expect(user.data.wallets).toStrictEqual([{ id: 'wallet_id', address: 'some_wallet_address' }]); + }); +}); diff --git a/src/store/account-management/saga.ts b/src/store/account-management/saga.ts index 0f9bd6aa7..a2fc06b76 100644 --- a/src/store/account-management/saga.ts +++ b/src/store/account-management/saga.ts @@ -1,28 +1,66 @@ import { call, put, select, spawn, take, takeLeading } from 'redux-saga/effects'; -import { Errors, SagaActionTypes, setAddEmailAccountModalStatus, setErrors, setSuccessMessage } from '.'; +import { + Errors, + SagaActionTypes, + setAddEmailAccountModalStatus, + setErrors, + setState, + setSuccessMessage, + State, +} from '.'; import { addEmailAccount } from '../registration/saga'; import { currentUserSelector } from '../authentication/saga'; import { setUser } from '../authentication'; import cloneDeep from 'lodash/cloneDeep'; import { Events as AuthEvents, getAuthChannel } from '../authentication/channels'; +import { getSignedToken } from '../web3/saga'; +import { linkNewWalletToZEROAccount as apiLinkNewWalletToZEROAccount } from './api'; export function* reset() { + yield put(setState(State.NONE)); yield put(setErrors([])); yield put(setSuccessMessage('')); } -export function* linkNewWalletToZEROAccount(action) { - const { connector } = action.payload; - console.log('Connector: ', connector); +export function* linkNewWalletToZEROAccount() { + yield call(reset); + yield put(setState(State.INPROGRESS)); try { - //yield call(closeWalletSelectModal); + let result = yield call(getSignedToken); + if (!result.success) { + yield put(setErrors([result.error])); + return; + } + + const apiResult = yield call(apiLinkNewWalletToZEROAccount, result.token); + if (apiResult.success) { + // other code + yield call(updateCurrentUserWallets, apiResult.response.wallet); + yield put(setSuccessMessage('Wallet added successfully')); + } else { + yield put(setErrors([apiResult.error])); + return; + } } catch (e) { yield put(setErrors([Errors.UNKNOWN_ERROR])); } finally { + yield put(setState(State.LOADED)); } + + return; +} + +export function* updateCurrentUserWallets(wallet) { + if (!wallet) { + return; + } + + let currentUser = cloneDeep(yield select(currentUserSelector())); + currentUser.wallets = (currentUser.wallets || []).concat(wallet); + yield put(setUser({ data: currentUser })); } export function* updateCurrentUserPrimaryEmail(email) { @@ -73,4 +111,5 @@ export function* saga() { yield takeLeading(SagaActionTypes.OpenAddEmailAccountModal, openAddEmailAccountModal); yield takeLeading(SagaActionTypes.CloseAddEmailAccountModal, closeAddEmailAccountModal); + yield takeLeading(SagaActionTypes.Reset, reset); }