From 0306422bbfce4f6182418bbd07c1af94deb901b3 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Sun, 7 May 2023 05:04:20 +0800 Subject: [PATCH] Add reveal to export private key (#18170) Co-authored-by: George Marshall Co-authored-by: Brad Decker Co-authored-by: David Walsh Co-authored-by: Howard Braham * change js to tsx * update to typescript * add labels to circle animation * add willHide prop to hold to reveal modal * add test * convert to design system * fix lint * fix type * bump coverage * rename * remove comments * remove ts comment and add fix exhuastive dep check * update coverage * add hide modal test * use banneralert * update label * remove unused * fix text * update aria label messages * change exportAccountAndGetPrivateKey to be async * fix lint * update coverage target * update coverage * update input component * update coverage * update coverage * fix blank line * use && * move plainKey to under !privateKeyInput * update hold modal to display srp and private key message * fix styling * fix lint and test * fix unused locales * remove redundent check * update storybook * fix text alignment * fix lint * update snapshot * fix test * update coverage * fix merge conflict * refactor * fix variant * update snapshot * fix test after merge * fix test after merge conflict * fix label text * update to use label component --- app/_locales/de/messages.json | 6 - app/_locales/el/messages.json | 6 - app/_locales/en/messages.json | 24 +- app/_locales/es/messages.json | 6 - app/_locales/fr/messages.json | 6 - app/_locales/hi/messages.json | 6 - app/_locales/id/messages.json | 6 - app/_locales/ja/messages.json | 6 - app/_locales/ko/messages.json | 6 - app/_locales/pt/messages.json | 6 - app/_locales/ru/messages.json | 6 - app/_locales/tl/messages.json | 6 - app/_locales/tr/messages.json | 6 - app/_locales/vi/messages.json | 6 - app/_locales/zh_CN/messages.json | 6 - .../hold-to-reveal-button.js | 4 +- .../hold-to-reveal-button.test.js | 39 +- .../export-private-key-modal.test.js.snap | 65 ++-- .../export-private-key-modal.component.js | 340 +++++++++--------- ...export-private-key-modal.component.test.js | 130 +++++++ .../export-private-key-modal.stories.js | 26 +- .../export-private-key-modal.test.js | 3 +- .../export-private-key-modal/index.scss | 82 ----- .../password-input.js | 50 +++ .../export-private-key-modal/private-key.js | 81 +++++ .../hold-to-reveal-modal.js | 134 +++++-- .../hold-to-reveal-modal.test.js | 19 +- ui/pages/keychains/reveal-seed.js | 1 + ui/pages/keychains/reveal-seed.test.js | 4 +- 29 files changed, 668 insertions(+), 418 deletions(-) create mode 100644 ui/components/app/modals/export-private-key-modal/export-private-key-modal.component.test.js create mode 100644 ui/components/app/modals/export-private-key-modal/password-input.js create mode 100644 ui/components/app/modals/export-private-key-modal/private-key.js diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index b8b8c19df..fceaa5ed5 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -1570,9 +1570,6 @@ "history": { "message": "Verlauf" }, - "holdToReveal": { - "message": "Halten, um GWP anzuzeigen" - }, "holdToRevealContent1": { "message": "Ihre geheime Wiederherstellungsphrase bietet $1", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1593,9 +1590,6 @@ "message": "Betrüger aber schon.", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { - "message": "Bewahren Sie Ihre GWP sicher auf" - }, "ignoreAll": { "message": "Alle ignorieren" }, diff --git a/app/_locales/el/messages.json b/app/_locales/el/messages.json index 06ebced86..d17783925 100644 --- a/app/_locales/el/messages.json +++ b/app/_locales/el/messages.json @@ -1570,9 +1570,6 @@ "history": { "message": "Ιστορικό" }, - "holdToReveal": { - "message": "Κρατήστε πατημένο για αποκάλυψη της ΜΦΑ" - }, "holdToRevealContent1": { "message": "Η Μυστική σας Φράση Ανάκτησης παρέχει $1", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1593,9 +1590,6 @@ "message": "αλλά οι απατεώνες μπορεί να το κάνουν.", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { - "message": "Κρατήστε τη ΜΦΑ σας ασφαλή" - }, "ignoreAll": { "message": "Αγνόηση όλων" }, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 088fe8444..7572190c1 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1754,9 +1754,6 @@ "history": { "message": "History" }, - "holdToReveal": { - "message": "Hold to reveal SRP" - }, "holdToRevealContent1": { "message": "Your Secret Recovery Phrase provides $1", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1777,9 +1774,28 @@ "message": "but phishers might.", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { + "holdToRevealContentPrivateKey1": { + "message": "Your Private Key provides $1", + "description": "$1 is a bolded text with the message from 'holdToRevealContentPrivateKey2'" + }, + "holdToRevealContentPrivateKey2": { + "message": "full access to your wallet and funds.", + "description": "Is the bolded text in 'holdToRevealContentPrivateKey2'" + }, + "holdToRevealLockedLabel": { "message": "hold to reveal circle locked" }, + "holdToRevealPrivateKey": { + "message": "Hold to reveal Private Key" + }, + "holdToRevealPrivateKeyTitle": { + "message": "Keep your private key safe" + }, + "holdToRevealSRP": { + "message": "Hold to reveal SRP" + }, + "holdToRevealSRPTitle": { "message": "Keep your SRP safe" }, + "holdToRevealUnlockedLabel": { "message": "hold to reveal circle unlocked" }, "id": { "message": "Id" }, diff --git a/app/_locales/es/messages.json b/app/_locales/es/messages.json index 67a3b012a..0661e837a 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -1570,9 +1570,6 @@ "history": { "message": "Historial" }, - "holdToReveal": { - "message": "Mantenga presionado para mostrar la SRP" - }, "holdToRevealContent1": { "message": "Su frase secreta de recuperación proporciona $1", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1593,9 +1590,6 @@ "message": "pero los defraudadores sí.", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { - "message": "Mantenga segura su SRP" - }, "ignoreAll": { "message": "Ignorar todo" }, diff --git a/app/_locales/fr/messages.json b/app/_locales/fr/messages.json index b543b2114..920ff11a7 100644 --- a/app/_locales/fr/messages.json +++ b/app/_locales/fr/messages.json @@ -1570,9 +1570,6 @@ "history": { "message": "Historique" }, - "holdToReveal": { - "message": "Appuyez longuement pour révéler la PSR" - }, "holdToRevealContent1": { "message": "Votre phrase secrète de récupération donne $1", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1593,9 +1590,6 @@ "message": "mais les hameçonneurs pourraient le faire.", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { - "message": "Conservez votre PSR en lieu sûr" - }, "ignoreAll": { "message": "Ignorer tout" }, diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index e9781a768..7861fd19e 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -1570,9 +1570,6 @@ "history": { "message": "इतिहास" }, - "holdToReveal": { - "message": "SRP देखने के लिए होल्ड करें" - }, "holdToRevealContent1": { "message": "आपका सीक्रेट रिकवरी फ्रेज $1 प्रदान करता है", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1593,9 +1590,6 @@ "message": "लेकिन फिशर कर सकते हैं।", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { - "message": "अपने SRP को सुरक्षित रखें" - }, "ignoreAll": { "message": "सभी को अनदेखा करें" }, diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index 97df69b6f..dd49e11ed 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -1570,9 +1570,6 @@ "history": { "message": "Riwayat" }, - "holdToReveal": { - "message": "Tahan untuk mengungkap FPR" - }, "holdToRevealContent1": { "message": "Frasa Pemulihan Rahasia memberikan $1", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1593,9 +1590,6 @@ "message": "tetapi penipu akan mencoba memintanya.", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { - "message": "Jaga keamanan FPR Anda" - }, "ignoreAll": { "message": "Abaikan semua" }, diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index a3c0dd28e..6121b09dd 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -1570,9 +1570,6 @@ "history": { "message": "履歴" }, - "holdToReveal": { - "message": "長押しして SRP を表示" - }, "holdToRevealContent1": { "message": "秘密のリカバリーフレーズは$1を提供します。", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1593,9 +1590,6 @@ "message": "もし尋ねられた場合はフィッシング詐欺の可能性があります。", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { - "message": "SRP は安全に保管してください" - }, "ignoreAll": { "message": "すべて無視" }, diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index c4de64fc4..61a439b52 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -1570,9 +1570,6 @@ "history": { "message": "기록" }, - "holdToReveal": { - "message": "눌러서 SRP 확인" - }, "holdToRevealContent1": { "message": "비밀 복구 구문이 있으면 $1 기능을 사용할 수 있습니다", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1593,9 +1590,6 @@ "message": "오히려 피싱 사기꾼들이 요구할 수 있으니 주의가 필요합니다.", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { - "message": "SRP를 안전하게 보관하세요" - }, "ignoreAll": { "message": "모두 무시" }, diff --git a/app/_locales/pt/messages.json b/app/_locales/pt/messages.json index d52ed995d..93adcfea8 100644 --- a/app/_locales/pt/messages.json +++ b/app/_locales/pt/messages.json @@ -1570,9 +1570,6 @@ "history": { "message": "Histórico" }, - "holdToReveal": { - "message": "Segure para revelar a FRS" - }, "holdToRevealContent1": { "message": "Sua Frase de Recuperação Secreta concede $1", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1593,9 +1590,6 @@ "message": "mas os phishers talvez solicitem.", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { - "message": "Mantenha sua FRS em segurança" - }, "ignoreAll": { "message": "Ignorar tudo" }, diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index 1b9a1a6e8..03d546df9 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -1570,9 +1570,6 @@ "history": { "message": "История" }, - "holdToReveal": { - "message": "Удерживайте для отображения СВФ" - }, "holdToRevealContent1": { "message": "Ваша секретная фраза для восстановления дает $1", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1593,9 +1590,6 @@ "message": "но злоумышленники-фишеры могут.", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { - "message": "Обеспечьте безопасность своей СВФ" - }, "ignoreAll": { "message": "Игнорировать все" }, diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index 479683256..bb47c161f 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -1570,9 +1570,6 @@ "history": { "message": "History" }, - "holdToReveal": { - "message": "I-hold para ipakita ang SRP" - }, "holdToRevealContent1": { "message": "Ang iyong Secret Recovery Phrase ay nagbibigay ng $1", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1593,9 +1590,6 @@ "message": "ngunit maaring hingin ng mga phisher.", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { - "message": "Ingatan ang iyong SRP" - }, "ignoreAll": { "message": "Huwag pansinin ang lahat" }, diff --git a/app/_locales/tr/messages.json b/app/_locales/tr/messages.json index 1479da53f..ebabab1c7 100644 --- a/app/_locales/tr/messages.json +++ b/app/_locales/tr/messages.json @@ -1570,9 +1570,6 @@ "history": { "message": "Geçmiş" }, - "holdToReveal": { - "message": "GKİ'yi göstermek için basılı tut" - }, "holdToRevealContent1": { "message": "Gizli Kurtarma İfadeniz: $1", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1593,9 +1590,6 @@ "message": "ancak dolandırıcılar talep edilebilir.", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { - "message": "GKİ'nizi güvende tutun" - }, "ignoreAll": { "message": "Tümünü yoksay" }, diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index 4bb67b89e..120efa397 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -1570,9 +1570,6 @@ "history": { "message": "Lịch sử" }, - "holdToReveal": { - "message": "Giữ để hiển thị Cụm từ khôi phục bí mật" - }, "holdToRevealContent1": { "message": "Cụm từ khôi phục bí mật của bạn cung cấp $1", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1593,9 +1590,6 @@ "message": "nhưng những kẻ lừa đảo qua mạng thì có.", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { - "message": "Đảm bảo an toàn cho Cụm từ khôi phục bí mật của bạn" - }, "ignoreAll": { "message": "Bỏ qua tất cả" }, diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index 3bbbf0165..ffe567145 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -1570,9 +1570,6 @@ "history": { "message": "历史记录" }, - "holdToReveal": { - "message": "按住以显示 SRP" - }, "holdToRevealContent1": { "message": "您的助记词提供 $1", "description": "$1 is a bolded text with the message from 'holdToRevealContent2'" @@ -1593,9 +1590,6 @@ "message": "但网络钓鱼者可能会。", "description": "The text link in 'holdToRevealContent3'" }, - "holdToRevealTitle": { - "message": "确保 SRP 的安全" - }, "ignoreAll": { "message": "忽略所有" }, diff --git a/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.js b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.js index fe2b422d4..b12443ccd 100644 --- a/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.js +++ b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.js @@ -121,7 +121,7 @@ export default function HoldToRevealButton({ buttonText, onLongPressed }) {
diff --git a/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.test.js b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.test.js index 16c6c0917..2fd23e8ef 100644 --- a/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.test.js +++ b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.test.js @@ -1,20 +1,21 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; +import configureMockState from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import mockState from '../../../../test/data/mock-state.json'; import { MetaMetricsEventCategory, MetaMetricsEventKeyType, MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; import HoldToRevealButton from './hold-to-reveal-button'; const mockTrackEvent = jest.fn(); -jest.mock('react', () => ({ - ...jest.requireActual('react'), - useContext: () => mockTrackEvent, -})); - describe('HoldToRevealButton', () => { + const mockStore = configureMockState([thunk])(mockState); let props = {}; beforeEach(() => { @@ -51,21 +52,24 @@ describe('HoldToRevealButton', () => { }); it('should show the locked padlock when a button is long pressed and then should show it after it was lifted off before the animation concludes', async () => { - const { getByText, queryByLabelText } = render( - , + const { getByText, queryByLabelText } = renderWithProvider( + + + , + mockStore, ); const button = getByText('Hold to reveal SRP'); fireEvent.mouseDown(button); - const circleLocked = queryByLabelText('circle-locked'); + const circleLocked = queryByLabelText('hold to reveal circle locked'); await waitFor(() => { expect(circleLocked).toBeInTheDocument(); }); fireEvent.mouseUp(button); - const circleUnlocked = queryByLabelText('circle-unlocked'); + const circleUnlocked = queryByLabelText('hold to reveal circle unlocked'); await waitFor(() => { expect(circleUnlocked).not.toBeInTheDocument(); @@ -73,37 +77,40 @@ describe('HoldToRevealButton', () => { }); it('should show the unlocked padlock when a button is long pressed for the duration of the animation', async () => { - const { getByText, queryByLabelText } = render( - , + const { getByText, queryByLabelText, getByLabelText } = renderWithProvider( + + + , + mockStore, ); const button = getByText('Hold to reveal SRP'); fireEvent.mouseDown(button); - const circleLocked = queryByLabelText('circle-locked'); + const circleLocked = getByLabelText('hold to reveal circle locked'); fireEvent.transitionEnd(circleLocked); - const circleUnlocked = queryByLabelText('circle-unlocked'); + const circleUnlocked = queryByLabelText('hold to reveal circle unlocked'); fireEvent.animationEnd(circleUnlocked); await waitFor(() => { expect(circleUnlocked).toBeInTheDocument(); - expect(mockTrackEvent).toHaveBeenNthCalledWith(2, { + expect(mockTrackEvent).toHaveBeenNthCalledWith(1, { category: MetaMetricsEventCategory.Keys, event: MetaMetricsEventName.SrpHoldToRevealClickStarted, properties: { key_type: MetaMetricsEventKeyType.Srp, }, }); - expect(mockTrackEvent).toHaveBeenNthCalledWith(5, { + expect(mockTrackEvent).toHaveBeenNthCalledWith(2, { category: MetaMetricsEventCategory.Keys, event: MetaMetricsEventName.SrpHoldToRevealCompleted, properties: { key_type: MetaMetricsEventKeyType.Srp, }, }); - expect(mockTrackEvent).toHaveBeenNthCalledWith(6, { + expect(mockTrackEvent).toHaveBeenNthCalledWith(3, { category: MetaMetricsEventCategory.Keys, event: MetaMetricsEventName.SrpRevealViewed, properties: { diff --git a/ui/components/app/modals/export-private-key-modal/__snapshots__/export-private-key-modal.test.js.snap b/ui/components/app/modals/export-private-key-modal/__snapshots__/export-private-key-modal.test.js.snap index f275c351e..be40a2b06 100644 --- a/ui/components/app/modals/export-private-key-modal/__snapshots__/export-private-key-modal.test.js.snap +++ b/ui/components/app/modals/export-private-key-modal/__snapshots__/export-private-key-modal.test.js.snap @@ -59,57 +59,76 @@ exports[`Export PrivateKey Modal should match snapshot 1`] = ` class="account-modal__close" />
0x0DCD5D886577d5081B0c52e242Ef29E70Be3E7bc
- Show Private Keys - +

- Type your MetaMask password - - +
+ > + +
+

- Warning: Never disclose this key. Anyone with your private keys can steal any assets held in your account. + +
+

+ Warning: Never disclose this key. Anyone with your private keys can steal any assets held in your account. +

+
diff --git a/ui/components/app/modals/export-private-key-modal/export-private-key-modal.component.js b/ui/components/app/modals/export-private-key-modal/export-private-key-modal.component.js index b50c3de85..49c4f7be2 100644 --- a/ui/components/app/modals/export-private-key-modal/export-private-key-modal.component.js +++ b/ui/components/app/modals/export-private-key-modal/export-private-key-modal.component.js @@ -1,139 +1,188 @@ import log from 'loglevel'; +import React, { useContext, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import React, { Component } from 'react'; - -import copyToClipboard from 'copy-to-clipboard'; -import Button from '../../../ui/button'; -import AccountModalContainer from '../account-modal-container'; +import withModalProps from '../../../../helpers/higher-order-components/with-modal-props'; +import Box from '../../../ui/box'; import { - toChecksumHexAddress, - stripHexPrefix, -} from '../../../../../shared/modules/hexstring-utils'; + BUTTON_SIZES, + BUTTON_VARIANT, + BannerAlert, + Button, + Text, +} from '../../../component-library'; +import AccountModalContainer from '../account-modal-container'; +import { toChecksumHexAddress } from '../../../../../shared/modules/hexstring-utils'; import { MetaMetricsEventCategory, MetaMetricsEventKeyType, MetaMetricsEventName, } from '../../../../../shared/constants/metametrics'; +import HoldToRevealModal from '../hold-to-reveal-modal/hold-to-reveal-modal'; +import { MetaMetricsContext } from '../../../../contexts/metametrics'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { + BLOCK_SIZES, + BorderColor, + BorderStyle, + Color, + DISPLAY, + FLEX_DIRECTION, + FONT_WEIGHT, + JustifyContent, + TextVariant, +} from '../../../../helpers/constants/design-system'; +import PrivateKeyDisplay from './private-key'; +import PasswordInput from './password-input'; -export default class ExportPrivateKeyModal extends Component { - static contextTypes = { - t: PropTypes.func, - trackEvent: PropTypes.func, - }; +const ExportPrivateKeyModal = ({ + clearAccountDetails, + hideWarning, + exportAccount, + selectedIdentity, + showAccountDetailModal, + hideModal, + warning = null, + previousModalState, +}) => { + const [password, setPassword] = useState(''); + const [privateKey, setPrivateKey] = useState(null); + const [showWarning, setShowWarning] = useState(true); + const [showHoldToReveal, setShowHoldToReveal] = useState(false); + const trackEvent = useContext(MetaMetricsContext); + const t = useI18nContext(); - static defaultProps = { - warning: null, - previousModalState: null, - }; + useEffect(() => { + return () => { + clearAccountDetails(); + hideWarning(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - static propTypes = { - exportAccount: PropTypes.func.isRequired, - selectedIdentity: PropTypes.object.isRequired, - warning: PropTypes.node, - showAccountDetailModal: PropTypes.func.isRequired, - hideModal: PropTypes.func.isRequired, - hideWarning: PropTypes.func.isRequired, - clearAccountDetails: PropTypes.func.isRequired, - previousModalState: PropTypes.string, - }; - - state = { - password: '', - privateKey: null, - showWarning: true, - }; - - componentWillUnmount() { - this.props.clearAccountDetails(); - this.props.hideWarning(); - } - - exportAccountAndGetPrivateKey = (password, address) => { - const { exportAccount } = this.props; - - exportAccount(password, address) - .then((privateKey) => { - this.context.trackEvent({ + const exportAccountAndGetPrivateKey = async (passwordInput, address) => { + try { + const privateKeyRetrieved = await exportAccount(passwordInput, address); + trackEvent( + { category: MetaMetricsEventCategory.Keys, event: MetaMetricsEventName.KeyExportRevealed, properties: { key_type: MetaMetricsEventKeyType.Pkey, }, - }); - - this.setState({ - privateKey, - showWarning: false, - }); - }) - .catch((e) => { - this.context.trackEvent({ + }, + {}, + ); + setPrivateKey(privateKeyRetrieved); + setShowWarning(false); + setShowHoldToReveal(true); + } catch (e) { + trackEvent( + { category: MetaMetricsEventCategory.Keys, event: MetaMetricsEventName.KeyExportFailed, properties: { key_type: MetaMetricsEventKeyType.Pkey, reason: 'incorrect_password', }, - }); + }, + {}, + ); - log.error(e); - }); + log.error(e); + } }; - renderPasswordLabel(privateKey) { + const { name, address } = selectedIdentity; + + if (showHoldToReveal) { return ( - - {privateKey - ? this.context.t('copyPrivateKey') - : this.context.t('typePassword')} - - ); - } - - renderPasswordInput(privateKey) { - const plainKey = privateKey && stripHexPrefix(privateKey); - - if (!privateKey) { - return ( - this.setState({ password: event.target.value })} - /> - ); - } - - return ( -
{ - copyToClipboard(plainKey); - this.context.trackEvent({ - category: MetaMetricsEventCategory.Keys, - event: MetaMetricsEventName.KeyExportCopied, - properties: { - key_type: MetaMetricsEventKeyType.Pkey, - copy_method: 'clipboard', - }, - }); - }} + showAccountDetailModal()} > - {plainKey} -
+ setShowHoldToReveal(false)} + willHide={false} + holdToRevealType="PrivateKey" + /> + ); } - renderButtons(privateKey, address, hideModal) { - return ( -
+ return ( + showAccountDetailModal()} + > + + {name} + + + {toChecksumHexAddress(address)} + + + + {t('showPrivateKeys')} + + {privateKey ? ( + + ) : ( + + )} + {showWarning && ( + + {warning} + + )} + + {t('privateKeyWarning')} + + {!privateKey && ( )} {privateKey ? ( ) : ( )} -
- ); - } + + + ); +}; - render() { - const { - selectedIdentity, - warning, - showAccountDetailModal, - hideModal, - previousModalState, - } = this.props; - const { name, address } = selectedIdentity; +ExportPrivateKeyModal.propTypes = { + exportAccount: PropTypes.func.isRequired, + selectedIdentity: PropTypes.object.isRequired, + warning: PropTypes.node, + showAccountDetailModal: PropTypes.func.isRequired, + hideModal: PropTypes.func.isRequired, + hideWarning: PropTypes.func.isRequired, + clearAccountDetails: PropTypes.func.isRequired, + previousModalState: PropTypes.string, +}; - const { privateKey, showWarning } = this.state; - - return ( - showAccountDetailModal()} - > - {name} -
- {toChecksumHexAddress(address)} -
-
- - {this.context.t('showPrivateKeys')} - -
- {this.renderPasswordLabel(privateKey)} - {this.renderPasswordInput(privateKey)} - {showWarning && warning ? ( - - {warning} - - ) : null} -
-
- {this.context.t('privateKeyWarning')} -
- {this.renderButtons(privateKey, address, hideModal)} - - ); - } -} +export default withModalProps(ExportPrivateKeyModal); diff --git a/ui/components/app/modals/export-private-key-modal/export-private-key-modal.component.test.js b/ui/components/app/modals/export-private-key-modal/export-private-key-modal.component.test.js new file mode 100644 index 000000000..f4a913280 --- /dev/null +++ b/ui/components/app/modals/export-private-key-modal/export-private-key-modal.component.test.js @@ -0,0 +1,130 @@ +import { fireEvent, waitFor } from '@testing-library/react'; +import configureMockStore from 'redux-mock-store'; +import React from 'react'; +import thunk from 'redux-thunk'; +import { renderWithProvider } from '../../../../../test/lib/render-helpers'; +import ExportPrivateKeyModal from '.'; + +const mockAddress = '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'; +const mockPrivateKey = 'mock private key'; +const mockExportAccount = jest.fn().mockResolvedValue(mockPrivateKey); +const mockClearAccountDetail = jest.fn(); +const mockHideWarning = jest.fn(); + +jest.mock('../../../../store/actions', () => ({ + exportAccount: () => mockExportAccount, + clearAccountDetails: () => mockClearAccountDetail, + hideWarning: () => mockHideWarning, +})); + +describe('Export Private Key Modal', () => { + const state = { + metamask: { + selectedAddress: mockAddress, + identities: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + name: 'Test Account', + }, + }, + providerConfig: { + type: 'rpc', + chainId: '0x5', + ticker: 'ETH', + id: 'testNetworkConfigurationId', + }, + }, + appState: { + warning: null, + previousModalState: { + name: null, + }, + isLoading: false, + accountDetail: { + privateKey: null, + }, + modal: { + modalState: {}, + previousModalState: { + name: null, + }, + }, + }, + }; + const mockStore = configureMockStore([thunk])(state); + + it('renders export private key modal', () => { + const { queryByText } = renderWithProvider( + , + mockStore, + ); + + const title = queryByText('Show Private Keys'); + expect(title).toBeInTheDocument(); + + const warning = queryByText( + 'Warning: Never disclose this key. Anyone with your private keys can steal any assets held in your account.', + ); + expect(warning).toBeInTheDocument(); + expect(queryByText(mockPrivateKey)).not.toBeInTheDocument(); + }); + + it('renders hold to reveal after entering password', async () => { + const { queryByText, getByPlaceholderText } = renderWithProvider( + , + mockStore, + ); + + const nextButton = queryByText('Confirm'); + expect(nextButton).toBeInTheDocument(); + + const input = getByPlaceholderText('Enter password'); + + fireEvent.change(input, { + target: { value: 'password' }, + }); + + fireEvent.click(nextButton); + + await waitFor(() => { + expect(mockExportAccount).toHaveBeenCalled(); + expect(queryByText('Keep your private key safe')).toBeInTheDocument(); + }); + }); + + it('provides password after passing hold to reveal', async () => { + const { queryByText, getByLabelText, getByText, getByPlaceholderText } = + renderWithProvider(, mockStore); + + const nextButton = queryByText('Confirm'); + expect(nextButton).toBeInTheDocument(); + + const input = getByPlaceholderText('Enter password'); + fireEvent.change(input, { + target: { value: 'password' }, + }); + + fireEvent.click(nextButton); + + await waitFor(() => { + expect(mockExportAccount).toHaveBeenCalled(); + expect(queryByText('Keep your private key safe')).toBeInTheDocument(); + }); + + const holdButton = getByText('Hold to reveal Private Key'); + expect(holdButton).toBeInTheDocument(); + + fireEvent.mouseDown(holdButton); + + const circle = getByLabelText('hold to reveal circle locked'); + fireEvent.transitionEnd(circle); + const circleUnlocked = getByLabelText('hold to reveal circle unlocked'); + fireEvent.animationEnd(circleUnlocked); + + await waitFor(() => { + expect(queryByText('Show Private Keys')).toBeInTheDocument(); + expect(queryByText('Done')).toBeInTheDocument(); + expect(queryByText(mockPrivateKey)).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/components/app/modals/export-private-key-modal/export-private-key-modal.stories.js b/ui/components/app/modals/export-private-key-modal/export-private-key-modal.stories.js index 39af8761f..36ba01b89 100644 --- a/ui/components/app/modals/export-private-key-modal/export-private-key-modal.stories.js +++ b/ui/components/app/modals/export-private-key-modal/export-private-key-modal.stories.js @@ -1,10 +1,32 @@ import React from 'react'; -import ExportPrivateKeyModal from '.'; +import { Provider } from 'react-redux'; +import testData from '../../../../../.storybook/test-data'; +import configureStore from '../../../../store/store'; +import ExportPrivateKeyModal from './export-private-key-modal.component'; + +// Using Test Data For Redux +const store = configureStore(testData); export default { title: 'Components/App/Modals/ExportPrivateKeyModal', + decorators: [(story) => {story()}], + argsTypes: { + exportAccount: { action: 'exportAccount' }, + }, }; -export const DefaultStory = () => ; +export const DefaultStory = () => { + return ( + { + return 'mockPrivateKey'; + }} + selectedIdentity={ + testData.metamask.identities[testData.metamask.selectedAddress] + } + /> + ); +}; DefaultStory.storyName = 'Default'; diff --git a/ui/components/app/modals/export-private-key-modal/export-private-key-modal.test.js b/ui/components/app/modals/export-private-key-modal/export-private-key-modal.test.js index bc5e38df2..873be62b6 100644 --- a/ui/components/app/modals/export-private-key-modal/export-private-key-modal.test.js +++ b/ui/components/app/modals/export-private-key-modal/export-private-key-modal.test.js @@ -59,7 +59,8 @@ describe('Export PrivateKey Modal', () => { mockStore, ); - const passwordInput = queryByTestId('password-input'); + const passwordInput = + queryByTestId('password-input').querySelector('input'); const passwordInputEvent = { target: { diff --git a/ui/components/app/modals/export-private-key-modal/index.scss b/ui/components/app/modals/export-private-key-modal/index.scss index 46e12a102..8edde5e39 100644 --- a/ui/components/app/modals/export-private-key-modal/index.scss +++ b/ui/components/app/modals/export-private-key-modal/index.scss @@ -1,11 +1,4 @@ .export-private-key-modal { - &__body-title { - @include H4; - - margin-top: 16px; - margin-bottom: 16px; - } - &__divider { width: 100%; height: 1px; @@ -13,93 +6,18 @@ background-color: var(--color-border-default); } - &__account-name { - @include H4; - - margin-top: 9px; - } - - &__password { - display: flex; - flex-direction: column; - } - - &__password-label, - &__password--error { - @include H6; - - color: var(--color-text-default); - margin-bottom: 10px; - } - - &__password--error { - color: var(--color-error-default); - margin-bottom: 0; - } - - &__password-input { - @include Paragraph; - - padding: 10px 0 13px 17px; - width: 291px; - height: 44px; - background: var(--color-background-default); - color: var(--color-text-default); - border: 1px solid var(--color-border-default); - } - &__password::-webkit-input-placeholder { color: var(--color-text-muted); } - &__password--warning { - @include H7; - - border-radius: 8px; - background-color: var(--color-error-muted); - font-weight: 500; - color: var(--color-text-default); - border: 1px solid var(--color-error-default); - width: 292px; - padding: 9px 15px; - margin-top: 18px; - } - &__private-key-display { - @include Paragraph; - height: 80px; width: 291px; - border: 1px solid var(--color-border-default); - border-radius: 2px; - color: var(--color-error-default); - padding: 9px 13px 8px; overflow: hidden; overflow-wrap: break-word; } - &__buttons { - display: flex; - flex-direction: row; - justify-content: center; - width: 100%; - padding: 0 25px; - } - - &__button { - margin-top: 17px; - width: 141px; - min-width: initial; - } - - &__button--cancel { - margin-right: 15px; - } - .ellip-address-wrapper { - border: 1px solid var(--color-border-default); - padding: 5px 10px; - margin-top: 7px; max-width: 286px; direction: ltr; overflow: hidden; diff --git a/ui/components/app/modals/export-private-key-modal/password-input.js b/ui/components/app/modals/export-private-key-modal/password-input.js new file mode 100644 index 000000000..4892055bd --- /dev/null +++ b/ui/components/app/modals/export-private-key-modal/password-input.js @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + BLOCK_SIZES, + FLEX_DIRECTION, + DISPLAY, + AlignItems, + Color, + TextVariant, +} from '../../../../helpers/constants/design-system'; +import Box from '../../../ui/box'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { Label, TEXT_FIELD_TYPES, TextField } from '../../../component-library'; + +const PasswordInput = ({ setPassword }) => { + const t = useI18nContext(); + + return ( + + + setPassword(event.target.value)} + data-testid="password-input" + /> + + ); +}; + +PasswordInput.propTypes = { + setPassword: PropTypes.func.isRequired, +}; + +export default PasswordInput; diff --git a/ui/components/app/modals/export-private-key-modal/private-key.js b/ui/components/app/modals/export-private-key-modal/private-key.js new file mode 100644 index 000000000..c2200ed47 --- /dev/null +++ b/ui/components/app/modals/export-private-key-modal/private-key.js @@ -0,0 +1,81 @@ +import copyToClipboard from 'copy-to-clipboard'; +import { stripHexPrefix } from 'ethereumjs-util'; +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import Box from '../../../ui/box'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, + MetaMetricsEventKeyType, +} from '../../../../../shared/constants/metametrics'; +import { MetaMetricsContext } from '../../../../contexts/metametrics'; +import { + BLOCK_SIZES, + BorderStyle, + BorderColor, + BorderRadius, + AlignItems, + DISPLAY, + Color, + FLEX_DIRECTION, + TextVariant, +} from '../../../../helpers/constants/design-system'; +import { Label } from '../../../component-library'; + +const PrivateKeyDisplay = ({ privateKey }) => { + const trackEvent = useContext(MetaMetricsContext); + const t = useI18nContext(); + const plainKey = stripHexPrefix(privateKey); + + return ( + + + { + copyToClipboard(plainKey); + trackEvent( + { + category: MetaMetricsEventCategory.Keys, + event: MetaMetricsEventName.KeyExportCopied, + properties: { + key_type: MetaMetricsEventKeyType.Pkey, + copy_method: 'clipboard', + }, + }, + {}, + ); + }} + > + {plainKey} + + + ); +}; + +PrivateKeyDisplay.propTypes = { + privateKey: PropTypes.string.isRequired, +}; + +export default PrivateKeyDisplay; diff --git a/ui/components/app/modals/hold-to-reveal-modal/hold-to-reveal-modal.js b/ui/components/app/modals/hold-to-reveal-modal/hold-to-reveal-modal.js index 4a4f79581..3377127c7 100644 --- a/ui/components/app/modals/hold-to-reveal-modal/hold-to-reveal-modal.js +++ b/ui/components/app/modals/hold-to-reveal-modal/hold-to-reveal-modal.js @@ -5,6 +5,7 @@ import Box from '../../../ui/box'; import { Text, Button, + BUTTON_SIZES, BUTTON_VARIANT, ButtonIcon, IconName, @@ -27,52 +28,80 @@ import { MetaMetricsEventName, } from '../../../../../shared/constants/metametrics'; -const HoldToRevealModal = ({ onLongPressed, hideModal }) => { +const HoldToRevealModal = ({ + onLongPressed, + hideModal, + willHide = true, + holdToRevealType = 'SRP', +}) => { const t = useI18nContext(); + const holdToRevealTitle = + holdToRevealType === 'SRP' + ? 'holdToRevealSRPTitle' + : 'holdToRevealPrivateKeyTitle'; + + const holdToRevealButton = + holdToRevealType === 'SRP' ? 'holdToRevealSRP' : 'holdToRevealPrivateKey'; const trackEvent = useContext(MetaMetricsContext); const unlock = () => { onLongPressed(); - hideModal(); + if (willHide) { + hideModal(); + } }; const handleCancel = () => { hideModal(); }; - return ( - + const renderHoldToRevealPrivateKeyContent = () => { + return ( - {t('holdToRevealTitle')} - { - trackEvent({ - category: MetaMetricsEventCategory.Keys, - event: MetaMetricsEventName.SrpHoldToRevealCloseClicked, - properties: { - key_type: MetaMetricsEventKeyType.Srp, - }, - }); - handleCancel(); - }} - ariaLabel={t('close')} - /> + + {t('holdToRevealContentPrivateKey1', [ + + {t('holdToRevealContentPrivateKey2')} + , + ])} + + + {t('holdToRevealContent3', [ + + {t('holdToRevealContent4')} + , + , + ])} + + ); + }; + + const renderHoldToRevealSRPContent = () => { + return ( { ])} + ); + }; + + return ( + + + {t(holdToRevealTitle)} + {willHide && ( + { + trackEvent({ + category: MetaMetricsEventCategory.Keys, + event: MetaMetricsEventName.SrpHoldToRevealCloseClicked, + properties: { + key_type: MetaMetricsEventKeyType.Srp, + }, + }); + handleCancel(); + }} + ariaLabel={t('close')} + /> + )} + + {holdToRevealType === 'SRP' + ? renderHoldToRevealSRPContent() + : renderHoldToRevealPrivateKeyContent()} { const onLongPressStub = jest.fn(); const hideModalStub = jest.fn(); - global.platform = { openTab: jest.fn() }; - afterEach(() => { jest.resetAllMocks(); }); @@ -36,6 +34,7 @@ describe('Hold to Reveal Modal', () => { , mockStore, ); @@ -43,7 +42,7 @@ describe('Hold to Reveal Modal', () => { const holdButton = getByText('Hold to reveal SRP'); expect(holdButton).toBeInTheDocument(); - const warningTitle = getByText(holdToRevealTitle.message); + const warningTitle = getByText(holdToRevealSRPTitle.message); expect(warningTitle).toBeInTheDocument(); const warningText1 = getByText( holdToRevealContent1.message.replace(' $1', ''), @@ -83,12 +82,12 @@ describe('Hold to Reveal Modal', () => { ); const holdButton = getByText('Hold to reveal SRP'); - const circleLocked = queryByLabelText('circle-locked'); + const circleLocked = queryByLabelText('hold to reveal circle locked'); fireEvent.mouseDown(holdButton); fireEvent.transitionEnd(circleLocked); - const circleUnlocked = queryByLabelText('circle-unlocked'); + const circleUnlocked = queryByLabelText('hold to reveal circle unlocked'); fireEvent.animationEnd(circleUnlocked); await waitFor(() => { @@ -112,8 +111,8 @@ describe('Hold to Reveal Modal', () => { fireEvent.click(holdButton); - const circleLocked = queryByLabelText('circle-locked'); - const circleUnlocked = queryByLabelText('circle-unlocked'); + const circleLocked = queryByLabelText('hold to reveal circle locked'); + const circleUnlocked = queryByLabelText('hold to reveal circle unlocked'); await waitFor(() => { expect(circleLocked).toBeInTheDocument(); @@ -163,12 +162,12 @@ describe('Hold to Reveal Modal', () => { ); const holdButton = getByText('Hold to reveal SRP'); - const circleLocked = queryByLabelText('circle-locked'); + const circleLocked = queryByLabelText('hold to reveal circle locked'); fireEvent.mouseDown(holdButton); fireEvent.transitionEnd(circleLocked); - const circleUnlocked = queryByLabelText('circle-unlocked'); + const circleUnlocked = queryByLabelText('hold to reveal circle unlocked'); fireEvent.animationEnd(circleUnlocked); await waitFor(() => { diff --git a/ui/pages/keychains/reveal-seed.js b/ui/pages/keychains/reveal-seed.js index 81c264abc..0fa7aa948 100644 --- a/ui/pages/keychains/reveal-seed.js +++ b/ui/pages/keychains/reveal-seed.js @@ -91,6 +91,7 @@ const RevealSeedPage = () => { setCompletedLongPress(true); setScreen(REVEAL_SEED_SCREEN); }, + holdToRevealType: 'SRP', }), ); }) diff --git a/ui/pages/keychains/reveal-seed.test.js b/ui/pages/keychains/reveal-seed.test.js index aec802384..58baa7503 100644 --- a/ui/pages/keychains/reveal-seed.test.js +++ b/ui/pages/keychains/reveal-seed.test.js @@ -214,12 +214,12 @@ describe('Reveal Seed Page', () => { }); const holdButton = getByText('Hold to reveal SRP'); - const circleLocked = queryByLabelText('circle-locked'); + const circleLocked = queryByLabelText('hold to reveal circle locked'); fireEvent.mouseDown(holdButton); fireEvent.transitionEnd(circleLocked); - const circleUnlocked = queryByLabelText('circle-unlocked'); + const circleUnlocked = queryByLabelText('hold to reveal circle unlocked'); fireEvent.animationEnd(circleUnlocked); await waitFor(() => {