1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 09:57:02 +01:00

[MMI] custody page component (#18688)

This commit is contained in:
António Regadas 2023-04-27 11:27:31 +01:00 committed by GitHub
parent c450fae84f
commit e6455e92ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1004 additions and 25 deletions

View File

@ -333,6 +333,12 @@
"alerts": {
"message": "Alerts"
},
"allCustodianAccountsConnectedSubtitle": {
"message": "You have either already connected all your custodian accounts or dont 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"
},

View File

@ -17,7 +17,6 @@ exports[`JwtUrlForm shows JWT text area when no jwt token exists 1`] = `
input text
</p>
<textarea
bordercolor="border-default"
class="jwt-url-form__input-jwt"
data-testid="jwt-input"
id="jwt-box"

View File

@ -4,7 +4,6 @@ import { useI18nContext } from '../../../hooks/useI18nContext';
import {
AlignItems,
DISPLAY,
BorderColor,
BLOCK_SIZES,
FLEX_DIRECTION,
} from '../../../helpers/constants/design-system';
@ -79,7 +78,6 @@ const JwtUrlForm = (props) => {
<textarea
className="jwt-url-form__input-jwt"
data-testid="jwt-input"
borderColor={BorderColor.borderDefault}
id="jwt-box"
onChange={(e) => {
props.onJwtChange(e.target.value);

View File

@ -23,6 +23,7 @@
height: 154px;
width: 100%;
border-radius: 4px;
border: 1px solid;
background-color: var(--color-background-default);
color: var(--color-text-default);
padding: 10px;

View File

@ -20,10 +20,6 @@ const CONTACT_LIST_ROUTE = '/settings/contact-list';
const CONTACT_EDIT_ROUTE = '/settings/contact-list/edit-contact';
const CONTACT_ADD_ROUTE = '/settings/contact-list/add-contact';
const CONTACT_VIEW_ROUTE = '/settings/contact-list/view-contact';
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
const CUSTODY_ACCOUNT_ROUTE = '/new-account/custody';
const INSTITUTIONAL_FEATURES_DONE_ROUTE = '/institutional-features/done';
///: END:ONLY_INCLUDE_IN
const REVEAL_SEED_ROUTE = '/seed';
const RESTORE_VAULT_ROUTE = '/restore-vault';
const IMPORT_TOKEN_ROUTE = '/import-token';
@ -32,6 +28,11 @@ const CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE = '/confirm-add-suggested-token';
const NEW_ACCOUNT_ROUTE = '/new-account';
const IMPORT_ACCOUNT_ROUTE = '/new-account/import';
const CONNECT_HARDWARE_ROUTE = '/new-account/connect';
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
const CUSTODY_ACCOUNT_ROUTE = '/new-account/custody';
const INSTITUTIONAL_FEATURES_DONE_ROUTE = '/institutional-features/done';
const CUSTODY_ACCOUNT_DONE_ROUTE = '/new-account/custody/done';
///: END:ONLY_INCLUDE_IN
const SEND_ROUTE = '/send';
const TOKEN_DETAILS = '/token-details';
const CONNECT_ROUTE = '/connect';
@ -212,6 +213,7 @@ export {
CONTACT_ADD_ROUTE,
CONTACT_VIEW_ROUTE,
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
CUSTODY_ACCOUNT_DONE_ROUTE,
CUSTODY_ACCOUNT_ROUTE,
INSTITUTIONAL_FEATURES_DONE_ROUTE,
///: END:ONLY_INCLUDE_IN

View File

@ -27,7 +27,7 @@ exports[`CustodyAccountList renders accounts 1`] = `
class="box box--margin-left-2 box--display-flex box--flex-direction-column box--width-full"
>
<label
class="box mm-text mm-label mm-label--html-for custody-account-list__item__title mm-text--body-md mm-text--font-weight-bold box--margin-top-2 box--margin-left-2 box--display-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-text-default"
class="box mm-text mm-label mm-label--html-for custody-account-list__item__title mm-text--body-md mm-text--font-weight-bold box--margin-top-2 box--margin-left-2 box--display-flex box--flex-direction-row box--align-items-center box--color-text-default"
for="address-0"
>
<span
@ -37,7 +37,7 @@ exports[`CustodyAccountList renders accounts 1`] = `
</span>
</label>
<label
class="box mm-text mm-label mm-label--html-for mm-text--body-md mm-text--font-weight-bold box--margin-top-2 box--margin-right-3 box--display-flex box--flex-direction-row box--align-items-center box--color-text-default"
class="box mm-text mm-label mm-label--html-for mm-text--body-md mm-text--font-weight-bold box--margin-top-2 box--margin-right-3 box--margin-left-2 box--display-flex box--flex-direction-row box--align-items-center box--color-text-default"
for="address-0"
>
<span
@ -103,7 +103,7 @@ exports[`CustodyAccountList renders accounts 1`] = `
class="box box--margin-left-2 box--display-flex box--flex-direction-column box--width-full"
>
<label
class="box mm-text mm-label mm-label--html-for custody-account-list__item__title mm-text--body-md mm-text--font-weight-bold box--margin-top-2 box--margin-left-2 box--display-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-text-default"
class="box mm-text mm-label mm-label--html-for custody-account-list__item__title mm-text--body-md mm-text--font-weight-bold box--margin-top-2 box--margin-left-2 box--display-flex box--flex-direction-row box--align-items-center box--color-text-default"
for="address-1"
>
<span
@ -113,7 +113,7 @@ exports[`CustodyAccountList renders accounts 1`] = `
</span>
</label>
<label
class="box mm-text mm-label mm-label--html-for mm-text--body-md mm-text--font-weight-bold box--margin-top-2 box--margin-right-3 box--display-flex box--flex-direction-row box--align-items-center box--color-text-default"
class="box mm-text mm-label mm-label--html-for mm-text--body-md mm-text--font-weight-bold box--margin-top-2 box--margin-right-3 box--margin-left-2 box--display-flex box--flex-direction-row box--align-items-center box--color-text-default"
for="address-1"
>
<span

View File

@ -1,20 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '../../../../components/ui/button';
import CustodyLabels from '../../../../components/institutional/custody-labels';
import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../../shared/constants/swaps';
import { CHAIN_IDS } from '../../../../../shared/constants/network';
import { shortenAddress } from '../../../../helpers/utils/util';
import Tooltip from '../../../../components/ui/tooltip';
import Button from '../../../components/ui/button';
import CustodyLabels from '../../../components/institutional/custody-labels';
import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/swaps';
import { CHAIN_IDS } from '../../../../shared/constants/network';
import { shortenAddress } from '../../../helpers/utils/util';
import Tooltip from '../../../components/ui/tooltip';
import {
TextVariant,
JustifyContent,
BLOCK_SIZES,
DISPLAY,
IconColor,
} from '../../../../helpers/constants/design-system';
import { useI18nContext } from '../../../../hooks/useI18nContext';
import Box from '../../../../components/ui/box';
} from '../../../helpers/constants/design-system';
import { useI18nContext } from '../../../hooks/useI18nContext';
import Box from '../../../components/ui/box';
import {
Text,
Label,
@ -22,8 +22,8 @@ import {
IconName,
IconSize,
ButtonLink,
} from '../../../../components/component-library';
import { useCopyToClipboard } from '../../../../hooks/useCopyToClipboard';
} from '../../../components/component-library';
import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard';
const getButtonLinkHref = (account) => {
const url = SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[CHAIN_IDS.MAINNET];
@ -94,7 +94,6 @@ export default function CustodyAccountList({
>
<Label
display={DISPLAY.FLEX}
justifyContent={JustifyContent.center}
marginTop={2}
marginLeft={2}
htmlFor={`address-${idx}`}
@ -114,6 +113,7 @@ export default function CustodyAccountList({
display={DISPLAY.FLEX}
size={TextVariant.bodySm}
marginTop={2}
marginLeft={2}
marginRight={3}
htmlFor={`address-${idx}`}
>

View File

@ -0,0 +1,174 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CustodyPage renders CustodyPage 1`] = `
<div>
<div
class="box box--flex-direction-row"
>
<div
class="box box--sm:padding-7 box--md:padding-2 box--display-flex box--flex-direction-column"
>
<button
aria-label="Back"
class="box mm-button-icon mm-button-icon--size-sm box--display-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-icon-default box--background-color-transparent box--rounded-lg"
>
<span
class="box mm-icon mm-icon--size-sm box--display-inline-block box--flex-direction-row box--color-inherit"
style="mask-image: url('./images/icons/arrow-left.svg');"
/>
</button>
<h4
class="box mm-text mm-text--body-lg-medium box--margin-top-4 box--margin-bottom-4 box--flex-direction-row box--color-text-default"
>
Custodial Accounts
</h4>
<h6
class="box mm-text mm-text--body-md box--margin-top-2 box--margin-bottom-5 box--flex-direction-row box--color-text-default"
>
Please choose the custodian you want to connect in order to add or refresh a token.
</h6>
<div
class="box box--flex-direction-row"
>
<ul
width="full"
>
<div
class="box box--margin-bottom-4 box--padding-3 box--sm:padding-4 box--display-flex box--flex-direction-row box--justify-content-space-between box--align-items-center box--rounded-sm box--border-color-border-default box--border-style-solid box--border-width-1"
>
<div
class="box box--display-flex box--flex-direction-row box--align-items-center"
>
<img
alt="Saturn Custody"
height="32"
src="https://saturn-custody-ui.dev.metamask-institutional.io/saturn.svg"
width="32"
/>
<p
class="box mm-text mm-text--body-md box--margin-left-2 box--flex-direction-row box--color-text-default"
>
Saturn Custody
</p>
</div>
<button
class="box mm-text mm-button-base mm-button-base--size-sm mm-button-primary mm-text--body-md box--padding-right-4 box--padding-left-4 box--display-inline-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-primary-inverse box--background-color-primary-default box--rounded-pill"
data-testid="custody-connect-button"
>
Select
</button>
</div>
</ul>
</div>
</div>
</div>
</div>
`;
exports[`CustodyPage renders CustodyPage 2`] = `
<div>
<div
class="box box--flex-direction-row"
>
<div
class="box box--sm:padding-7 box--md:padding-2 box--display-flex box--flex-direction-column"
>
<button
aria-label="Back"
class="box mm-button-icon mm-button-icon--size-sm box--display-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-icon-alternative box--background-color-transparent box--rounded-lg"
>
<span
class="box mm-icon mm-icon--size-sm box--display-inline-block box--flex-direction-row box--color-inherit"
style="mask-image: url('./images/icons/arrow-left.svg');"
/>
</button>
<h4
class="box mm-text mm-text--body-md box--flex-direction-row box--color-text-default"
>
<div
class="box box--display-flex box--flex-direction-row box--align-items-center"
>
<p
class="box mm-text mm-text--body-md box--margin-left-2 box--flex-direction-row box--color-text-default"
/>
</div>
</h4>
<p
class="box mm-text mm-text--body-md box--margin-top-4 box--margin-bottom-4 box--flex-direction-row box--color-text-default"
>
Enter your token or add a new token
</p>
</div>
<div
class="box box--padding-top-7 box--padding-bottom-7 box--flex-direction-row"
>
<div
class="box jwt-url-form box--margin-bottom-8 box--display-flex box--flex-direction-column box--align-items-flex-start"
>
<div
class="box jwt-url-form__jwt-container box--margin-top-4 box--margin-bottom-6 box--display-flex box--flex-direction-column box--align-items-center box--width-full"
>
<div
class="box box--flex-direction-row"
>
<p
class="box mm-text jwt-url-form__instruction mm-text--body-md box--display-block box--flex-direction-row box--color-text-default"
>
Paste or drop your token here:
</p>
<textarea
class="jwt-url-form__input-jwt"
data-testid="jwt-input"
id="jwt-box"
>
token
</textarea>
</div>
</div>
<div
class="box box--flex-direction-row box--width-full"
>
<p
class="box mm-text jwt-url-form__instruction mm-text--body-md box--display-block box--flex-direction-row box--color-text-default"
>
API URL
</p>
<div
class="box box--flex-direction-row"
>
<input
class="jwt-url-form__input"
data-testid="jwt-api-url-input"
id="api-url-box"
value="url"
/>
</div>
</div>
</div>
<div
class="box box--padding-4 box--display-flex box--flex-direction-row box--justify-content-center"
>
<button
class="box mm-text mm-button-base mm-button-base--size-md mm-button-primary mm-text--body-md box--margin-right-4 box--padding-right-4 box--padding-left-4 box--display-inline-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-primary-inverse box--background-color-primary-default box--rounded-pill"
type="secondary"
>
Cancel
</button>
<button
class="box mm-text mm-button-base mm-button-base--size-md mm-button-primary mm-text--body-md box--padding-right-4 box--padding-left-4 box--display-inline-flex box--flex-direction-row box--justify-content-center box--align-items-center box--color-primary-inverse box--background-color-primary-default box--rounded-pill"
data-testid="jwt-form-connect-button"
>
Connect
</button>
</div>
</div>
</div>
</div>
`;
exports[`CustodyPage renders CustodyPage 3`] = `<div />`;

