diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 136ae4817..35e6fbd15 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -399,6 +399,9 @@ "backToAll": { "message": "Back to all" }, + "backup": { + "message": "Backup" + }, "backupApprovalInfo": { "message": "This secret code is required to recover your wallet in case you lose your device, forget your password, have to re-install MetaMask, or want to access your wallet on another device." }, @@ -408,6 +411,12 @@ "backupNow": { "message": "Backup now" }, + "backupUserData": { + "message": "Backup your data" + }, + "backupUserDataDescription": { + "message": "You can backup user settings containing preferences and account addresses into a JSON file." + }, "balance": { "message": "Balance" }, @@ -2768,6 +2777,18 @@ "restore": { "message": "Restore" }, + "restoreFailed": { + "message": "Can not restore your data from the file provided" + }, + "restoreSuccessful": { + "message": "Your data has been restored successfully" + }, + "restoreUserData": { + "message": "Restore user data" + }, + "restoreUserDataDescription": { + "message": "You can restore user settings containing preferences and account addresses from a previously backed up JSON file." + }, "restoreWalletPreferences": { "message": "A backup of your data from $1 has been found. Would you like to restore your wallet preferences?", "description": "$1 is the date at which the data was backed up" diff --git a/app/scripts/controllers/backup.js b/app/scripts/controllers/backup.js new file mode 100644 index 000000000..4d7b5c371 --- /dev/null +++ b/app/scripts/controllers/backup.js @@ -0,0 +1,77 @@ +import { exportAsFile } from '../../../shared/modules/export-utils'; +import { prependZero } from '../../../shared/modules/string-utils'; + +export default class BackupController { + constructor(opts = {}) { + const { + preferencesController, + addressBookController, + trackMetaMetricsEvent, + } = opts; + + this.preferencesController = preferencesController; + this.addressBookController = addressBookController; + this._trackMetaMetricsEvent = trackMetaMetricsEvent; + } + + async restoreUserData(jsonString) { + const existingPreferences = this.preferencesController.store.getState(); + const { preferences, addressBook } = JSON.parse(jsonString); + if (preferences) { + preferences.identities = existingPreferences.identities; + preferences.lostIdentities = existingPreferences.lostIdentities; + preferences.selectedAddress = existingPreferences.selectedAddress; + + this.preferencesController.store.updateState(preferences); + } + + if (addressBook) { + this.addressBookController.update(addressBook, true); + } + + if (preferences && addressBook) { + this._trackMetaMetricsEvent({ + event: 'User Data Imported', + category: 'Backup', + }); + } + } + + async backupUserData() { + const userData = { + preferences: { ...this.preferencesController.store.getState() }, + addressBook: { ...this.addressBookController.state }, + }; + + /** + * We can remove these properties since we will won't be restoring identities from backup + */ + delete userData.preferences.identities; + delete userData.preferences.lostIdentities; + delete userData.preferences.selectedAddress; + + const result = JSON.stringify(userData); + + const date = new Date(); + + const prefixZero = (num) => prependZero(num, 2); + + /* + * userData.YYYY_MM_DD_HH_mm_SS e.g userData.2022_01_13_13_45_56 + * */ + const userDataFileName = `MetaMaskUserData.${date.getFullYear()}_${prefixZero( + date.getMonth() + 1, + )}_${prefixZero(date.getDay())}_${prefixZero(date.getHours())}_${prefixZero( + date.getMinutes(), + )}_${prefixZero(date.getDay())}.json`; + + exportAsFile(userDataFileName, result); + + this._trackMetaMetricsEvent({ + event: 'User Data Exported', + category: 'Backup', + }); + + return result; + } +} diff --git a/app/scripts/controllers/backup.test.js b/app/scripts/controllers/backup.test.js new file mode 100644 index 000000000..b4e740bcf --- /dev/null +++ b/app/scripts/controllers/backup.test.js @@ -0,0 +1,118 @@ +import { strict as assert } from 'assert'; +import sinon from 'sinon'; +import BackupController from './backup'; + +function getMockController() { + const mcState = { + getSelectedAddress: sinon.stub().returns('0x01'), + selectedAddress: '0x01', + identities: { + '0x295e26495CEF6F69dFA69911d9D8e4F3bBadB89B': { + address: '0x295e26495CEF6F69dFA69911d9D8e4F3bBadB89B', + lastSelected: 1655380342907, + name: 'Account 3', + }, + }, + lostIdentities: { + '0xfd59bbe569376e3d3e4430297c3c69ea93f77435': { + address: '0xfd59bbe569376e3d3e4430297c3c69ea93f77435', + lastSelected: 1655379648197, + name: 'Ledger 1', + }, + }, + update: (store) => (mcState.store = store), + }; + + mcState.store = { + getState: sinon.stub().returns(mcState), + updateState: (store) => (mcState.store = store), + }; + + return mcState; +} + +const jsonData = `{"preferences":{"frequentRpcListDetail":[{"chainId":"0x539","nickname":"Localhost 8545","rpcPrefs":{},"rpcUrl":"http://localhost:8545","ticker":"ETH"},{"chainId":"0x38","nickname":"Binance Smart Chain Mainnet","rpcPrefs":{"blockExplorerUrl":"https://bscscan.com"},"rpcUrl":"https://bsc-dataseed1.binance.org","ticker":"BNB"},{"chainId":"0x61","nickname":"Binance Smart Chain Testnet","rpcPrefs":{"blockExplorerUrl":"https://testnet.bscscan.com"},"rpcUrl":"https://data-seed-prebsc-1-s1.binance.org:8545","ticker":"tBNB"},{"chainId":"0x89","nickname":"Polygon Mainnet","rpcPrefs":{"blockExplorerUrl":"https://polygonscan.com"},"rpcUrl":"https://polygon-rpc.com","ticker":"MATIC"}],"useBlockie":false,"useNonceField":false,"usePhishDetect":true,"dismissSeedBackUpReminder":false,"useTokenDetection":false,"useCollectibleDetection":false,"openSeaEnabled":false,"advancedGasFee":null,"featureFlags":{"sendHexData":true,"showIncomingTransactions":true},"knownMethodData":{},"currentLocale":"en","forgottenPassword":false,"preferences":{"hideZeroBalanceTokens":false,"showFiatInTestnets":false,"showTestNetworks":true,"useNativeCurrencyAsPrimaryCurrency":true},"ipfsGateway":"dweb.link","infuraBlocked":false,"ledgerTransportType":"webhid","theme":"light","customNetworkListEnabled":false,"textDirection":"auto"},"addressBook":{"addressBook":{"0x61":{"0x42EB768f2244C8811C63729A21A3569731535f06":{"address":"0x42EB768f2244C8811C63729A21A3569731535f06","chainId":"0x61","isEns":false,"memo":"","name":""}}}}}`; + +describe('BackupController', function () { + const getBackupController = () => { + return new BackupController({ + preferencesController: getMockController(), + addressBookController: getMockController(), + trackMetaMetricsEvent: sinon.stub(), + }); + }; + + describe('constructor', function () { + it('should setup correctly', async function () { + const backupController = getBackupController(); + const selectedAddress = + backupController.preferencesController.getSelectedAddress(); + assert.equal(selectedAddress, '0x01'); + }); + + it('should restore backup', async function () { + const backupController = getBackupController(); + backupController.restoreUserData(jsonData); + // check Preferences backup + assert.equal( + backupController.preferencesController.store.frequentRpcListDetail[0] + .chainId, + '0x539', + ); + assert.equal( + backupController.preferencesController.store.frequentRpcListDetail[1] + .chainId, + '0x38', + ); + // make sure identities are not lost after restore + assert.equal( + backupController.preferencesController.store.identities[ + '0x295e26495CEF6F69dFA69911d9D8e4F3bBadB89B' + ].lastSelected, + 1655380342907, + ); + assert.equal( + backupController.preferencesController.store.identities[ + '0x295e26495CEF6F69dFA69911d9D8e4F3bBadB89B' + ].name, + 'Account 3', + ); + assert.equal( + backupController.preferencesController.store.lostIdentities[ + '0xfd59bbe569376e3d3e4430297c3c69ea93f77435' + ].lastSelected, + 1655379648197, + ); + assert.equal( + backupController.preferencesController.store.lostIdentities[ + '0xfd59bbe569376e3d3e4430297c3c69ea93f77435' + ].name, + 'Ledger 1', + ); + // make sure selected address is not lost after restore + assert.equal( + backupController.preferencesController.store.selectedAddress, + '0x01', + ); + // check address book backup + assert.equal( + backupController.addressBookController.store.addressBook['0x61'][ + '0x42EB768f2244C8811C63729A21A3569731535f06' + ].chainId, + '0x61', + ); + assert.equal( + backupController.addressBookController.store.addressBook['0x61'][ + '0x42EB768f2244C8811C63729A21A3569731535f06' + ].address, + '0x42EB768f2244C8811C63729A21A3569731535f06', + ); + assert.equal( + backupController.addressBookController.store.addressBook['0x61'][ + '0x42EB768f2244C8811C63729A21A3569731535f06' + ].isEns, + false, + ); + }); + }); +}); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index f025712e2..76bab1039 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -122,6 +122,7 @@ import CachedBalancesController from './controllers/cached-balances'; import AlertController from './controllers/alert'; import OnboardingController from './controllers/onboarding'; import ThreeBoxController from './controllers/threebox'; +import BackupController from './controllers/backup'; import IncomingTransactionsController from './controllers/incoming-transactions'; import MessageManager, { normalizeMsgData } from './lib/message-manager'; import DecryptMessageManager from './lib/decrypt-message-manager'; @@ -797,6 +798,14 @@ export default class MetamaskController extends EventEmitter { ), }); + this.backupController = new BackupController({ + preferencesController: this.preferencesController, + addressBookController: this.addressBookController, + trackMetaMetricsEvent: this.metaMetricsController.trackEvent.bind( + this.metaMetricsController, + ), + }); + this.txController = new TransactionController({ initState: initState.TransactionController || initState.TransactionManager, @@ -1047,6 +1056,7 @@ export default class MetamaskController extends EventEmitter { PermissionLogController: this.permissionLogController.store, SubjectMetadataController: this.subjectMetadataController, ThreeBoxController: this.threeBoxController.store, + BackupController: this.backupController, AnnouncementController: this.announcementController, GasFeeController: this.gasFeeController, TokenListController: this.tokenListController, @@ -1085,6 +1095,7 @@ export default class MetamaskController extends EventEmitter { PermissionLogController: this.permissionLogController.store, SubjectMetadataController: this.subjectMetadataController, ThreeBoxController: this.threeBoxController.store, + BackupController: this.backupController, SwapsController: this.swapsController.store, EnsController: this.ensController.store, ApprovalController: this.approvalController, @@ -1521,6 +1532,7 @@ export default class MetamaskController extends EventEmitter { smartTransactionsController, txController, assetsContractController, + backupController, } = this; return { @@ -1965,6 +1977,10 @@ export default class MetamaskController extends EventEmitter { removePollingTokenFromAppState: appStateController.removePollingToken.bind(appStateController), + // BackupController + backupUserData: backupController.backupUserData.bind(backupController), + restoreUserData: backupController.restoreUserData.bind(backupController), + // DetectTokenController detectNewTokens: detectTokensController.detectNewTokens.bind( detectTokensController, diff --git a/shared/modules/export-utils.js b/shared/modules/export-utils.js new file mode 100644 index 000000000..609c881c1 --- /dev/null +++ b/shared/modules/export-utils.js @@ -0,0 +1,19 @@ +import { getRandomFileName } from '../../ui/helpers/utils/util'; + +export function exportAsFile(filename, data, type = 'text/csv') { + // eslint-disable-next-line no-param-reassign + filename = filename || getRandomFileName(); + // source: https://stackoverflow.com/a/33542499 by Ludovic Feltz + const blob = new window.Blob([data], { type }); + if (window.navigator.msSaveOrOpenBlob) { + window.navigator.msSaveBlob(blob, filename); + } else { + const elem = window.document.createElement('a'); + elem.target = '_blank'; + elem.href = window.URL.createObjectURL(blob); + elem.download = filename; + document.body.appendChild(elem); + elem.click(); + document.body.removeChild(elem); + } +} diff --git a/shared/modules/string-utils.js b/shared/modules/string-utils.js index fc496227f..c8a1c0e87 100644 --- a/shared/modules/string-utils.js +++ b/shared/modules/string-utils.js @@ -4,3 +4,7 @@ export function isEqualCaseInsensitive(value1, value2) { } return value1.toLowerCase() === value2.toLowerCase(); } + +export function prependZero(num, maxLength) { + return num.toString().padStart(maxLength, '0'); +} diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index 0763762c8..6dadb1e23 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -1,4 +1,5 @@ const path = require('path'); +const { promises: fs } = require('fs'); const BigNumber = require('bignumber.js'); const mockttp = require('mockttp'); const createStaticServer = require('../../development/create-static-server'); @@ -17,6 +18,11 @@ const largeDelayMs = regularDelayMs * 2; const veryLargeDelayMs = largeDelayMs * 2; const dappBasePort = 8080; +const createDownloadFolder = async (downloadsFolder) => { + await fs.rm(downloadsFolder, { recursive: true, force: true }); + await fs.mkdir(downloadsFolder, { recursive: true }); +}; + const convertToHexValue = (val) => `0x${new BigNumber(val, 10).toString(16)}`; async function withFixtures(options, testSuite) { @@ -330,4 +336,5 @@ module.exports = { connectDappWithExtensionPopup, completeImportSRPOnboardingFlow, completeImportSRPOnboardingFlowWordByWord, + createDownloadFolder, }; diff --git a/test/e2e/tests/backup.spec.js b/test/e2e/tests/backup.spec.js new file mode 100644 index 000000000..99b3a43ea --- /dev/null +++ b/test/e2e/tests/backup.spec.js @@ -0,0 +1,81 @@ +const { strict: assert } = require('assert'); +const { promises: fs } = require('fs'); +const { + convertToHexValue, + withFixtures, + createDownloadFolder, +} = require('../helpers'); + +const downloadsFolder = `${process.cwd()}/test-artifacts/downloads`; + +const backupExists = async () => { + const date = new Date(); + + const prependZero = (num, maxLength) => { + return num.toString().padStart(maxLength, '0'); + }; + + const prefixZero = (num) => prependZero(num, 2); + + /* + * userData.YYYY_MM_DD_HH_mm_SS e.g userData.2022_01_13_13_45_56 + * */ + const userDataFileName = `MetaMaskUserData.${date.getFullYear()}_${prefixZero( + date.getMonth() + 1, + )}_${prefixZero(date.getDay())}_${prefixZero(date.getHours())}_${prefixZero( + date.getMinutes(), + )}_${prefixZero(date.getDay())}.json`; + + try { + const backup = `${downloadsFolder}/${userDataFileName}`; + await fs.access(backup); + return true; + } catch (e) { + return false; + } +}; + +describe('Backup', function () { + const ganacheOptions = { + accounts: [ + { + secretKey: + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', + balance: convertToHexValue(25000000000000000000), + }, + ], + }; + it('should create backup for the account', async function () { + await withFixtures( + { + fixtures: 'imported-account', + ganacheOptions, + title: this.test.title, + failOnConsoleError: false, + }, + async ({ driver }) => { + await createDownloadFolder(downloadsFolder); + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + // Download user settings + await driver.clickElement('.account-menu__icon'); + await driver.clickElement({ text: 'Settings', tag: 'div' }); + await driver.clickElement({ text: 'Advanced', tag: 'div' }); + await driver.clickElement({ + text: 'Backup', + tag: 'button', + }); + + // Verify download + let fileExists; + await driver.wait(async () => { + fileExists = await backupExists(); + return fileExists === true; + }, 10000); + assert.equal(fileExists, true); + }, + ); + }); +}); diff --git a/test/e2e/tests/state-logs.spec.js b/test/e2e/tests/state-logs.spec.js index e79d198cf..ae42abfc1 100644 --- a/test/e2e/tests/state-logs.spec.js +++ b/test/e2e/tests/state-logs.spec.js @@ -1,14 +1,13 @@ const { strict: assert } = require('assert'); const { promises: fs } = require('fs'); -const { convertToHexValue, withFixtures } = require('../helpers'); +const { + convertToHexValue, + withFixtures, + createDownloadFolder, +} = require('../helpers'); const downloadsFolder = `${process.cwd()}/test-artifacts/downloads`; -const createDownloadFolder = async () => { - await fs.rm(downloadsFolder, { recursive: true, force: true }); - await fs.mkdir(downloadsFolder, { recursive: true }); -}; - const stateLogsExist = async () => { try { const stateLogs = `${downloadsFolder}/MetaMask state logs.json`; @@ -38,7 +37,7 @@ describe('State logs', function () { failOnConsoleError: false, }, async ({ driver }) => { - await createDownloadFolder(); + await createDownloadFolder(downloadsFolder); await driver.navigate(); await driver.fill('#password', 'correct horse battery staple'); await driver.press('#password', driver.Key.ENTER); diff --git a/ui/components/ui/export-text-container/export-text-container.component.js b/ui/components/ui/export-text-container/export-text-container.component.js index 7223305c0..08c61e4f9 100644 --- a/ui/components/ui/export-text-container/export-text-container.component.js +++ b/ui/components/ui/export-text-container/export-text-container.component.js @@ -1,9 +1,9 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { exportAsFile } from '../../../helpers/utils/util'; import Copy from '../icon/copy-icon.component'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; +import { exportAsFile } from '../../../../shared/modules/export-utils'; function ExportTextContainer({ text = '' }) { const t = useI18nContext(); diff --git a/ui/helpers/constants/settings.js b/ui/helpers/constants/settings.js index 3148e7543..7d62fa3fa 100644 --- a/ui/helpers/constants/settings.js +++ b/ui/helpers/constants/settings.js @@ -353,4 +353,18 @@ export const SETTINGS_CONSTANTS = [ route: `${EXPERIMENTAL_ROUTE}#show-custom-network`, icon: 'fa fa-flask', }, + { + tabMessage: (t) => t('advanced'), + sectionMessage: (t) => t('backupUserData'), + descriptionMessage: (t) => t('backupUserDataDescription'), + route: `${ADVANCED_ROUTE}#backup-userdata`, + icon: 'fas fa-download', + }, + { + tabMessage: (t) => t('advanced'), + sectionMessage: (t) => t('restoreUserData'), + descriptionMessage: (t) => t('restoreUserDataDescription'), + route: `${ADVANCED_ROUTE}#restore-userdata`, + icon: 'fas fa-upload', + }, ]; diff --git a/ui/helpers/utils/settings-search.test.js b/ui/helpers/utils/settings-search.test.js index e17d323d7..a40424416 100644 --- a/ui/helpers/utils/settings-search.test.js +++ b/ui/helpers/utils/settings-search.test.js @@ -172,7 +172,7 @@ describe('Settings Search Utils', () => { }); it('should get good advanced section number', () => { - expect(getNumberOfSettingsInSection(t, t('advanced'))).toStrictEqual(13); + expect(getNumberOfSettingsInSection(t, t('advanced'))).toStrictEqual(15); }); it('should get good contact section number', () => { diff --git a/ui/helpers/utils/util.js b/ui/helpers/utils/util.js index 683a34ccd..86aabc98f 100644 --- a/ui/helpers/utils/util.js +++ b/ui/helpers/utils/util.js @@ -193,24 +193,6 @@ export function getRandomFileName() { return fileName; } -export function exportAsFile(filename, data, type = 'text/csv') { - // eslint-disable-next-line no-param-reassign - filename = filename || getRandomFileName(); - // source: https://stackoverflow.com/a/33542499 by Ludovic Feltz - const blob = new window.Blob([data], { type }); - if (window.navigator.msSaveOrOpenBlob) { - window.navigator.msSaveBlob(blob, filename); - } else { - const elem = window.document.createElement('a'); - elem.target = '_blank'; - elem.href = window.URL.createObjectURL(blob); - elem.download = filename; - document.body.appendChild(elem); - elem.click(); - document.body.removeChild(elem); - } -} - /** * Shortens an Ethereum address for display, preserving the beginning and end. * Returns the given address if it is no longer than 10 characters. diff --git a/ui/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.component.js b/ui/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.component.js index ebef3fd58..05d0338fe 100644 --- a/ui/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.component.js +++ b/ui/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.component.js @@ -6,8 +6,8 @@ import { INITIALIZE_END_OF_FLOW_ROUTE, INITIALIZE_SEED_PHRASE_ROUTE, } from '../../../../helpers/constants/routes'; -import { exportAsFile } from '../../../../helpers/utils/util'; import { EVENT } from '../../../../../shared/constants/metametrics'; +import { exportAsFile } from '../../../../../shared/modules/export-utils'; import DraggableSeed from './draggable-seed.component'; const EMPTY_SEEDS = Array(12).fill(null); diff --git a/ui/pages/first-time-flow/seed-phrase/reveal-seed-phrase/reveal-seed-phrase.component.js b/ui/pages/first-time-flow/seed-phrase/reveal-seed-phrase/reveal-seed-phrase.component.js index af02b4761..24c9deefc 100644 --- a/ui/pages/first-time-flow/seed-phrase/reveal-seed-phrase/reveal-seed-phrase.component.js +++ b/ui/pages/first-time-flow/seed-phrase/reveal-seed-phrase/reveal-seed-phrase.component.js @@ -10,9 +10,9 @@ import { DEFAULT_ROUTE, INITIALIZE_SEED_PHRASE_INTRO_ROUTE, } from '../../../../helpers/constants/routes'; -import { exportAsFile } from '../../../../helpers/utils/util'; import { EVENT } from '../../../../../shared/constants/metametrics'; import { returnToOnboardingInitiatorTab } from '../../onboarding-initiator-util'; +import { exportAsFile } from '../../../../../shared/modules/export-utils'; export default class RevealSeedPhrase extends PureComponent { static contextTypes = { diff --git a/ui/pages/settings/advanced-tab/advanced-tab.component.js b/ui/pages/settings/advanced-tab/advanced-tab.component.js index a9f0fa20b..a03d6a25a 100644 --- a/ui/pages/settings/advanced-tab/advanced-tab.component.js +++ b/ui/pages/settings/advanced-tab/advanced-tab.component.js @@ -1,7 +1,6 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import { exportAsFile } from '../../../helpers/utils/util'; import ToggleButton from '../../../components/ui/toggle-button'; import TextField from '../../../components/ui/text-field'; import Button from '../../../components/ui/button'; @@ -22,6 +21,8 @@ import { LEDGER_USB_VENDOR_ID, } from '../../../../shared/constants/hardware-wallets'; import { EVENT } from '../../../../shared/constants/metametrics'; +import { exportAsFile } from '../../../../shared/modules/export-utils'; +import ActionableMessage from '../../../components/ui/actionable-message'; export default class AdvancedTab extends PureComponent { static contextTypes = { @@ -58,6 +59,8 @@ export default class AdvancedTab extends PureComponent { userHasALedgerAccount: PropTypes.bool.isRequired, useTokenDetection: PropTypes.bool.isRequired, setUseTokenDetection: PropTypes.func.isRequired, + backupUserData: PropTypes.func.isRequired, + restoreUserData: PropTypes.func.isRequired, }; state = { @@ -66,6 +69,8 @@ export default class AdvancedTab extends PureComponent { ipfsGateway: this.props.ipfsGateway, ipfsGatewayError: '', showLedgerTransportWarning: false, + showResultMessage: false, + restoreSuccessful: true, }; settingsRefs = Array( @@ -117,6 +122,123 @@ export default class AdvancedTab extends PureComponent { ); } + async getTextFromFile(file) { + return new Promise((resolve, reject) => { + const reader = new window.FileReader(); + reader.onload = (e) => { + const text = e.target.result; + resolve(text); + }; + + reader.onerror = (e) => { + reject(e); + }; + + reader.readAsText(file); + }); + } + + async handleFileUpload(event) { + /** + * we need this to be able to access event.target after + * the event handler has been called. [Synthetic Event Pooling, pre React 17] + * + * @see https://fb.me/react-event-pooling + */ + event.persist(); + const file = event.target.files[0]; + const jsonString = await this.getTextFromFile(file); + /** + * so that we can restore same file again if we want to. + * chrome blocks uploading same file twice. + * + */ + event.target.value = ''; + const result = await this.props.restoreUserData(jsonString); + this.setState({ + showResultMessage: true, + restoreSuccessful: result, + }); + } + + renderRestoreUserData() { + const { t } = this.context; + const { showResultMessage, restoreSuccessful } = this.state; + + const settingsRefIndex = process.env.TOKEN_DETECTION_V2 ? 15 : 14; + return ( +
+
+ {t('restoreUserData')} + + {t('restoreUserDataDescription')} + +
+
+
+ + this.handleFileUpload(e)} + /> +
+ {showResultMessage && ( + + )} +
+
+ ); + } + + renderUserDataBackup() { + const { t } = this.context; + const settingsRefIndex = process.env.TOKEN_DETECTION_V2 ? 15 : 13; + return ( +
+
+ {t('backupUserData')} + + {t('backupUserDataDescription')} + +
+
+
+ +
+
+
+ ); + } + renderStateLogs() { const { t } = this.context; const { displayWarning } = this.props; @@ -730,6 +852,8 @@ export default class AdvancedTab extends PureComponent { {this.renderToggleTestNetworks()} {this.renderUseNonceOptIn()} {this.renderAutoLockTimeLimit()} + {this.renderUserDataBackup()} + {this.renderRestoreUserData()} {this.renderThreeBoxControl()} {this.renderIpfsGatewayControl()} {notUsingFirefox ? this.renderLedgerLiveControl() : null} diff --git a/ui/pages/settings/advanced-tab/advanced-tab.component.test.js b/ui/pages/settings/advanced-tab/advanced-tab.component.test.js index 300003319..744ef2f80 100644 --- a/ui/pages/settings/advanced-tab/advanced-tab.component.test.js +++ b/ui/pages/settings/advanced-tab/advanced-tab.component.test.js @@ -31,6 +31,8 @@ describe('AdvancedTab Component', () => { useTokenDetection setUseTokenDetection={toggleTokenDetection} userHasALedgerAccount + backupUserData={() => undefined} + restoreUserData={() => undefined} />, { context: { @@ -41,7 +43,69 @@ describe('AdvancedTab Component', () => { }); it('should render correctly when threeBoxFeatureFlag', () => { - expect(component.find('.settings-page__content-row')).toHaveLength(13); + expect(component.find('.settings-page__content-row')).toHaveLength(15); + }); + + it('should render backup button', () => { + expect(component.find('.settings-page__content-row')).toHaveLength(15); + + expect( + component + .find('.settings-page__content-row') + .at(9) + .find('.settings-page__content-item'), + ).toHaveLength(2); + + expect( + component + .find('.settings-page__content-row') + .at(9) + .find('.settings-page__content-item') + .at(0) + .find('.settings-page__content-description') + .props().children, + ).toStrictEqual('_backupUserDataDescription'); + + expect( + component + .find('.settings-page__content-row') + .at(9) + .find('.settings-page__content-item') + .at(1) + .find('Button') + .props().children, + ).toStrictEqual('_backup'); + }); + + it('should render restore button', () => { + expect(component.find('.settings-page__content-row')).toHaveLength(15); + + expect( + component + .find('.settings-page__content-row') + .at(10) + .find('.settings-page__content-item'), + ).toHaveLength(2); + + expect( + component + .find('.settings-page__content-row') + .at(10) + .find('.settings-page__content-item') + .at(0) + .find('.settings-page__content-description') + .props().children, + ).toStrictEqual('_restoreUserDataDescription'); + + expect( + component + .find('.settings-page__content-row') + .at(10) + .find('.settings-page__content-item') + .at(1) + .find('label') + .props().children, + ).toStrictEqual('_restore'); }); it('should update autoLockTimeLimit', () => { @@ -63,6 +127,8 @@ describe('AdvancedTab Component', () => { useTokenDetection setUseTokenDetection={toggleTokenDetection} userHasALedgerAccount + backupUserData={() => undefined} + restoreUserData={() => undefined} />, { context: { @@ -108,6 +174,8 @@ describe('AdvancedTab Component', () => { useTokenDetection setUseTokenDetection={toggleTokenDetection} userHasALedgerAccount + backupUserData={() => undefined} + restoreUserData={() => undefined} />, { context: { @@ -145,6 +213,8 @@ describe('AdvancedTab Component', () => { useTokenDetection setUseTokenDetection={toggleTokenDetection} userHasALedgerAccount + backupUserData={() => undefined} + restoreUserData={() => undefined} />, { context: { diff --git a/ui/pages/settings/advanced-tab/advanced-tab.container.js b/ui/pages/settings/advanced-tab/advanced-tab.container.js index e775be502..6826ec272 100644 --- a/ui/pages/settings/advanced-tab/advanced-tab.container.js +++ b/ui/pages/settings/advanced-tab/advanced-tab.container.js @@ -15,6 +15,8 @@ import { setLedgerTransportPreference, setDismissSeedBackUpReminder, setUseTokenDetection, + backupUserData, + restoreUserData, } from '../../../store/actions'; import { getPreferences } from '../../../selectors'; import { doesUserHaveALedgerAccount } from '../../../ducks/metamask/metamask'; @@ -63,6 +65,8 @@ export const mapStateToProps = (state) => { export const mapDispatchToProps = (dispatch) => { return { + backupUserData: () => backupUserData(), + restoreUserData: (jsonString) => restoreUserData(jsonString), setHexDataFeatureFlag: (shouldShow) => dispatch(setFeatureFlag('sendHexData', shouldShow)), displayWarning: (warning) => dispatch(displayWarning(warning)), diff --git a/ui/pages/settings/index.scss b/ui/pages/settings/index.scss index 8dba19d34..41948d721 100644 --- a/ui/pages/settings/index.scss +++ b/ui/pages/settings/index.scss @@ -12,6 +12,12 @@ display: flex; flex-flow: column nowrap; + &__error-text { + @include H7; + + color: var(--color-error-default); + } + &__header { padding: 8px 24px 8px 24px; position: relative; @@ -350,6 +356,10 @@ } } + &__button { + cursor: pointer; + } + &__copy-icon { padding-left: 4px; } diff --git a/ui/store/actions.js b/ui/store/actions.js index 620127be0..39e9227bf 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -778,6 +778,29 @@ export function updateTransactionSendFlowHistory(txId, sendFlowHistory) { }; } +export async function backupUserData() { + let backedupData; + try { + backedupData = await promisifiedBackground.backupUserData(); + } catch (error) { + log.error(error.message); + throw error; + } + + return backedupData; +} + +export async function restoreUserData(jsonString) { + try { + await promisifiedBackground.restoreUserData(jsonString); + } catch (error) { + log.error(error.message); + throw error; + } + + return true; +} + export function updateTransactionGasFees(txId, txGasFees) { return async (dispatch) => { let updatedTransaction;