1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-26 12:29:06 +01:00

3box Replacement (#15243)

* Backup user data

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Tests for prependZero (utils.js)

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Fix advancedtab test

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

backup controller tests

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Lint fixes

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Backup controller don't have a store.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Restore from file.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Advanced Tab tests

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Lint fix

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

e2e tests for backup
unit tests for restore.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Fix comments on PR.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

restore style

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Lint fixes

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

We should move the exportAsFile to a utility file in the shared/ directory

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Move export as file to shared folder

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Refactor create download folder methods

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Lint fixes.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Move the backup/restore buttons closer to 3box

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Change descriptions
Add to search

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

refactor code to use if instead of &&

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Lint fixes

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Restore button should change cursor to pointer.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Fix restore not uploading same file twice.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Do not backup these items in preferences
    identities
    lostIdentities
    selectedAddress

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

lint fixes.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* Only update what is needed.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* Fixed test for search

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* remove txError as it currently does nothing.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* Remove dispatch, not needed since we're not dispatching any actions.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* Event should be title case.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* Make backup/restore normal async functions
rename event as per product suggestion.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* Use success Actionable message for success message and danger for error
message

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* change event name to match with backup

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* Lint fixes

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* lint fixes
Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* fix e2e

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>
This commit is contained in:
Olusegun Akintayo 2022-08-09 19:36:32 +01:00 committed by GitHub
parent d255fcdefb
commit 4f34e72085
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 600 additions and 31 deletions

View File

@ -399,6 +399,9 @@
"backToAll": { "backToAll": {
"message": "Back to all" "message": "Back to all"
}, },
"backup": {
"message": "Backup"
},
"backupApprovalInfo": { "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." "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": { "backupNow": {
"message": "Backup now" "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": { "balance": {
"message": "Balance" "message": "Balance"
}, },
@ -2768,6 +2777,18 @@
"restore": { "restore": {
"message": "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": { "restoreWalletPreferences": {
"message": "A backup of your data from $1 has been found. Would you like to restore your wallet preferences?", "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" "description": "$1 is the date at which the data was backed up"

View File

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

View File

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

View File

@ -122,6 +122,7 @@ import CachedBalancesController from './controllers/cached-balances';
import AlertController from './controllers/alert'; import AlertController from './controllers/alert';
import OnboardingController from './controllers/onboarding'; import OnboardingController from './controllers/onboarding';
import ThreeBoxController from './controllers/threebox'; import ThreeBoxController from './controllers/threebox';
import BackupController from './controllers/backup';
import IncomingTransactionsController from './controllers/incoming-transactions'; import IncomingTransactionsController from './controllers/incoming-transactions';
import MessageManager, { normalizeMsgData } from './lib/message-manager'; import MessageManager, { normalizeMsgData } from './lib/message-manager';
import DecryptMessageManager from './lib/decrypt-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({ this.txController = new TransactionController({
initState: initState:
initState.TransactionController || initState.TransactionManager, initState.TransactionController || initState.TransactionManager,
@ -1047,6 +1056,7 @@ export default class MetamaskController extends EventEmitter {
PermissionLogController: this.permissionLogController.store, PermissionLogController: this.permissionLogController.store,
SubjectMetadataController: this.subjectMetadataController, SubjectMetadataController: this.subjectMetadataController,
ThreeBoxController: this.threeBoxController.store, ThreeBoxController: this.threeBoxController.store,
BackupController: this.backupController,
AnnouncementController: this.announcementController, AnnouncementController: this.announcementController,
GasFeeController: this.gasFeeController, GasFeeController: this.gasFeeController,
TokenListController: this.tokenListController, TokenListController: this.tokenListController,
@ -1085,6 +1095,7 @@ export default class MetamaskController extends EventEmitter {
PermissionLogController: this.permissionLogController.store, PermissionLogController: this.permissionLogController.store,
SubjectMetadataController: this.subjectMetadataController, SubjectMetadataController: this.subjectMetadataController,
ThreeBoxController: this.threeBoxController.store, ThreeBoxController: this.threeBoxController.store,
BackupController: this.backupController,
SwapsController: this.swapsController.store, SwapsController: this.swapsController.store,
EnsController: this.ensController.store, EnsController: this.ensController.store,
ApprovalController: this.approvalController, ApprovalController: this.approvalController,
@ -1521,6 +1532,7 @@ export default class MetamaskController extends EventEmitter {
smartTransactionsController, smartTransactionsController,
txController, txController,
assetsContractController, assetsContractController,
backupController,
} = this; } = this;
return { return {
@ -1965,6 +1977,10 @@ export default class MetamaskController extends EventEmitter {
removePollingTokenFromAppState: removePollingTokenFromAppState:
appStateController.removePollingToken.bind(appStateController), appStateController.removePollingToken.bind(appStateController),
// BackupController
backupUserData: backupController.backupUserData.bind(backupController),
restoreUserData: backupController.restoreUserData.bind(backupController),
// DetectTokenController // DetectTokenController
detectNewTokens: detectTokensController.detectNewTokens.bind( detectNewTokens: detectTokensController.detectNewTokens.bind(
detectTokensController, detectTokensController,

View File

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

View File

@ -4,3 +4,7 @@ export function isEqualCaseInsensitive(value1, value2) {
} }
return value1.toLowerCase() === value2.toLowerCase(); return value1.toLowerCase() === value2.toLowerCase();
} }
export function prependZero(num, maxLength) {
return num.toString().padStart(maxLength, '0');
}

View File

@ -1,4 +1,5 @@
const path = require('path'); const path = require('path');
const { promises: fs } = require('fs');
const BigNumber = require('bignumber.js'); const BigNumber = require('bignumber.js');
const mockttp = require('mockttp'); const mockttp = require('mockttp');
const createStaticServer = require('../../development/create-static-server'); const createStaticServer = require('../../development/create-static-server');
@ -17,6 +18,11 @@ const largeDelayMs = regularDelayMs * 2;
const veryLargeDelayMs = largeDelayMs * 2; const veryLargeDelayMs = largeDelayMs * 2;
const dappBasePort = 8080; 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)}`; const convertToHexValue = (val) => `0x${new BigNumber(val, 10).toString(16)}`;
async function withFixtures(options, testSuite) { async function withFixtures(options, testSuite) {
@ -330,4 +336,5 @@ module.exports = {
connectDappWithExtensionPopup, connectDappWithExtensionPopup,
completeImportSRPOnboardingFlow, completeImportSRPOnboardingFlow,
completeImportSRPOnboardingFlowWordByWord, completeImportSRPOnboardingFlowWordByWord,
createDownloadFolder,
}; };

View File

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

View File

@ -1,14 +1,13 @@
const { strict: assert } = require('assert'); const { strict: assert } = require('assert');
const { promises: fs } = require('fs'); const { promises: fs } = require('fs');
const { convertToHexValue, withFixtures } = require('../helpers'); const {
convertToHexValue,
withFixtures,
createDownloadFolder,
} = require('../helpers');
const downloadsFolder = `${process.cwd()}/test-artifacts/downloads`; 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 () => { const stateLogsExist = async () => {
try { try {
const stateLogs = `${downloadsFolder}/MetaMask state logs.json`; const stateLogs = `${downloadsFolder}/MetaMask state logs.json`;
@ -38,7 +37,7 @@ describe('State logs', function () {
failOnConsoleError: false, failOnConsoleError: false,
}, },
async ({ driver }) => { async ({ driver }) => {
await createDownloadFolder(); await createDownloadFolder(downloadsFolder);
await driver.navigate(); await driver.navigate();
await driver.fill('#password', 'correct horse battery staple'); await driver.fill('#password', 'correct horse battery staple');
await driver.press('#password', driver.Key.ENTER); await driver.press('#password', driver.Key.ENTER);

View File

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { exportAsFile } from '../../../helpers/utils/util';
import Copy from '../icon/copy-icon.component'; import Copy from '../icon/copy-icon.component';
import { useI18nContext } from '../../../hooks/useI18nContext'; import { useI18nContext } from '../../../hooks/useI18nContext';
import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard';
import { exportAsFile } from '../../../../shared/modules/export-utils';
function ExportTextContainer({ text = '' }) { function ExportTextContainer({ text = '' }) {
const t = useI18nContext(); const t = useI18nContext();

View File

@ -353,4 +353,18 @@ export const SETTINGS_CONSTANTS = [
route: `${EXPERIMENTAL_ROUTE}#show-custom-network`, route: `${EXPERIMENTAL_ROUTE}#show-custom-network`,
icon: 'fa fa-flask', 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',
},
]; ];

View File

@ -172,7 +172,7 @@ describe('Settings Search Utils', () => {
}); });
it('should get good advanced section number', () => { 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', () => { it('should get good contact section number', () => {

View File

@ -193,24 +193,6 @@ export function getRandomFileName() {
return fileName; 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. * Shortens an Ethereum address for display, preserving the beginning and end.
* Returns the given address if it is no longer than 10 characters. * Returns the given address if it is no longer than 10 characters.

View File

@ -6,8 +6,8 @@ import {
INITIALIZE_END_OF_FLOW_ROUTE, INITIALIZE_END_OF_FLOW_ROUTE,
INITIALIZE_SEED_PHRASE_ROUTE, INITIALIZE_SEED_PHRASE_ROUTE,
} from '../../../../helpers/constants/routes'; } from '../../../../helpers/constants/routes';
import { exportAsFile } from '../../../../helpers/utils/util';
import { EVENT } from '../../../../../shared/constants/metametrics'; import { EVENT } from '../../../../../shared/constants/metametrics';
import { exportAsFile } from '../../../../../shared/modules/export-utils';
import DraggableSeed from './draggable-seed.component'; import DraggableSeed from './draggable-seed.component';
const EMPTY_SEEDS = Array(12).fill(null); const EMPTY_SEEDS = Array(12).fill(null);

View File

@ -10,9 +10,9 @@ import {
DEFAULT_ROUTE, DEFAULT_ROUTE,
INITIALIZE_SEED_PHRASE_INTRO_ROUTE, INITIALIZE_SEED_PHRASE_INTRO_ROUTE,
} from '../../../../helpers/constants/routes'; } from '../../../../helpers/constants/routes';
import { exportAsFile } from '../../../../helpers/utils/util';
import { EVENT } from '../../../../../shared/constants/metametrics'; import { EVENT } from '../../../../../shared/constants/metametrics';
import { returnToOnboardingInitiatorTab } from '../../onboarding-initiator-util'; import { returnToOnboardingInitiatorTab } from '../../onboarding-initiator-util';
import { exportAsFile } from '../../../../../shared/modules/export-utils';
export default class RevealSeedPhrase extends PureComponent { export default class RevealSeedPhrase extends PureComponent {
static contextTypes = { static contextTypes = {

View File

@ -1,7 +1,6 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import { exportAsFile } from '../../../helpers/utils/util';
import ToggleButton from '../../../components/ui/toggle-button'; import ToggleButton from '../../../components/ui/toggle-button';
import TextField from '../../../components/ui/text-field'; import TextField from '../../../components/ui/text-field';
import Button from '../../../components/ui/button'; import Button from '../../../components/ui/button';
@ -22,6 +21,8 @@ import {
LEDGER_USB_VENDOR_ID, LEDGER_USB_VENDOR_ID,
} from '../../../../shared/constants/hardware-wallets'; } from '../../../../shared/constants/hardware-wallets';
import { EVENT } from '../../../../shared/constants/metametrics'; 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 { export default class AdvancedTab extends PureComponent {
static contextTypes = { static contextTypes = {
@ -58,6 +59,8 @@ export default class AdvancedTab extends PureComponent {
userHasALedgerAccount: PropTypes.bool.isRequired, userHasALedgerAccount: PropTypes.bool.isRequired,
useTokenDetection: PropTypes.bool.isRequired, useTokenDetection: PropTypes.bool.isRequired,
setUseTokenDetection: PropTypes.func.isRequired, setUseTokenDetection: PropTypes.func.isRequired,
backupUserData: PropTypes.func.isRequired,
restoreUserData: PropTypes.func.isRequired,
}; };
state = { state = {
@ -66,6 +69,8 @@ export default class AdvancedTab extends PureComponent {
ipfsGateway: this.props.ipfsGateway, ipfsGateway: this.props.ipfsGateway,
ipfsGatewayError: '', ipfsGatewayError: '',
showLedgerTransportWarning: false, showLedgerTransportWarning: false,
showResultMessage: false,
restoreSuccessful: true,
}; };
settingsRefs = Array( 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 (
<div
ref={this.settingsRefs[settingsRefIndex]}
className="settings-page__content-row"
data-testid="advanced-setting-data-restore"
>
<div className="settings-page__content-item">
<span>{t('restoreUserData')}</span>
<span className="settings-page__content-description">
{t('restoreUserDataDescription')}
</span>
</div>
<div className="settings-page__content-item">
<div className="settings-page__content-item-col">
<label
htmlFor="restore-file"
className="button btn btn--rounded btn-secondary btn--large settings-page__button"
>
{t('restore')}
</label>
<input
id="restore-file"
style={{ visibility: 'hidden' }}
type="file"
accept=".json"
onChange={(e) => this.handleFileUpload(e)}
/>
</div>
{showResultMessage && (
<ActionableMessage
type={restoreSuccessful ? 'success' : 'danger'}
message={
restoreSuccessful ? t('restoreSuccessful') : t('restoreFailed')
}
/>
)}
</div>
</div>
);
}
renderUserDataBackup() {
const { t } = this.context;
const settingsRefIndex = process.env.TOKEN_DETECTION_V2 ? 15 : 13;
return (
<div
ref={this.settingsRefs[settingsRefIndex]}
className="settings-page__content-row"
data-testid="advanced-setting-data-backup"
>
<div className="settings-page__content-item">
<span>{t('backupUserData')}</span>
<span className="settings-page__content-description">
{t('backupUserDataDescription')}
</span>
</div>
<div className="settings-page__content-item">
<div className="settings-page__content-item-col">
<Button
type="secondary"
large
onClick={() => {
this.props.backupUserData();
}}
>
{t('backup')}
</Button>
</div>
</div>
</div>
);
}
renderStateLogs() { renderStateLogs() {
const { t } = this.context; const { t } = this.context;
const { displayWarning } = this.props; const { displayWarning } = this.props;
@ -730,6 +852,8 @@ export default class AdvancedTab extends PureComponent {
{this.renderToggleTestNetworks()} {this.renderToggleTestNetworks()}
{this.renderUseNonceOptIn()} {this.renderUseNonceOptIn()}
{this.renderAutoLockTimeLimit()} {this.renderAutoLockTimeLimit()}
{this.renderUserDataBackup()}
{this.renderRestoreUserData()}
{this.renderThreeBoxControl()} {this.renderThreeBoxControl()}
{this.renderIpfsGatewayControl()} {this.renderIpfsGatewayControl()}
{notUsingFirefox ? this.renderLedgerLiveControl() : null} {notUsingFirefox ? this.renderLedgerLiveControl() : null}

View File

@ -31,6 +31,8 @@ describe('AdvancedTab Component', () => {
useTokenDetection useTokenDetection
setUseTokenDetection={toggleTokenDetection} setUseTokenDetection={toggleTokenDetection}
userHasALedgerAccount userHasALedgerAccount
backupUserData={() => undefined}
restoreUserData={() => undefined}
/>, />,
{ {
context: { context: {
@ -41,7 +43,69 @@ describe('AdvancedTab Component', () => {
}); });
it('should render correctly when threeBoxFeatureFlag', () => { 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', () => { it('should update autoLockTimeLimit', () => {
@ -63,6 +127,8 @@ describe('AdvancedTab Component', () => {
useTokenDetection useTokenDetection
setUseTokenDetection={toggleTokenDetection} setUseTokenDetection={toggleTokenDetection}
userHasALedgerAccount userHasALedgerAccount
backupUserData={() => undefined}
restoreUserData={() => undefined}
/>, />,
{ {
context: { context: {
@ -108,6 +174,8 @@ describe('AdvancedTab Component', () => {
useTokenDetection useTokenDetection
setUseTokenDetection={toggleTokenDetection} setUseTokenDetection={toggleTokenDetection}
userHasALedgerAccount userHasALedgerAccount
backupUserData={() => undefined}
restoreUserData={() => undefined}
/>, />,
{ {
context: { context: {
@ -145,6 +213,8 @@ describe('AdvancedTab Component', () => {
useTokenDetection useTokenDetection
setUseTokenDetection={toggleTokenDetection} setUseTokenDetection={toggleTokenDetection}
userHasALedgerAccount userHasALedgerAccount
backupUserData={() => undefined}
restoreUserData={() => undefined}
/>, />,
{ {
context: { context: {

View File

@ -15,6 +15,8 @@ import {
setLedgerTransportPreference, setLedgerTransportPreference,
setDismissSeedBackUpReminder, setDismissSeedBackUpReminder,
setUseTokenDetection, setUseTokenDetection,
backupUserData,
restoreUserData,
} from '../../../store/actions'; } from '../../../store/actions';
import { getPreferences } from '../../../selectors'; import { getPreferences } from '../../../selectors';
import { doesUserHaveALedgerAccount } from '../../../ducks/metamask/metamask'; import { doesUserHaveALedgerAccount } from '../../../ducks/metamask/metamask';
@ -63,6 +65,8 @@ export const mapStateToProps = (state) => {
export const mapDispatchToProps = (dispatch) => { export const mapDispatchToProps = (dispatch) => {
return { return {
backupUserData: () => backupUserData(),
restoreUserData: (jsonString) => restoreUserData(jsonString),
setHexDataFeatureFlag: (shouldShow) => setHexDataFeatureFlag: (shouldShow) =>
dispatch(setFeatureFlag('sendHexData', shouldShow)), dispatch(setFeatureFlag('sendHexData', shouldShow)),
displayWarning: (warning) => dispatch(displayWarning(warning)), displayWarning: (warning) => dispatch(displayWarning(warning)),

View File

@ -12,6 +12,12 @@
display: flex; display: flex;
flex-flow: column nowrap; flex-flow: column nowrap;
&__error-text {
@include H7;
color: var(--color-error-default);
}
&__header { &__header {
padding: 8px 24px 8px 24px; padding: 8px 24px 8px 24px;
position: relative; position: relative;
@ -350,6 +356,10 @@
} }
} }
&__button {
cursor: pointer;
}
&__copy-icon { &__copy-icon {
padding-left: 4px; padding-left: 4px;
} }

View File

@ -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) { export function updateTransactionGasFees(txId, txGasFees) {
return async (dispatch) => { return async (dispatch) => {
let updatedTransaction; let updatedTransaction;