View File

@ -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(
<Box
key={uuidv4()}
display={DISPLAY.FLEX}
flexDirection={FLEX_DIRECTION.ROW}
justifyContent={JustifyContent.spaceBetween}
alignItems={AlignItems.center}
borderColor={BorderColor.borderDefault}
borderRadius={BorderRadius.SM}
padding={[3, 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 (_) => {
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: 'MMI',
event: 'Custodian Selected',
properties: {
custodian: custodian.name,
},
});
}}
>
{t('select')}
</Button>
</Box>,
);
});
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 (
<Box>
{connectError && (
<Text textAlign={TEXT_ALIGN.CENTER} marginTop={3} padding={[2, 7, 5]}>
{connectError}
</Text>
)}
{selectError && (
<Text textAlign={TEXT_ALIGN.CENTER} marginTop={3} padding={[2, 7, 5]}>
{selectError}
</Text>
)}
{!accounts && !selectedCustodianType ? (
<Box
padding={[0, 7, 2]}
display={DISPLAY.FLEX}
flexDirection={FLEX_DIRECTION.COLUMN}
>
<ButtonIcon
ariaLabel={t('back')}
iconName={IconName.ArrowLeft}
size={IconSize.Sm}
color={Color.iconDefault}
onClick={() => history.push(DEFAULT_ROUTE)}
display={DISPLAY.FLEX}
/>
<Text
as="h4"
variant={TextVariant.bodyLgMedium}
marginTop={4}
marginBottom={4}
>
{t('connectCustodialAccountTitle')}
</Text>
<Text
as="h6"
color={TextColor.textDefault}
marginTop={2}
marginBottom={5}
>
{t('connectCustodialAccountMsg')}
</Text>
<Box>
<ul width={BLOCK_SIZES.FULL}>{custodianButtons}</ul>
</Box>
</Box>
) : null}
{!accounts && selectedCustodianType && (
<>
<Box
padding={[0, 7, 2]}
display={DISPLAY.FLEX}
flexDirection={FLEX_DIRECTION.COLUMN}
>
<ButtonIcon
ariaLabel={t('back')}
iconName={IconName.ArrowLeft}
size={IconSize.Sm}
color={Color.iconAlternative}
onClick={() => cancelConnectCustodianToken()}
display={[DISPLAY.FLEX]}
/>
<Text as="h4">
<Box display={DISPLAY.FLEX} alignItems={AlignItems.center}>
{selectedCustodianImage && (
<img
width={32}
height={32}
src={selectedCustodianImage}
alt={selectedCustodianDisplayName}
/>
)}
<Text marginLeft={2}>{selectedCustodianDisplayName}</Text>
</Box>
</Text>
<Text marginTop={4} marginBottom={4}>
{t('enterCustodianToken', [selectedCustodianDisplayName])}
</Text>
</Box>
<Box paddingTop={7} 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
display={DISPLAY.FLEX}
flexDirection={FLEX_DIRECTION.ROW}
justifyContent={JustifyContent.center}
padding={[4, 0]}
>
<Button
type={BUTTON_VARIANT.SECONDARY}
marginRight={4}
onClick={() => {
cancelConnectCustodianToken();
}}
>
{t('cancel')}
</Button>
<Button
data-testid="jwt-form-connect-button"
onClick={connect}
disabled={
!selectedCustodianName || (addNewTokenClicked && !currentJwt)
}
>
{t('connect')}
</Button>
</Box>
</Box>
</>
)}
{accounts && accounts.length > 0 && (
<>
<Box
borderColor={BorderColor.borderDefault}
padding={[5, 7, 2]}
width={BLOCK_SIZES.FULL}
>
<Text as="h4">{t('selectAnAccount')}</Text>
<Text marginTop={2} marginBottom={5}>
{t('selectAnAccountHelp')}
</Text>
</Box>
<Box
padding={[5, 7, 0]}
display={DISPLAY.FLEX}
flexDirection={FLEX_DIRECTION.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) => {
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 && (
<Box
data-testid="custody-accounts-empty"
padding={[6, 7, 2]}
className="custody-accounts-empty"
>
<Text
marginBottom={2}
fontWeight={FONT_WEIGHT.BOLD}
color={TextColor.textDefault}
variant={TextVariant.bodySm}
>
{t('allCustodianAccountsConnectedTitle')}
</Text>
<Text variant={TextVariant.bodyXs}>
{t('allCustodianAccountsConnectedSubtitle')}
</Text>
<Box padding={[5, 7]} className="custody-accounts-empty__footer">
<Button
size={BUTTON_SIZES.LG}
type={BUTTON_VARIANT.SECONDARY}
onClick={() => history.push(DEFAULT_ROUTE)}
>
{t('close')}
</Button>
</Box>
</Box>
)}
</Box>
);
};
export default CustodyPage;

View File

@ -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) => <Provider store={store}>{story()}</Provider>],
component: CustodyPage,
argTypes: {
onClick: {
action: 'onClick',
},
onChange: {
action: 'onChange',
},
},
};
export const DefaultStory = (args) => <CustodyPage {...args} />;
DefaultStory.storyName = 'CustodyPage';

View File

@ -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(<CustodyPage />, store);
await waitFor(() => {
expect(container).toMatchSnapshot();
});
});
it('opens connect custody without any custody selected', async () => {
const { getByTestId } = renderWithProvider(<CustodyPage />, store);
await waitFor(() => {
expect(getByTestId('custody-connect-button')).toBeDefined();
});
});
it('calls getCustodianJwtList on custody select when connect btn is click', async () => {
const { getByTestId } = renderWithProvider(<CustodyPage />, 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(<CustodyPage />, store);
const custodyBtn = getByTestId('custody-connect-button');
await waitFor(() => {
fireEvent.click(custodyBtn);
});
await waitFor(() => {
expect(screen.getByTestId('jwt-form-connect-button')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1 @@
export { default } from './custody';

View File

@ -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;
}
}

View File

@ -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';

View File

@ -71,7 +71,7 @@ export function getMMIAddressFromModalOrAddress(state) {
}
export function getMMIConfiguration(state) {
return state.metamask.mmiConfiguration;
return state.metamask.mmiConfiguration || [];
}
export function getInteractiveReplacementToken(state) {