From e6455e92ae86ed0f94035784fb0cefcb83ec27e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Thu, 27 Apr 2023 11:27:31 +0100 Subject: [PATCH] [MMI] custody page component (#18688) --- app/_locales/en/messages.json | 30 + .../__snapshots__/jwt-url-form.test.js.snap | 1 - .../jwt-url-form/jwt-url-form.js | 2 - .../jwt-url-form/jwt-url-form.scss | 1 + ui/helpers/constants/routes.ts | 10 +- .../__snapshots__/account-list.test.js.snap | 8 +- .../connect-custody/account-list.js | 24 +- .../connect-custody/account-list.stories.js | 0 .../connect-custody/account-list.test.js | 0 .../institutional/connect-custody/index.js | 0 .../institutional/connect-custody/index.scss | 0 .../__snapshots__/custody.test.js.snap | 174 ++++++ ui/pages/institutional/custody/custody.js | 591 ++++++++++++++++++ .../institutional/custody/custody.stories.js | 52 ++ .../institutional/custody/custody.test.js | 113 ++++ ui/pages/institutional/custody/index.js | 1 + ui/pages/institutional/custody/index.scss | 14 + ui/pages/pages.scss | 6 +- ui/selectors/institutional/selectors.js | 2 +- 19 files changed, 1004 insertions(+), 25 deletions(-) rename ui/pages/{create-account => }/institutional/connect-custody/__snapshots__/account-list.test.js.snap (95%) rename ui/pages/{create-account => }/institutional/connect-custody/account-list.js (90%) rename ui/pages/{create-account => }/institutional/connect-custody/account-list.stories.js (100%) rename ui/pages/{create-account => }/institutional/connect-custody/account-list.test.js (100%) rename ui/pages/{create-account => }/institutional/connect-custody/index.js (100%) rename ui/pages/{create-account => }/institutional/connect-custody/index.scss (100%) create mode 100644 ui/pages/institutional/custody/__snapshots__/custody.test.js.snap create mode 100644 ui/pages/institutional/custody/custody.js create mode 100644 ui/pages/institutional/custody/custody.stories.js create mode 100644 ui/pages/institutional/custody/custody.test.js create mode 100644 ui/pages/institutional/custody/index.js create mode 100644 ui/pages/institutional/custody/index.scss diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 9a4cec433..def53a578 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -333,6 +333,12 @@ "alerts": { "message": "Alerts" }, + "allCustodianAccountsConnectedSubtitle": { + "message": "You have either already connected all your custodian accounts or don’t have any account to connect to MetaMask Institutional." + }, + "allCustodianAccountsConnectedTitle": { + "message": "No accounts available to connect" + }, "allOfYour": { "message": "All of your $1", "description": "$1 is the symbol or name of the token that the user is approving spending" @@ -724,6 +730,12 @@ "connectAccountOrCreate": { "message": "Connect account or create new" }, + "connectCustodialAccountMsg": { + "message": "Please choose the custodian you want to connect in order to add or refresh a token." + }, + "connectCustodialAccountTitle": { + "message": "Custodial Accounts" + }, "connectHardwareWallet": { "message": "Connect hardware wallet" }, @@ -950,6 +962,9 @@ "custodianReplaceRefreshTokenTitle": { "message": "Replace custodian token" }, + "custodyApiUrl": { + "message": "$1 API URL" + }, "custodyDeeplinkDescription": { "message": "Approve the transaction in the $1 app. Once all required custody approvals have been performed the transaction will complete. Check your $1 app for status." }, @@ -1388,6 +1403,9 @@ "enterANumber": { "message": "Enter a number" }, + "enterCustodianToken": { + "message": "Enter your $1 token or add a new token" + }, "enterMaxSpendLimit": { "message": "Enter max spend limit" }, @@ -2909,6 +2927,9 @@ "passwordsDontMatch": { "message": "Passwords don't match" }, + "pasteJWTToken": { + "message": "Paste or drop your token here:" + }, "pastePrivateKey": { "message": "Enter your private key string here:", "description": "For importing an account from a private key" @@ -3519,18 +3540,27 @@ "seedPhraseWriteDownHeader": { "message": "Write down your Secret Recovery Phrase" }, + "select": { + "message": "Select" + }, "selectAccounts": { "message": "Select the account(s) to use on this site" }, "selectAll": { "message": "Select all" }, + "selectAllAccounts": { + "message": "Select all accounts" + }, "selectAnAccount": { "message": "Select an account" }, "selectAnAccountAlreadyConnected": { "message": "This account has already been connected to MetaMask" }, + "selectAnAccountHelp": { + "message": "Select the custodian accounts to use in MetaMask Institutional." + }, "selectHdPath": { "message": "Select HD path" }, diff --git a/ui/components/institutional/jwt-url-form/__snapshots__/jwt-url-form.test.js.snap b/ui/components/institutional/jwt-url-form/__snapshots__/jwt-url-form.test.js.snap index cdf419d89..c84e7439d 100644 --- a/ui/components/institutional/jwt-url-form/__snapshots__/jwt-url-form.test.js.snap +++ b/ui/components/institutional/jwt-url-form/__snapshots__/jwt-url-form.test.js.snap @@ -17,7 +17,6 @@ exports[`JwtUrlForm shows JWT text area when no jwt token exists 1`] = ` input text

+ + +
+

+ API URL +

+
+ +
+
+ +
+ + +
+ + + +`; + +exports[`CustodyPage renders CustodyPage 3`] = `
`; diff --git a/ui/pages/institutional/custody/custody.js b/ui/pages/institutional/custody/custody.js new file mode 100644 index 000000000..a9ac39ad8 --- /dev/null +++ b/ui/pages/institutional/custody/custody.js @@ -0,0 +1,591 @@ +import React, { + useState, + useMemo, + useCallback, + useEffect, + useContext, +} from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { v4 as uuidv4 } from 'uuid'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { mmiActionsFactory } from '../../../store/institutional/institution-background'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; +import { + ButtonIcon, + Button, + Text, + Label, + IconName, + IconSize, + BUTTON_SIZES, + BUTTON_VARIANT, +} from '../../../components/component-library'; +import { + AlignItems, + DISPLAY, + FLEX_DIRECTION, + FONT_WEIGHT, + Color, + JustifyContent, + BorderRadius, + BorderColor, + BLOCK_SIZES, + TextColor, + TEXT_ALIGN, + TextVariant, +} from '../../../helpers/constants/design-system'; +import Box from '../../../components/ui/box'; +import { + CUSTODY_ACCOUNT_DONE_ROUTE, + DEFAULT_ROUTE, +} from '../../../helpers/constants/routes'; +import { getCurrentChainId, getProvider } from '../../../selectors'; +import { getMMIConfiguration } from '../../../selectors/institutional/selectors'; +import CustodyAccountList from '../connect-custody/account-list'; +import JwtUrlForm from '../../../components/institutional/jwt-url-form'; + +const CustodyPage = () => { + const t = useI18nContext(); + const history = useHistory(); + const trackEvent = useContext(MetaMetricsContext); + const dispatch = useDispatch(); + + const mmiActions = mmiActionsFactory(); + const currentChainId = useSelector(getCurrentChainId); + const provider = useSelector(getProvider); + const { custodians } = useSelector(getMMIConfiguration); + + const [selectedAccounts, setSelectedAccounts] = useState({}); + const [selectedCustodianName, setSelectedCustodianName] = useState(''); + const [selectedCustodianImage, setSelectedCustodianImage] = useState(null); + const [selectedCustodianDisplayName, setSelectedCustodianDisplayName] = + useState(''); + const [selectedCustodianType, setSelectedCustodianType] = useState(''); + const [connectError, setConnectError] = useState(''); + const [currentJwt, setCurrentJwt] = useState(''); + const [selectError, setSelectError] = useState(''); + const [jwtList, setJwtList] = useState([]); + const [apiUrl, setApiUrl] = useState(''); + const [addNewTokenClicked, setAddNewTokenClicked] = useState(false); + const [chainId, setChainId] = useState(0); + const [connectRequest, setConnectRequest] = useState(undefined); + const [accounts, setAccounts] = useState(); + + const custodianButtons = useMemo(() => { + const custodianItems = []; + custodians.forEach((custodian) => { + if ( + (!custodian.production && + process.env.METAMASK_ENVIRONMENT === 'production') || + custodian.hidden || + (connectRequest && + Object.keys(connectRequest).length && + custodian.name !== selectedCustodianName) + ) { + return; + } + + custodianItems.push( + + + {custodian.iconUrl && ( + {custodian.displayName} + )} + {custodian.displayName} + + + + , + ); + }); + + return custodianItems; + }, [connectRequest, custodians, dispatch, selectedCustodianName]); + + const handleConnectError = useCallback( + (e) => { + let errorMessage; + const detailedError = e.message.split(':'); + + if (detailedError.length > 1 && !isNaN(parseInt(detailedError[0], 10))) { + if (parseInt(detailedError[0], 10) === 401) { + // Authentication Error + errorMessage = + 'Authentication error. Please ensure you have entered the correct token'; + } + } + + if (/Network Error/u.test(e.message)) { + errorMessage = + 'Network error. Please ensure you have entered the correct API URL'; + } + + if (!errorMessage) { + errorMessage = e.message; + } + + setConnectError( + `Something went wrong connecting your custodian account. Error details: ${errorMessage}`, + ); + trackEvent({ + category: 'MMI', + event: 'Connect to custodian error', + properties: { + custodian: selectedCustodianName, + }, + }); + }, + [selectedCustodianName, trackEvent], + ); + + const getCustodianAccounts = useCallback( + async (token, custody, getNonImportedAccounts) => { + return await dispatch( + mmiActions.getCustodianAccounts( + token, + apiUrl, + custody || selectedCustodianType, + getNonImportedAccounts, + ), + ); + }, + [dispatch, mmiActions, apiUrl, selectedCustodianType], + ); + + const connect = useCallback(async () => { + try { + // If you have one JWT already, but no dropdown yet, currentJwt is null! + const jwt = currentJwt || jwtList[0]; + setConnectError(''); + const accountsValue = await getCustodianAccounts( + jwt, + apiUrl, + selectedCustodianType, + true, + ); + setAccounts(accountsValue); + trackEvent({ + category: 'MMI', + event: 'Connect to custodian', + properties: { + custodian: selectedCustodianName, + apiUrl, + rpc: Boolean(connectRequest), + }, + }); + } catch (e) { + handleConnectError(e); + } + }, [ + apiUrl, + connectRequest, + currentJwt, + getCustodianAccounts, + handleConnectError, + jwtList, + selectedCustodianName, + selectedCustodianType, + trackEvent, + ]); + + useEffect(() => { + const fetchConnectRequest = async () => { + const connectRequestValue = await dispatch( + mmiActions.getCustodianConnectRequest(), + ); + setChainId(parseInt(currentChainId, 16)); + + // check if it's empty object + if (Object.keys(connectRequestValue).length) { + setConnectRequest(connectRequestValue); + setCurrentJwt( + connectRequestValue.token || + (await dispatch(mmiActions.getCustodianToken())), + ); + setSelectedCustodianType(connectRequestValue.custodianType); + setSelectedCustodianName(connectRequestValue.custodianName); + setApiUrl(connectRequestValue.apiUrl); + connect(); + } + }; + + // call the function + fetchConnectRequest() + // make sure to catch any error + .catch(console.error); + }, [dispatch, connect, currentChainId, mmiActions]); + + useEffect(() => { + const handleNetworkChange = async () => { + if (!isNaN(chainId)) { + const jwt = currentJwt || jwtList[0]; + + if (jwt && jwt.length) { + setAccounts( + await getCustodianAccounts( + jwt, + apiUrl, + selectedCustodianType, + true, + ), + ); + } + } + }; + + if (parseInt(chainId, 16) !== chainId) { + setChainId(parseInt(currentChainId, 16)); + handleNetworkChange(); + } + }, [ + getCustodianAccounts, + apiUrl, + currentJwt, + jwtList, + selectedCustodianType, + currentChainId, + chainId, + ]); + + const cancelConnectCustodianToken = () => { + setSelectedCustodianName(''); + setSelectedCustodianType(''); + setSelectedCustodianImage(null); + setSelectedCustodianDisplayName(''); + setApiUrl(''); + setCurrentJwt(''); + setConnectError(''); + setSelectError(''); + }; + + const setSelectAllAccounts = (e) => { + const allAccounts = {}; + + if (e.currentTarget.checked) { + accounts.forEach((account) => { + allAccounts[account.address] = { + name: account.name, + custodianDetails: account.custodianDetails, + labels: account.labels, + token: currentJwt, + apiUrl, + chainId: account.chainId, + custodyType: selectedCustodianType, + custodyName: selectedCustodianName, + }; + }); + setSelectedAccounts(allAccounts); + } else { + setSelectedAccounts({}); + } + }; + + return ( + + {connectError && ( + + {connectError} + + )} + + {selectError && ( + + {selectError} + + )} + + {!accounts && !selectedCustodianType ? ( + + history.push(DEFAULT_ROUTE)} + display={DISPLAY.FLEX} + /> + + {t('connectCustodialAccountTitle')} + + + {t('connectCustodialAccountMsg')} + + +
    {custodianButtons}
+
+
+ ) : null} + + {!accounts && selectedCustodianType && ( + <> + + cancelConnectCustodianToken()} + display={[DISPLAY.FLEX]} + /> + + + {selectedCustodianImage && ( + {selectedCustodianDisplayName} + )} + {selectedCustodianDisplayName} + + + + {t('enterCustodianToken', [selectedCustodianDisplayName])} + + + + setCurrentJwt(jwt)} + jwtInputText={t('pasteJWTToken')} + apiUrl={apiUrl} + urlInputText={t('custodyApiUrl', [selectedCustodianDisplayName])} + onUrlChange={(url) => setApiUrl(url)} + /> + + + + + + + )} + + {accounts && accounts.length > 0 && ( + <> + + {t('selectAnAccount')} + + {t('selectAnAccountHelp')} + + + + setSelectAllAccounts(e)} + checked={Object.keys(selectedAccounts).length === accounts.length} + /> + + + { + if (selectedAccounts[account.address]) { + delete selectedAccounts[account.address]; + } else { + selectedAccounts[account.address] = { + name: account.name, + custodianDetails: account.custodianDetails, + labels: account.labels, + token: currentJwt, + apiUrl, + chainId: account.chainId, + custodyType: selectedCustodianType, + custodyName: selectedCustodianName, + }; + } + + setSelectedAccounts(selectedAccounts); + }} + provider={provider} + selectedAccounts={selectedAccounts} + onAddAccounts={async () => { + try { + await dispatch( + mmiActions.connectCustodyAddresses( + selectedCustodianType, + selectedCustodianName, + selectedAccounts, + ), + ); + const selectedCustodian = custodians.find( + (custodian) => custodian.name === selectedCustodianName, + ); + history.push({ + pathname: CUSTODY_ACCOUNT_DONE_ROUTE, + state: { + imgSrc: selectedCustodian.iconUrl, + title: t('custodianAccountAddedTitle'), + description: t('custodianAccountAddedDesc'), + }, + }); + trackEvent({ + category: 'MMI', + event: 'Custodial accounts connected', + properties: { + custodian: selectedCustodianName, + numberOfAccounts: Object.keys(selectedAccounts).length, + chainId, + }, + }); + } catch (e) { + setSelectError(e.message); + } + }} + onCancel={() => { + setAccounts(null); + setSelectedCustodianName(null); + setSelectedCustodianType(null); + setSelectedAccounts({}); + setCurrentJwt(''); + setApiUrl(''); + setAddNewTokenClicked(false); + + if (Object.keys(connectRequest).length) { + history.push(DEFAULT_ROUTE); + } + + trackEvent({ + category: 'MMI', + event: 'Connect to custodian cancel', + properties: { + custodian: selectedCustodianName, + numberOfAccounts: Object.keys(selectedAccounts).length, + chainId, + }, + }); + }} + /> + + )} + + {accounts && accounts.length === 0 && ( + + + {t('allCustodianAccountsConnectedTitle')} + + + {t('allCustodianAccountsConnectedSubtitle')} + + + + + + + )} +
+ ); +}; + +export default CustodyPage; diff --git a/ui/pages/institutional/custody/custody.stories.js b/ui/pages/institutional/custody/custody.stories.js new file mode 100644 index 000000000..1b34abc27 --- /dev/null +++ b/ui/pages/institutional/custody/custody.stories.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import configureStore from '../../../store/store'; +import testData from '../../../../.storybook/test-data'; +import CustodyPage from '.'; + +const customData = { + ...testData, + metamask: { + ...testData.metamask, + mmiConfiguration: { + portfolio: { + enabled: true, + url: 'https://portfolio.io', + }, + custodians: [ + { + type: 'Saturn', + name: 'saturn', + apiUrl: 'https://saturn-custody.dev.metamask-institutional.io', + iconUrl: + 'https://saturn-custody-ui.dev.metamask-institutional.io/saturn.svg', + displayName: 'Saturn Custody', + production: true, + refreshTokenUrl: null, + isNoteToTraderSupported: false, + version: 1, + }, + ], + }, + }, +}; + +const store = configureStore(customData); + +export default { + title: 'Pages/Institutional/CustodyPage', + decorators: [(story) => {story()}], + component: CustodyPage, + argTypes: { + onClick: { + action: 'onClick', + }, + onChange: { + action: 'onChange', + }, + }, +}; + +export const DefaultStory = (args) => ; + +DefaultStory.storyName = 'CustodyPage'; diff --git a/ui/pages/institutional/custody/custody.test.js b/ui/pages/institutional/custody/custody.test.js new file mode 100644 index 000000000..b9381e309 --- /dev/null +++ b/ui/pages/institutional/custody/custody.test.js @@ -0,0 +1,113 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { fireEvent, waitFor, screen } from '@testing-library/react'; +import thunk from 'redux-thunk'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import CustodyPage from '.'; + +const mockedReturnedValue = jest.fn().mockReturnValue({ type: 'TYPE' }); +const mockedGetCustodianJWTList = jest.fn().mockReturnValue({ type: 'TYPE' }); + +const mockedGetCustodianAccounts = jest.fn().mockReturnValue(async () => null); +const mockedGetCustodianToken = jest.fn().mockReturnValue('testJWT'); + +const mockedGetCustodianConnectRequest = jest.fn().mockReturnValue({ + type: 'TYPE', + custodian: 'saturn', + token: 'token', + apiUrl: 'url', + custodianType: 'JSON-RPC', + custodianName: 'Saturn', +}); + +jest.mock('../../../store/institutional/institution-background', () => ({ + mmiActionsFactory: () => ({ + getCustodianConnectRequest: mockedGetCustodianConnectRequest, + getCustodianToken: mockedGetCustodianToken, + getCustodianAccounts: mockedGetCustodianAccounts, + getCustodianAccountsByAddress: mockedReturnedValue, + getCustodianJWTList: mockedGetCustodianJWTList, + connectCustodyAddresses: mockedReturnedValue, + }), +})); + +describe('CustodyPage', function () { + const mockStore = { + metamask: { + provider: { chainId: 0x1, type: 'test' }, + mmiConfiguration: { + portfolio: { + enabled: true, + url: 'https://portfolio.io', + }, + custodians: [ + { + type: 'Saturn', + name: 'saturn', + apiUrl: 'https://saturn-custody.dev.metamask-institutional.io', + iconUrl: + 'https://saturn-custody-ui.dev.metamask-institutional.io/saturn.svg', + displayName: 'Saturn Custody', + production: true, + refreshTokenUrl: null, + isNoteToTraderSupported: false, + version: 1, + }, + ], + }, + preferences: { + useNativeCurrencyAsPrimaryCurrency: true, + }, + appState: { + isLoading: false, + }, + history: { + mostRecentOverviewPage: '/', + }, + }, + }; + + const store = configureMockStore([thunk])(mockStore); + + it('renders CustodyPage', async () => { + const { container } = renderWithProvider(, store); + + await waitFor(() => { + expect(container).toMatchSnapshot(); + }); + }); + + it('opens connect custody without any custody selected', async () => { + const { getByTestId } = renderWithProvider(, store); + + await waitFor(() => { + expect(getByTestId('custody-connect-button')).toBeDefined(); + }); + }); + + it('calls getCustodianJwtList on custody select when connect btn is click', async () => { + const { getByTestId } = renderWithProvider(, store); + + const custodyBtn = getByTestId('custody-connect-button'); + await waitFor(() => { + fireEvent.click(custodyBtn); + }); + + await waitFor(() => { + expect(mockedGetCustodianJWTList).toHaveBeenCalled(); + }); + }); + + it('clicks connect button and shows the jwt form', async () => { + const { getByTestId } = renderWithProvider(, store); + const custodyBtn = getByTestId('custody-connect-button'); + + await waitFor(() => { + fireEvent.click(custodyBtn); + }); + + await waitFor(() => { + expect(screen.getByTestId('jwt-form-connect-button')).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/pages/institutional/custody/index.js b/ui/pages/institutional/custody/index.js new file mode 100644 index 000000000..a53ef30e0 --- /dev/null +++ b/ui/pages/institutional/custody/index.js @@ -0,0 +1 @@ +export { default } from './custody'; diff --git a/ui/pages/institutional/custody/index.scss b/ui/pages/institutional/custody/index.scss new file mode 100644 index 000000000..d2f7ef0fa --- /dev/null +++ b/ui/pages/institutional/custody/index.scss @@ -0,0 +1,14 @@ +@import '../../../components/institutional/jwt-dropdown/jwt-dropdown.scss'; +@import '../../../components/institutional/jwt-url-form/jwt-url-form.scss'; + +.custody-accounts-empty { + min-height: 300px; + + &__footer { + border-top: 1px solid var(--color-border-muted); + position: absolute; + width: 100%; + bottom: 0; + left: 0; + } +} diff --git a/ui/pages/pages.scss b/ui/pages/pages.scss index 9d926ccd9..26b50cbfb 100644 --- a/ui/pages/pages.scss +++ b/ui/pages/pages.scss @@ -13,7 +13,11 @@ @import 'connected-sites/index'; @import 'create-account/index'; ///: BEGIN:ONLY_INCLUDE_IN(build-mmi) -@import "create-account/institutional/connect-custody/index"; +@import "institutional/connect-custody/index"; +@import "institutional/custody/index"; +@import "institutional/institutional-entity-done-page/index"; +@import "institutional/compliance-feature-page/index"; +@import "institutional/confirm-add-custodian-token/index"; @import "institutional/interactive-replacement-token-page/index"; ///: END:ONLY_INCLUDE_IN @import 'error/index'; diff --git a/ui/selectors/institutional/selectors.js b/ui/selectors/institutional/selectors.js index b45786188..d8dc5c7d1 100644 --- a/ui/selectors/institutional/selectors.js +++ b/ui/selectors/institutional/selectors.js @@ -71,7 +71,7 @@ export function getMMIAddressFromModalOrAddress(state) { } export function getMMIConfiguration(state) { - return state.metamask.mmiConfiguration; + return state.metamask.mmiConfiguration || []; } export function getInteractiveReplacementToken(state) {