1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 09:52:26 +01:00
metamask-extension/ui/pages/institutional/custody/custody.js

671 lines
21 KiB
JavaScript

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 { isEqual } from 'lodash';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { mmiActionsFactory } from '../../../store/institutional/institution-background';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import {
ButtonIcon,
Button,
Label,
IconName,
IconSize,
BUTTON_SIZES,
BUTTON_VARIANT,
Box,
Text,
} from '../../../components/component-library';
import {
AlignItems,
Display,
FlexDirection,
FontWeight,
Color,
JustifyContent,
BorderRadius,
BorderColor,
BlockSize,
TextColor,
TextAlign,
TextVariant,
} from '../../../helpers/constants/design-system';
import {
CUSTODY_ACCOUNT_DONE_ROUTE,
DEFAULT_ROUTE,
} from '../../../helpers/constants/routes';
import { getCurrentChainId, getSelectedAddress } from '../../../selectors';
import { getMMIConfiguration } from '../../../selectors/institutional/selectors';
import { getInstitutionalConnectRequests } from '../../../ducks/institutional/institutional';
import CustodyAccountList from '../connect-custody/account-list';
import JwtUrlForm from '../../../components/institutional/jwt-url-form';
import {
MetaMetricsEventCategory,
MetaMetricsEventName,
} from '../../../../shared/constants/metametrics';
import PulseLoader from '../../../components/ui/pulse-loader/pulse-loader';
const CustodyPage = () => {
const t = useI18nContext();
const history = useHistory();
const trackEvent = useContext(MetaMetricsContext);
const dispatch = useDispatch();
const mmiActions = mmiActionsFactory();
const currentChainId = useSelector(getCurrentChainId);
const { custodians } = useSelector(getMMIConfiguration);
const [loading, setLoading] = useState(true);
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(parseInt(currentChainId, 16));
const connectRequests = useSelector(getInstitutionalConnectRequests, isEqual);
const [accounts, setAccounts] = useState();
const address = useSelector(getSelectedAddress);
const connectRequest = connectRequests ? connectRequests[0] : undefined;
const custodianButtons = useMemo(() => {
const custodianItems = [];
const sortedCustodians = [...custodians].sort(function (a, b) {
const nameA = a.name.toLowerCase();
const nameB = b.name.toLowerCase();
if (nameA < nameB) {
return -1;
}
if (nameA > nameB) {
return 1;
}
return 0;
});
function shouldShowInProduction(custodian) {
return (
custodian &&
'production' in custodian &&
!custodian.production &&
process.env.METAMASK_ENVIRONMENT === 'production'
);
}
function isHidden(custodian) {
return custodian && 'hidden' in custodian && custodian.hidden;
}
function isNotSelectedCustodian(custodian) {
return (
custodian &&
'name' in custodian &&
connectRequest &&
Object.keys(connectRequest).length &&
custodian.name !== selectedCustodianName
);
}
sortedCustodians.forEach((custodian) => {
if (
shouldShowInProduction(custodian) ||
isHidden(custodian) ||
isNotSelectedCustodian(custodian)
) {
return;
}
custodianItems.push(
<Box
key={uuidv4()}
display={Display.Flex}
flexDirection={FlexDirection.Row}
justifyContent={JustifyContent.spaceBetween}
alignItems={AlignItems.center}
borderColor={BorderColor.borderDefault}
borderRadius={BorderRadius.SM}
padding={4}
marginBottom={4}
>
<Box display={Display.Flex} alignItems={AlignItems.center}>
{custodian.iconUrl && (
<img
width={32}
height={32}
src={custodian.iconUrl}
alt={custodian.displayName}
/>
)}
<Text marginLeft={2}>{custodian.displayName}</Text>
</Box>
<Button
size={BUTTON_SIZES.SM}
data-testid="custody-connect-button"
onClick={async () => {
try {
const jwtListValue = await dispatch(
mmiActions.getCustodianJWTList(custodian.name),
);
setSelectedCustodianName(custodian.name);
setSelectedCustodianType(custodian.type);
setSelectedCustodianImage(custodian.iconUrl);
setSelectedCustodianDisplayName(custodian.displayName);
setApiUrl(custodian.apiUrl);
setCurrentJwt(jwtListValue[0] || '');
setJwtList(jwtListValue);
trackEvent({
category: MetaMetricsEventCategory.MMI,
event: MetaMetricsEventName.CustodianSelected,
properties: {
custodian: custodian.name,
},
});
} catch (error) {
console.error('Error:', error);
}
}}
>
{t('select')}
</Button>
</Box>,
);
});
return custodianItems;
}, [
connectRequest,
custodians,
dispatch,
mmiActions,
selectedCustodianName,
t,
trackEvent,
]);
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: MetaMetricsEventCategory.MMI,
event: MetaMetricsEventName.CustodianConnectionFailed,
properties: {
custodian: selectedCustodianName,
},
});
},
[selectedCustodianName, trackEvent],
);
useEffect(() => {
const fetchConnectRequest = async () => {
try {
if (connectRequest && Object.keys(connectRequest).length) {
const {
token,
environment: custodianName,
service: custodianType,
apiUrl: custodianApiUrl,
} = connectRequest;
const custodianToken =
token || (await dispatch(mmiActions.getCustodianToken(address)));
setCurrentJwt(custodianToken);
setSelectedCustodianType(custodianType);
setSelectedCustodianName(custodianName || custodianType);
setApiUrl(custodianApiUrl);
setConnectError('');
const accountsValue = await dispatch(
mmiActions.getCustodianAccounts(
custodianToken,
custodianApiUrl,
custodianType,
true,
),
);
setAccounts(accountsValue);
trackEvent({
category: MetaMetricsEventCategory.MMI,
event: MetaMetricsEventName.CustodianConnected,
properties: {
custodian: custodianName,
apiUrl,
rpc: Boolean(connectRequest),
},
});
}
} catch (error) {
console.error(error);
handleConnectError(error);
}
};
const handleFetchConnectRequest = () => {
setLoading(true);
fetchConnectRequest().finally(() => setLoading(false));
};
handleFetchConnectRequest();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
async function handleNetworkChange() {
if (!isNaN(chainId)) {
const jwt = currentJwt || jwtList[0];
if (jwt && jwt.length) {
setAccounts(
await dispatch(
mmiActions.getCustodianAccounts(
jwt,
apiUrl,
selectedCustodianType,
true,
),
),
);
}
}
}
if (parseInt(chainId, 16) !== chainId) {
setChainId(parseInt(currentChainId, 16));
handleNetworkChange();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentChainId]);
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({});
}
};
if (loading) {
return <PulseLoader />;
}
return (
<Box className="page-container">
{connectError && (
<Text textAlign={TextAlign.Center} marginTop={3} padding={[2, 7, 5]}>
{connectError}
</Text>
)}
{selectError && (
<Text textAlign={TextAlign.Center} marginTop={3} padding={[2, 7, 5]}>
{selectError}
</Text>
)}
{!accounts && !selectedCustodianType && (
<Box
padding={4}
display={Display.Flex}
flexDirection={FlexDirection.Column}
className="page-container__content"
width={BlockSize.Full}
>
<Box
display={Display.Flex}
alignItems={AlignItems.center}
marginBottom={4}
marginTop={4}
>
<ButtonIcon
ariaLabel={t('back')}
iconName={IconName.ArrowLeft}
size={IconSize.Sm}
color={Color.iconDefault}
onClick={() => history.push(DEFAULT_ROUTE)}
display={Display.Flex}
/>
<Text>{t('back')}</Text>
</Box>
<Text as="h4" variant={TextVariant.bodyLgMedium} marginTop={4}>
{t('connectCustodialAccountTitle')}
</Text>
<Text
as="h6"
color={TextColor.textDefault}
marginTop={2}
marginBottom={5}
>
{t('connectCustodialAccountMsg')}
</Text>
<Box>
<ul width={BlockSize.Full}>{custodianButtons}</ul>
</Box>
</Box>
)}
{!accounts && selectedCustodianType && (
<>
<Box
padding={4}
display={Display.Flex}
flexDirection={FlexDirection.Column}
className="page-container__content"
width={BlockSize.Full}
>
<Box
display={Display.Flex}
alignItems={AlignItems.center}
marginBottom={4}
marginTop={4}
>
<ButtonIcon
ariaLabel={t('back')}
iconName={IconName.ArrowLeft}
size={IconSize.Sm}
color={Color.iconDefault}
onClick={cancelConnectCustodianToken}
display={[Display.Flex]}
/>
<Text>{t('back')}</Text>
</Box>
{selectedCustodianImage && (
<Box display={Display.Flex} alignItems={AlignItems.center}>
<img
width={32}
height={32}
src={selectedCustodianImage}
alt={selectedCustodianDisplayName}
/>
<Text as="h4" marginLeft={2}>
{selectedCustodianDisplayName}
</Text>
</Box>
)}
<Text marginTop={4}>
{t('enterCustodianToken', [selectedCustodianDisplayName])}
</Text>
<Box paddingBottom={7}>
<JwtUrlForm
jwtList={jwtList}
currentJwt={currentJwt}
onJwtChange={(jwt) => setCurrentJwt(jwt)}
jwtInputText={t('pasteJWTToken')}
apiUrl={apiUrl}
urlInputText={t('custodyApiUrl', [
selectedCustodianDisplayName,
])}
onUrlChange={(url) => setApiUrl(url)}
/>
</Box>
</Box>
<Box as="footer" className="page-container__footer" padding={4}>
{loading ? (
<PulseLoader />
) : (
<Box display={Display.Flex} gap={4}>
<Button
block
variant={BUTTON_VARIANT.SECONDARY}
size={BUTTON_SIZES.LG}
onClick={cancelConnectCustodianToken}
>
{t('cancel')}
</Button>
<Button
block
data-testid="jwt-form-connect-button"
size={BUTTON_SIZES.LG}
onClick={async () => {
try {
setConnectError('');
const accountsValue = await dispatch(
mmiActions.getCustodianAccounts(
currentJwt || jwtList[0],
apiUrl,
selectedCustodianType,
true,
),
);
setAccounts(accountsValue);
trackEvent({
category: MetaMetricsEventCategory.MMI,
event: MetaMetricsEventName.CustodianConnected,
properties: {
custodian: selectedCustodianName,
apiUrl,
rpc: Boolean(connectRequest),
},
});
} catch (e) {
handleConnectError(e);
}
}}
disabled={
!selectedCustodianName ||
(addNewTokenClicked && !currentJwt)
}
>
{t('connect')}
</Button>
</Box>
)}
</Box>
</>
)}
{accounts && accounts.length > 0 && (
<>
<Box padding={[5, 7, 2]} width={BlockSize.Full}>
<Text as="h4">{t('selectAnAccount')}</Text>
<Text marginTop={2} marginBottom={2}>
{t('selectAnAccountHelp')}
</Text>
</Box>
<Box
padding={[5, 7, 0]}
display={Display.Flex}
flexDirection={FlexDirection.Row}
justifyContent={JustifyContent.flexStart}
alignItems={AlignItems.center}
>
<input
type="checkbox"
id="selectAllAccounts"
name="selectAllAccounts"
marginRight={2}
marginLeft={2}
value={{}}
onChange={(e) => setSelectAllAccounts(e)}
checked={Object.keys(selectedAccounts).length === accounts.length}
/>
<Label htmlFor="selectAllAccounts">{t('selectAllAccounts')}</Label>
</Box>
<CustodyAccountList
custody={selectedCustodianName}
accounts={accounts}
onAccountChange={(account) => {
setSelectedAccounts((prevSelectedAccounts) => {
const updatedSelectedAccounts = { ...prevSelectedAccounts };
if (updatedSelectedAccounts[account.address]) {
delete updatedSelectedAccounts[account.address];
} else {
updatedSelectedAccounts[account.address] = {
name: account.name,
custodianDetails: account.custodianDetails,
labels: account.labels,
token: currentJwt,
apiUrl,
chainId: account.chainId,
custodyType: selectedCustodianType,
custodyName: selectedCustodianName,
};
}
return updatedSelectedAccounts;
});
}}
selectedAccounts={selectedAccounts}
onAddAccounts={async () => {
try {
const selectedCustodian = custodians.find(
(custodian) => custodian.name === selectedCustodianName,
);
await dispatch(
mmiActions.connectCustodyAddresses(
selectedCustodianType,
selectedCustodianName,
selectedAccounts,
),
);
trackEvent({
category: MetaMetricsEventCategory.MMI,
event: MetaMetricsEventName.CustodialAccountsConnected,
properties: {
custodian: selectedCustodianName,
numberOfAccounts: Object.keys(selectedAccounts).length,
chainId,
},
});
history.push({
pathname: CUSTODY_ACCOUNT_DONE_ROUTE,
state: {
imgSrc: selectedCustodian.iconUrl,
title: t('custodianAccountAddedTitle'),
description: t('custodianAccountAddedDesc'),
},
});
} 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: MetaMetricsEventCategory.MMI,
event: MetaMetricsEventName.CustodianConnectionCanceled,
properties: {
custodian: selectedCustodianName,
numberOfAccounts: Object.keys(selectedAccounts).length,
chainId,
},
});
}}
/>
</>
)}
{accounts && accounts.length === 0 && (
<>
<Box
data-testid="custody-accounts-empty"
padding={[6, 7, 2]}
className="page-container__content"
>
<Text
marginBottom={2}
fontWeight={FontWeight.Bold}
color={TextColor.textDefault}
variant={TextVariant.bodyLgMedium}
>
{t('allCustodianAccountsConnectedTitle')}
</Text>
<Text variant={TextVariant.bodyMd}>
{t('allCustodianAccountsConnectedSubtitle')}
</Text>
</Box>
<Box as="footer" className="page-container__footer" padding={4}>
<Button
block
size={BUTTON_SIZES.LG}
type={BUTTON_VARIANT.SECONDARY}
onClick={() => history.push(DEFAULT_ROUTE)}
>
{t('close')}
</Button>
</Box>
</>
)}
</Box>
);
};
export default CustodyPage;