diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 06a134f72..90b3245e4 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1412,6 +1412,9 @@ "enterPasswordContinue": { "message": "Enter password to continue" }, + "enterYourPassword": { + "message": "Enter your password" + }, "errorCode": { "message": "Code: $1", "description": "Displayed error code for debugging purposes. $1 is the error code" @@ -3167,6 +3170,10 @@ "message": "Private Key", "description": "select this type of file to use to import an account" }, + "privateKeyCopyWarning": { + "message": "Private key for $1", + "description": "$1 represents the account name" + }, "privateKeyWarning": { "message": "Warning: Never disclose this key. Anyone with your private keys can steal any assets held in your account." }, @@ -3673,6 +3680,9 @@ "showPermissions": { "message": "Show permissions" }, + "showPrivateKey": { + "message": "Show private key" + }, "showPrivateKeys": { "message": "Show Private Keys" }, diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index beee1e509..5e970ca0b 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -483,6 +483,7 @@ export enum MetaMetricsEventName { AccountAddFailed = 'Account Add Failed', AccountPasswordCreated = 'Account Password Created', AccountReset = 'Account Reset', + AccountRenamed = 'Account Renamed', AppInstalled = 'App Installed', AppUnlocked = 'App Unlocked', AppUnlockedFailed = 'App Unlocked Failed', diff --git a/ui/components/component-library/header-base/header-base.tsx b/ui/components/component-library/header-base/header-base.tsx index 8f73c7b17..f10315e3f 100644 --- a/ui/components/component-library/header-base/header-base.tsx +++ b/ui/components/component-library/header-base/header-base.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useLayoutEffect, useMemo, useState } from 'react'; +import React, { useRef, useEffect, useMemo, useState } from 'react'; import classnames from 'classnames'; import { BLOCK_SIZES, @@ -23,7 +23,7 @@ export const HeaderBase: React.FC = ({ const endAccessoryRef = useRef(null); const [accessoryMinWidth, setAccessoryMinWidth] = useState(); - useLayoutEffect(() => { + useEffect(() => { function handleResize() { if (startAccessoryRef.current && endAccessoryRef.current) { const accMinWidth = Math.max( diff --git a/ui/components/multichain/account-details/account-details-authenticate.js b/ui/components/multichain/account-details/account-details-authenticate.js new file mode 100644 index 000000000..240b618a4 --- /dev/null +++ b/ui/components/multichain/account-details/account-details-authenticate.js @@ -0,0 +1,90 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; +import { + DISPLAY, + SEVERITIES, + TextColor, + TextVariant, +} from '../../../helpers/constants/design-system'; +import { + BannerAlert, + ButtonPrimary, + ButtonSecondary, + FormTextField, + Text, +} from '../../component-library'; +import Box from '../../ui/box/box'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { exportAccount, hideWarning } from '../../../store/actions'; + +export const AccountDetailsAuthenticate = ({ address, onCancel }) => { + const t = useI18nContext(); + const dispatch = useDispatch(); + + const [password, setPassword] = useState(''); + + // Password error would result from appState + const warning = useSelector((state) => state.appState.warning); + + const onSubmit = useCallback(() => { + dispatch(exportAccount(password, address)).then((res) => { + dispatch(hideWarning()); + return res; + }); + }, [dispatch, password, address]); + + const handleKeyPress = useCallback( + (e) => { + if (e.key === 'Enter') { + onSubmit(); + } + }, + [onSubmit], + ); + + return ( + <> + setPassword(e.target.value)} + value={password} + variant={TextVariant.bodySm} + type="password" + inputProps={{ + onKeyPress: handleKeyPress, + }} + /> + {warning ? ( + + {warning} + + ) : null} + + {t('privateKeyWarning')} + + + + {t('cancel')} + + + {t('confirm')} + + + + ); +}; + +AccountDetailsAuthenticate.propTypes = { + address: PropTypes.string.isRequired, + onCancel: PropTypes.func.isRequired, +}; diff --git a/ui/components/multichain/account-details/account-details-display.js b/ui/components/multichain/account-details/account-details-display.js new file mode 100644 index 000000000..e3c609813 --- /dev/null +++ b/ui/components/multichain/account-details/account-details-display.js @@ -0,0 +1,103 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; + +import QrView from '../../ui/qr-code'; +import EditableLabel from '../../ui/editable-label/editable-label'; + +import { setAccountLabel } from '../../../store/actions'; +import { + getCurrentChainId, + getHardwareWalletType, + getMetaMaskKeyrings, +} from '../../../selectors'; +import { isHardwareKeyring } from '../../../helpers/utils/hardware'; +import { + BUTTON_SECONDARY_SIZES, + ButtonSecondary, +} from '../../component-library'; +import { + AlignItems, + DISPLAY, + FLEX_DIRECTION, + TextVariant, +} from '../../../helpers/constants/design-system'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; +import { + MetaMetricsEventCategory, + MetaMetricsEventKeyType, + MetaMetricsEventName, +} from '../../../../shared/constants/metametrics'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import Box from '../../ui/box/box'; + +export const AccountDetailsDisplay = ({ + accounts, + accountName, + address, + onExportClick, +}) => { + const dispatch = useDispatch(); + const trackEvent = useContext(MetaMetricsContext); + const t = useI18nContext(); + + const keyrings = useSelector(getMetaMaskKeyrings); + const keyring = keyrings.find((kr) => kr.accounts.includes(address)); + const exportPrivateKeyFeatureEnabled = !isHardwareKeyring(keyring?.type); + + const chainId = useSelector(getCurrentChainId); + const deviceName = useSelector(getHardwareWalletType); + + return ( + + { + dispatch(setAccountLabel(address, label)); + trackEvent({ + category: MetaMetricsEventCategory.Accounts, + event: MetaMetricsEventName.AccountRenamed, + properties: { + location: 'Account Details Modal', + chain_id: chainId, + account_hardware_type: deviceName, + }, + }); + }} + accounts={accounts} + /> + + {exportPrivateKeyFeatureEnabled ? ( + { + trackEvent({ + category: MetaMetricsEventCategory.Accounts, + event: MetaMetricsEventName.KeyExportSelected, + properties: { + key_type: MetaMetricsEventKeyType.Pkey, + location: 'Account Details Modal', + }, + }); + onExportClick(); + }} + > + {t('showPrivateKey')} + + ) : null} + + ); +}; + +AccountDetailsDisplay.propTypes = { + accounts: PropTypes.array.isRequired, + accountName: PropTypes.string.isRequired, + address: PropTypes.string.isRequired, + onExportClick: PropTypes.func.isRequired, +}; diff --git a/ui/components/multichain/account-details/account-details-key.js b/ui/components/multichain/account-details/account-details-key.js new file mode 100644 index 000000000..2c539ae0e --- /dev/null +++ b/ui/components/multichain/account-details/account-details-key.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + BannerAlert, + ButtonIcon, + ButtonPrimary, + IconName, + Text, +} from '../../component-library'; +import { + AlignItems, + BorderColor, + BorderRadius, + DISPLAY, + FLEX_DIRECTION, + SEVERITIES, + TextVariant, +} from '../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import Box from '../../ui/box/box'; +import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; + +export const AccountDetailsKey = ({ accountName, onClose, privateKey }) => { + const t = useI18nContext(); + + const [privateKeyCopied, handlePrivateKeyCopy] = useCopyToClipboard(); + + return ( + <> + + {t('privateKeyCopyWarning', [accountName])} + + + + {privateKey} + + handlePrivateKeyCopy(privateKey)} + iconName={privateKeyCopied ? IconName.CopySuccess : IconName.Copy} + /> + + + {t('privateKeyWarning')} + + + {t('done')} + + + ); +}; + +AccountDetailsKey.propTypes = { + accountName: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + privateKey: PropTypes.string.isRequired, +}; diff --git a/ui/components/multichain/account-details/account-details.js b/ui/components/multichain/account-details/account-details.js new file mode 100644 index 000000000..70fa9e862 --- /dev/null +++ b/ui/components/multichain/account-details/account-details.js @@ -0,0 +1,145 @@ +import React, { useCallback, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import Popover from '../../ui/popover/popover.component'; +import { + setAccountDetailsAddress, + clearAccountDetails, + hideWarning, +} from '../../../store/actions'; +import { + AvatarAccount, + AvatarAccountSize, + AvatarAccountVariant, + ButtonIcon, + IconName, + PopoverHeader, + Text, +} from '../../component-library'; +import Box from '../../ui/box/box'; +import { getMetaMaskAccountsOrdered } from '../../../selectors'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { + AlignItems, + DISPLAY, + FLEX_DIRECTION, + JustifyContent, + TextVariant, + Size, +} from '../../../helpers/constants/design-system'; +import { AddressCopyButton } from '../address-copy-button'; +import { AccountDetailsDisplay } from './account-details-display'; +import { AccountDetailsAuthenticate } from './account-details-authenticate'; +import { AccountDetailsKey } from './account-details-key'; + +export const AccountDetails = ({ address }) => { + const dispatch = useDispatch(); + const t = useI18nContext(); + const useBlockie = useSelector((state) => state.metamask.useBlockie); + const accounts = useSelector(getMetaMaskAccountsOrdered); + const { name } = accounts.find((account) => account.address === address); + + const [attemptingExport, setAttemptingExport] = useState(false); + + // This is only populated when the user properly authenticates + const privateKey = useSelector( + (state) => state.appState.accountDetail.privateKey, + ); + + const onClose = useCallback(() => { + dispatch(setAccountDetailsAddress('')); + dispatch(clearAccountDetails()); + dispatch(hideWarning()); + }, [dispatch]); + + const avatar = ( + + ); + + return ( + setAttemptingExport(false)} + iconName={IconName.ArrowLeft} + size={Size.SM} + /> + } + > + {t('showPrivateKey')} + + ) : ( + + {avatar} + + ) + } + onClose={onClose} + > + {attemptingExport ? ( + <> + + {avatar} + + {name} + + + + {privateKey ? ( + + ) : ( + + )} + + ) : ( + setAttemptingExport(true)} + /> + )} + + ); +}; + +AccountDetails.propTypes = { + address: PropTypes.string, +}; diff --git a/ui/components/multichain/account-details/account-details.stories.js b/ui/components/multichain/account-details/account-details.stories.js new file mode 100644 index 000000000..7989834c7 --- /dev/null +++ b/ui/components/multichain/account-details/account-details.stories.js @@ -0,0 +1,20 @@ +import React from 'react'; +import testData from '../../../../.storybook/test-data'; +import { AccountDetails } from './account-details'; + +const [, address] = Object.keys(testData.metamask.identities); + +export default { + title: 'Components/Multichain/AccountDetails', + component: AccountDetails, + argTypes: { + address: { + control: 'text', + }, + }, + args: { + address, + }, +}; + +export const DefaultStory = (args) => ; diff --git a/ui/components/multichain/account-details/account-details.test.js b/ui/components/multichain/account-details/account-details.test.js new file mode 100644 index 000000000..baec78f20 --- /dev/null +++ b/ui/components/multichain/account-details/account-details.test.js @@ -0,0 +1,91 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProvider } from '../../../../test/jest'; +import configureStore from '../../../store/store'; +import mockState from '../../../../test/data/mock-state.json'; +import { showPrivateKey } from '../../../../app/_locales/en/messages.json'; +import { + setAccountDetailsAddress, + exportAccount, + hideWarning, +} from '../../../store/actions'; +import { AccountDetails } from '.'; + +jest.mock('../../../store/actions.ts'); + +describe('AccountDetails', () => { + const address = '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'; + const mockSetAccountDetailsAddress = jest.fn(); + const mockExportAccount = jest.fn().mockResolvedValue(true); + const mockHideWarning = jest.fn(); + + beforeEach(() => { + setAccountDetailsAddress.mockReturnValue(mockSetAccountDetailsAddress); + exportAccount.mockReturnValue(mockExportAccount); + hideWarning.mockReturnValue(mockHideWarning); + }); + + afterEach(() => jest.clearAllMocks()); + + function render(props = {}, storeModifications = {}) { + const store = configureStore({ + metamask: { + ...mockState.metamask, + }, + ...storeModifications, + }); + const allProps = { address, ...props }; + return renderWithProvider(, store); + } + + it('should set account label when changing default account label', () => { + render(); + + const editButton = screen.getByTestId('editable-label-button'); + fireEvent.click(editButton); + + const editableInput = screen.getByTestId('editable-input'); + const newAccountLabel = 'New Label'; + + fireEvent.change(editableInput, { target: { value: newAccountLabel } }); + + expect(editableInput).toHaveAttribute('value', newAccountLabel); + }); + + it('shows export private key contents and password field when clicked', () => { + const { queryByText, queryByPlaceholderText } = render(); + const exportPrivateKeyButton = queryByText(showPrivateKey.message); + fireEvent.click(exportPrivateKeyButton); + + expect(queryByText('Show private key')).toBeInTheDocument(); + expect(queryByPlaceholderText('Password')).toBeInTheDocument(); + }); + + it('attempts to validate password when submitted', async () => { + const password = 'password'; + + const { queryByPlaceholderText, queryByText } = render(); + const exportPrivateKeyButton = queryByText(showPrivateKey.message); + fireEvent.click(exportPrivateKeyButton); + + queryByPlaceholderText('Password').focus(); + await userEvent.keyboard(password); + fireEvent.click(queryByText('Confirm')); + + expect(exportAccount).toHaveBeenCalledWith(password, address); + }); + + it('displays the private key when exposed in state', () => { + const samplePrivateKey = '8675309'; + const { queryByText } = render( + {}, + { appState: { accountDetail: { privateKey: samplePrivateKey } } }, + ); + + const exportPrivateKeyButton = queryByText(showPrivateKey.message); + fireEvent.click(exportPrivateKeyButton); + + expect(queryByText(samplePrivateKey)).toBeInTheDocument(); + }); +}); diff --git a/ui/components/multichain/account-details/index.js b/ui/components/multichain/account-details/index.js new file mode 100644 index 000000000..0591a525b --- /dev/null +++ b/ui/components/multichain/account-details/index.js @@ -0,0 +1 @@ +export { AccountDetails } from './account-details'; diff --git a/ui/components/multichain/account-list-item-menu/account-list-item-menu.js b/ui/components/multichain/account-list-item-menu/account-list-item-menu.js index cf480dc26..d156d1439 100644 --- a/ui/components/multichain/account-list-item-menu/account-list-item-menu.js +++ b/ui/components/multichain/account-list-item-menu/account-list-item-menu.js @@ -22,7 +22,7 @@ import { MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; import { getURLHostName } from '../../../helpers/utils/util'; -import { showModal } from '../../../store/actions'; +import { setAccountDetailsAddress, showModal } from '../../../store/actions'; import { TextVariant } from '../../../helpers/constants/design-system'; import { formatAccountType } from '../../../helpers/utils/metrics'; @@ -101,7 +101,7 @@ export const AccountListItemMenu = ({ { - dispatch(showModal({ name: 'ACCOUNT_DETAILS' })); + dispatch(setAccountDetailsAddress(identity.address)); trackEvent({ event: MetaMetricsEventName.NavAccountDetailsOpened, category: MetaMetricsEventCategory.Navigation, diff --git a/ui/components/multichain/address-copy-button/address-copy-button.js b/ui/components/multichain/address-copy-button/address-copy-button.js index c71f8ba3d..087960e7b 100644 --- a/ui/components/multichain/address-copy-button/address-copy-button.js +++ b/ui/components/multichain/address-copy-button/address-copy-button.js @@ -21,6 +21,7 @@ export const AddressCopyButton = ({ address, shorten = false, wrap = false, + onClick, }) => { const displayAddress = shorten ? shortenAddress(address) : address; const [copied, handleCopy] = useCopyToClipboard(); @@ -30,7 +31,10 @@ export const AddressCopyButton = ({ handleCopy(address)} + onClick={() => { + handleCopy(address); + onClick?.(); + }} paddingRight={4} paddingLeft={4} size={Size.SM} @@ -63,4 +67,8 @@ AddressCopyButton.propTypes = { * Represents if the element should wrap to multiple lines */ wrap: PropTypes.bool, + /** + * Fires when the button is clicked + */ + onClick: PropTypes.func, }; diff --git a/ui/components/multichain/index.js b/ui/components/multichain/index.js index 7b213723d..2fad68f03 100644 --- a/ui/components/multichain/index.js +++ b/ui/components/multichain/index.js @@ -12,3 +12,4 @@ export { MultichainConnectedSiteMenu } from './multichain-connected-site-menu'; export { NetworkListItem } from './network-list-item'; export { NetworkListMenu } from './network-list-menu'; export { ProductTour } from './product-tour-popover'; +export { AccountDetails } from './account-details'; diff --git a/ui/components/ui/qr-code/qr-code.js b/ui/components/ui/qr-code/qr-code.js index d06d4c9f2..7c1f3af7a 100644 --- a/ui/components/ui/qr-code/qr-code.js +++ b/ui/components/ui/qr-code/qr-code.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useContext } from 'react'; import qrCode from 'qrcode-generator'; import { connect } from 'react-redux'; import { isHexPrefixed } from 'ethereumjs-util'; @@ -10,6 +10,11 @@ import { useI18nContext } from '../../../hooks/useI18nContext'; import { AddressCopyButton } from '../../multichain/address-copy-button'; import Box from '../box/box'; import { Icon, IconName, IconSize } from '../../component-library'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, +} from '../../../../shared/constants/metametrics'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; export default connect(mapStateToProps)(QrCodeView); @@ -22,14 +27,14 @@ function mapStateToProps(state) { }; } -function QrCodeView(props) { - const { Qr, warning } = props; +function QrCodeView({ Qr, warning }) { + const [copied, handleCopy] = useCopyToClipboard(); + const t = useI18nContext(); + const trackEvent = useContext(MetaMetricsContext); const { message, data } = Qr; const address = `${ isHexPrefixed(data) ? 'ethereum:' : '' }${toChecksumHexAddress(data)}`; - const [copied, handleCopy] = useCopyToClipboard(); - const t = useI18nContext(); const qrImage = qrCode(4, 'M'); qrImage.addData(address); qrImage.make(); @@ -55,12 +60,26 @@ function QrCodeView(props) {
{process.env.MULTICHAIN ? ( - - + + { + trackEvent({ + category: MetaMetricsEventCategory.Accounts, + event: MetaMetricsEventName.PublicAddressCopied, + properties: { + location: 'Account Details Modal', + }, + }); + }} + /> ) : ( toggleNetworkMenu()} /> ) : null} + {process.env.MULTICHAIN && accountDetailsAddress ? ( + + ) : null}
{isLoading ? : null} {!isLoading && isNetworkLoading ? : null} diff --git a/ui/pages/routes/routes.container.js b/ui/pages/routes/routes.container.js index d5b08b6a4..9e1e6660c 100644 --- a/ui/pages/routes/routes.container.js +++ b/ui/pages/routes/routes.container.js @@ -60,6 +60,7 @@ function mapStateToProps(state) { completedOnboarding, isAccountMenuOpen: state.metamask.isAccountMenuOpen, isNetworkMenuOpen: state.metamask.isNetworkMenuOpen, + accountDetailsAddress: state.appState.accountDetailsAddress, }; } diff --git a/ui/store/actionConstants.ts b/ui/store/actionConstants.ts index 8dbe40906..73d488a81 100644 --- a/ui/store/actionConstants.ts +++ b/ui/store/actionConstants.ts @@ -35,6 +35,7 @@ export const SHOW_SEND_TOKEN_PAGE = 'SHOW_SEND_TOKEN_PAGE'; export const SHOW_PRIVATE_KEY = 'SHOW_PRIVATE_KEY'; export const SET_ACCOUNT_LABEL = 'SET_ACCOUNT_LABEL'; export const CLEAR_ACCOUNT_DETAILS = 'CLEAR_ACCOUNT_DETAILS'; +export const SET_ACCOUNT_DETAILS_ADDRESS = 'SET_ACCOUNT_DETAILS_ADDRESS'; // tx conf screen export const COMPLETED_TX = 'COMPLETED_TX'; export const TRANSACTION_ERROR = 'TRANSACTION_ERROR'; diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 5f33fecd8..a923bca0f 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -3115,6 +3115,13 @@ export function toggleNetworkMenu() { }; } +export function setAccountDetailsAddress(address: string) { + return { + type: actionConstants.SET_ACCOUNT_DETAILS_ADDRESS, + payload: address, + }; +} + export function setParticipateInMetaMetrics( participationPreference: boolean, ): ThunkAction<