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

I5849 incremental account security (#6874)

* Implements ability to defer seed phrase backup to later

* Adds incremental-security.spec.js, including test dapp that sends signed tx with stand alone localhost provider

* Update metamask-responsive-ui for incremental account security changes

* Update backup-notification style and fix responsiveness of seed phrase screen

* Remove uneeded files from send-eth-with-private-key-test/

* Apply linguist flags in .gitattributes for send-eth-with-private-key-test/ethereumjs-tx.js

* Improve docs in controllers/onboarding.js

* Clean up metamask-extension/test/e2e/send-eth-with-private-key-test/index.html

* Remove unnecessary newlines in a couple first-time-flow/ files

* Fix import of backup-notification in home.component

* Fix git attrs file
This commit is contained in:
Dan J Miller 2019-08-02 01:27:26 -02:30 committed by GitHub
parent 189e126f61
commit 3eff478775
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1507 additions and 28 deletions

2
.gitattributes vendored
View File

@ -4,3 +4,5 @@ CHANGELOG.md merge=union
# we're using the dependencies we expect to be using
package-lock.json linguist-generated=false
yarn.lock linguist-generated=false
test/e2e/send-eth-with-private-key-test/ethereumjs-tx.js linguist-vendored linguist-generated

View File

@ -208,6 +208,9 @@
"backToAll": {
"message": "Back to All"
},
"backupNow": {
"message": "Backup now"
},
"balance": {
"message": "Balance"
},
@ -1310,6 +1313,9 @@
"deleteNetworkDescription": {
"message": "Are you sure you want to delete this network?"
},
"remindMeLater": {
"message": "Remind me later"
},
"restoreFromSeed": {
"message": "Restore account?"
},

View File

@ -0,0 +1,3 @@
<svg width="20" height="24" viewBox="0 0 20 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 9.71495V3.57158L10 0.872803L20 3.57158V9.71495C20 16.0442 15.7999 21.4041 10 23.2357C4.19995 21.4041 0 16.0442 0 9.71495ZM15.3879 10.7277L15.6232 10.898L15.0819 11.5366L15.9057 14.1001L15.1431 16.7158L12.4738 15.9779L11.956 16.4036L10.9014 17.1367H9.09833L8.04379 16.4036L7.52596 15.9779L4.85671 16.7158L4.09874 14.1001L4.91784 11.5366L4.37644 10.898L4.61188 10.7277L4.23524 10.3825L4.52242 10.1553L4.14578 9.8669L4.4 9.6777L3.99989 7.74783L4.59302 5.95527L8.41104 7.38838H11.5887L15.4067 5.95527L15.9999 7.74783L15.6045 9.6777L15.854 9.8669L15.4773 10.1553L15.7644 10.3825L15.3879 10.7277ZM7.38499 13.1652L8.47065 12.6641L8.92346 13.6225L7.38499 13.1652ZM10.7685 13.6225L11.2197 12.6641L12.307 13.1652L10.7685 13.6225ZM9.24343 14.9001H10.4439L10.6519 15.0662L10.7689 16.178L10.6607 16.0674H9.02677L8.92279 16.178L9.03541 15.0662L9.24343 14.9001Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1018 B

View File

@ -0,0 +1,43 @@
const ObservableStore = require('obs-store')
const extend = require('xtend')
/**
* @typedef {Object} InitState
* @property {Boolean} seedPhraseBackedUp Indicates whether the user has completed the seed phrase backup challenge
*/
/**
* @typedef {Object} OnboardingOptions
* @property {InitState} initState The initial controller state
*/
/**
* Controller responsible for maintaining
* a cache of account balances in local storage
*/
class OnboardingController {
/**
* Creates a new controller instance
*
* @param {OnboardingOptions} [opts] Controller configuration parameters
*/
constructor (opts = {}) {
const initState = extend({
seedPhraseBackedUp: null,
}, opts.initState)
this.store = new ObservableStore(initState)
}
setSeedPhraseBackedUp (newSeedPhraseBackUpState) {
this.store.updateState({
seedPhraseBackedUp: newSeedPhraseBackUpState,
})
}
getSeedPhraseBackedUp () {
return this.store.getState().seedPhraseBackedUp
}
}
module.exports = OnboardingController

View File

@ -28,6 +28,7 @@ const PreferencesController = require('./controllers/preferences')
const AppStateController = require('./controllers/app-state')
const InfuraController = require('./controllers/infura')
const CachedBalancesController = require('./controllers/cached-balances')
const OnboardingController = require('./controllers/onboarding')
const RecentBlocksController = require('./controllers/recent-blocks')
const MessageManager = require('./lib/message-manager')
const PersonalMessageManager = require('./lib/personal-message-manager')
@ -158,6 +159,10 @@ module.exports = class MetamaskController extends EventEmitter {
initState: initState.CachedBalancesController,
})
this.onboardingController = new OnboardingController({
initState: initState.OnboardingController,
})
// ensure accountTracker updates balances after network change
this.networkController.on('networkDidChange', () => {
this.accountTracker._updateAccounts()
@ -262,6 +267,7 @@ module.exports = class MetamaskController extends EventEmitter {
NetworkController: this.networkController.store,
InfuraController: this.infuraController.store,
CachedBalancesController: this.cachedBalancesController.store,
OnboardingController: this.onboardingController.store,
})
this.memStore = new ComposableObservableStore(null, {
@ -283,6 +289,7 @@ module.exports = class MetamaskController extends EventEmitter {
ShapeshiftController: this.shapeshiftController,
InfuraController: this.infuraController.store,
ProviderApprovalController: this.providerApprovalController.store,
OnboardingController: this.onboardingController.store,
})
this.memStore.subscribe(this.sendUpdate.bind(this))
}
@ -398,6 +405,7 @@ module.exports = class MetamaskController extends EventEmitter {
const txController = this.txController
const networkController = this.networkController
const providerApprovalController = this.providerApprovalController
const onboardingController = this.onboardingController
return {
// etc
@ -501,6 +509,9 @@ module.exports = class MetamaskController extends EventEmitter {
rejectProviderRequestByOrigin: providerApprovalController.rejectProviderRequestByOrigin.bind(providerApprovalController),
forceApproveProviderRequestByOrigin: providerApprovalController.forceApproveProviderRequestByOrigin.bind(providerApprovalController),
clearApprovedOrigins: providerApprovalController.clearApprovedOrigins.bind(providerApprovalController),
// onboarding controller
setSeedPhraseBackedUp: nodeify(onboardingController.setSeedPhraseBackedUp, onboardingController),
}
}

View File

@ -13,6 +13,7 @@
"dapp": "static-server test/e2e/contract-test --port 8080",
"dapp-chain": "GANACHE_ARGS='-b 2' concurrently -k -n ganache,dapp -p '[{time}][{name}]' 'yarn ganache:start' 'sleep 5 && static-server test/e2e/contract-test --port 8080'",
"watch:test:unit": "nodemon --exec \"yarn test:unit\" ./test ./app ./ui",
"sendwithprivatedapp": "static-server test/e2e/send-eth-with-private-key-test --port 8080",
"test:unit": "cross-env METAMASK_ENV=test mocha --exit --require test/setup.js --recursive \"test/unit/**/*.js\" \"ui/app/**/*.test.js\"",
"test:single": "cross-env METAMASK_ENV=test mocha --require test/helper.js",
"test:integration": "yarn test:integration:build && yarn test:flat",

View File

@ -0,0 +1,295 @@
const path = require('path')
const assert = require('assert')
const webdriver = require('selenium-webdriver')
const { By, until } = webdriver
const {
delay,
buildChromeWebDriver,
buildFirefoxWebdriver,
installWebExt,
getExtensionIdChrome,
getExtensionIdFirefox,
} = require('./func')
const {
assertElementNotPresent,
checkBrowserForConsoleErrors,
closeAllWindowHandlesExcept,
findElement,
findElements,
loadExtension,
openNewPage,
verboseReportOnFailure,
} = require('./helpers')
const fetchMockResponses = require('./fetch-mocks.js')
describe('MetaMask', function () {
let extensionId
let driver
let publicAddress
const tinyDelayMs = 200
const regularDelayMs = tinyDelayMs * 2
const largeDelayMs = regularDelayMs * 2
this.timeout(0)
this.bail(true)
before(async function () {
let extensionUrl
switch (process.env.SELENIUM_BROWSER) {
case 'chrome': {
const extPath = path.resolve('dist/chrome')
driver = buildChromeWebDriver(extPath)
extensionId = await getExtensionIdChrome(driver)
await delay(largeDelayMs)
extensionUrl = `chrome-extension://${extensionId}/home.html`
break
}
case 'firefox': {
const extPath = path.resolve('dist/firefox')
driver = buildFirefoxWebdriver()
await installWebExt(driver, extPath)
await delay(largeDelayMs)
extensionId = await getExtensionIdFirefox(driver)
extensionUrl = `moz-extension://${extensionId}/home.html`
break
}
}
// Depending on the state of the application built into the above directory (extPath) and the value of
// METAMASK_DEBUG we will see different post-install behaviour and possibly some extra windows. Here we
// are closing any extraneous windows to reset us to a single window before continuing.
const [tab1] = await driver.getAllWindowHandles()
await closeAllWindowHandlesExcept(driver, [tab1])
await driver.switchTo().window(tab1)
await driver.get(extensionUrl)
})
beforeEach(async function () {
await driver.executeScript(
'window.origFetch = window.fetch.bind(window);' +
'window.fetch = ' +
'(...args) => { ' +
'if (args[0] === "https://ethgasstation.info/json/ethgasAPI.json") { return ' +
'Promise.resolve({ json: () => Promise.resolve(JSON.parse(\'' + fetchMockResponses.ethGasBasic + '\')) }); } else if ' +
'(args[0] === "https://ethgasstation.info/json/predictTable.json") { return ' +
'Promise.resolve({ json: () => Promise.resolve(JSON.parse(\'' + fetchMockResponses.ethGasPredictTable + '\')) }); } else if ' +
'(args[0].match(/chromeextensionmm/)) { return ' +
'Promise.resolve({ json: () => Promise.resolve(JSON.parse(\'' + fetchMockResponses.metametrics + '\')) }); } else if ' +
'(args[0] === "https://dev.blockscale.net/api/gasexpress.json") { return ' +
'Promise.resolve({ json: () => Promise.resolve(JSON.parse(\'' + fetchMockResponses.gasExpress + '\')) }); } ' +
'return window.origFetch(...args); };' +
'function cancelInfuraRequest(requestDetails) {' +
'console.log("Canceling: " + requestDetails.url);' +
'return {' +
'cancel: true' +
'};' +
' }' +
'window.chrome && window.chrome.webRequest && window.chrome.webRequest.onBeforeRequest.addListener(' +
'cancelInfuraRequest,' +
'{urls: ["https://*.infura.io/*"]},' +
'["blocking"]' +
');'
)
})
afterEach(async function () {
if (process.env.SELENIUM_BROWSER === 'chrome') {
const errors = await checkBrowserForConsoleErrors(driver)
if (errors.length) {
const errorReports = errors.map(err => err.message)
const errorMessage = `Errors found in browser console:\n${errorReports.join('\n')}`
console.error(new Error(errorMessage))
}
}
if (this.currentTest.state === 'failed') {
await verboseReportOnFailure(driver, this.currentTest)
}
})
after(async function () {
await driver.quit()
})
describe('Going through the first time flow, but skipping the seed phrase challenge', () => {
it('clicks the continue button on the welcome screen', async () => {
await findElement(driver, By.css('.welcome-page__header'))
const welcomeScreenBtn = await findElement(driver, By.css('.first-time-flow__button'))
welcomeScreenBtn.click()
await delay(largeDelayMs)
})
it('clicks the "Create New Wallet" option', async () => {
const customRpcButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Create a Wallet')]`))
customRpcButton.click()
await delay(largeDelayMs)
})
it('clicks the "No thanks" option on the metametrics opt-in screen', async () => {
const optOutButton = await findElement(driver, By.css('.btn-default'))
optOutButton.click()
await delay(largeDelayMs)
})
it('accepts a secure password', async () => {
const passwordBox = await findElement(driver, By.css('.first-time-flow__form #create-password'))
const passwordBoxConfirm = await findElement(driver, By.css('.first-time-flow__form #confirm-password'))
const button = await findElement(driver, By.css('.first-time-flow__form button'))
await passwordBox.sendKeys('correct horse battery staple')
await passwordBoxConfirm.sendKeys('correct horse battery staple')
const tosCheckBox = await findElement(driver, By.css('.first-time-flow__checkbox'))
await tosCheckBox.click()
await button.click()
await delay(regularDelayMs)
})
it('skips the seed phrase challenge', async () => {
const buttons = await findElements(driver, By.css('.first-time-flow__button'))
await buttons[0].click()
await delay(regularDelayMs)
const detailsButton = await findElement(driver, By.css('.wallet-view__details-button'))
await detailsButton.click()
await delay(regularDelayMs)
})
it('gets the current accounts address', async () => {
const addressInput = await findElement(driver, By.css('.qr-ellip-address'))
publicAddress = await addressInput.getAttribute('value')
const accountModal = await driver.findElement(By.css('span .modal'))
await driver.executeScript("document.querySelector('.account-modal-close').click()")
await driver.wait(until.stalenessOf(accountModal))
await delay(regularDelayMs)
})
})
describe('send to current account from dapp with different provider', () => {
let extension
it('switches to dapp screen', async () => {
const windowHandles = await driver.getAllWindowHandles()
extension = windowHandles[0]
await openNewPage(driver, 'http://127.0.0.1:8080/')
await delay(regularDelayMs)
})
it('sends eth to the current account', async () => {
const addressInput = await findElement(driver, By.css('#address'))
await addressInput.sendKeys(publicAddress)
await delay(regularDelayMs)
const sendButton = await findElement(driver, By.css('#send'))
await sendButton.click()
const txStatus = await findElement(driver, By.css('#success'))
await driver.wait(until.elementTextMatches(txStatus, /Success/), 15000)
})
it('switches back to MetaMask', async () => {
await driver.switchTo().window(extension)
})
it('should have the correct amount of eth', async () => {
const balances = await findElements(driver, By.css('.currency-display-component__text'))
await driver.wait(until.elementTextMatches(balances[0], /1/), 15000)
const balance = await balances[0].getText()
assert.equal(balance, '1')
})
})
describe('backs up the seed phrase', () => {
it('should show a backup reminder', async () => {
const backupReminder = await findElements(driver, By.css('.backup-notification'))
assert.equal(backupReminder.length, 1)
})
it('should take the user to the seedphrase backup screen', async () => {
const backupButton = await findElement(driver, By.css('.backup-notification__submit-button'))
await backupButton.click()
await delay(regularDelayMs)
})
let seedPhrase
it('reveals the seed phrase', async () => {
const byRevealButton = By.css('.reveal-seed-phrase__secret-blocker .reveal-seed-phrase__reveal-button')
await driver.wait(until.elementLocated(byRevealButton, 10000))
const revealSeedPhraseButton = await findElement(driver, byRevealButton, 10000)
await revealSeedPhraseButton.click()
await delay(regularDelayMs)
seedPhrase = await driver.findElement(By.css('.reveal-seed-phrase__secret-words')).getText()
assert.equal(seedPhrase.split(' ').length, 12)
await delay(regularDelayMs)
const nextScreen = (await findElements(driver, By.css('button.first-time-flow__button')))[1]
await nextScreen.click()
await delay(regularDelayMs)
})
async function clickWordAndWait (word) {
const xpath = `//div[contains(@class, 'confirm-seed-phrase__seed-word--shuffled') and not(contains(@class, 'confirm-seed-phrase__seed-word--selected')) and contains(text(), '${word}')]`
const word0 = await findElement(driver, By.xpath(xpath), 10000)
await word0.click()
await delay(tinyDelayMs)
}
async function retypeSeedPhrase (words, wasReloaded, count = 0) {
try {
if (wasReloaded) {
const byRevealButton = By.css('.reveal-seed-phrase__secret-blocker .reveal-seed-phrase__reveal-button')
await driver.wait(until.elementLocated(byRevealButton, 10000))
const revealSeedPhraseButton = await findElement(driver, byRevealButton, 10000)
await revealSeedPhraseButton.click()
await delay(regularDelayMs)
const nextScreen = await findElement(driver, By.css('button.first-time-flow__button'))
await nextScreen.click()
await delay(regularDelayMs)
}
for (let i = 0; i < 12; i++) {
await clickWordAndWait(words[i])
}
} catch (e) {
if (count > 2) {
throw e
} else {
await loadExtension(driver, extensionId)
await retypeSeedPhrase(words, true, count + 1)
}
}
}
it('can retype the seed phrase', async () => {
const words = seedPhrase.split(' ')
await retypeSeedPhrase(words)
const confirm = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`))
await confirm.click()
await delay(regularDelayMs)
})
it('should have the correct amount of eth', async () => {
const balances = await findElements(driver, By.css('.currency-display-component__text'))
await driver.wait(until.elementTextMatches(balances[0], /1/), 15000)
const balance = await balances[0].getText()
assert.equal(balance, '1')
})
it('should not show a backup reminder', async () => {
await assertElementNotPresent(webdriver, driver, By.css('.backup-notification'))
})
})
})

View File

@ -156,7 +156,7 @@ describe('MetaMask', function () {
assert.equal(seedPhrase.split(' ').length, 12)
await delay(regularDelayMs)
const nextScreen = await findElement(driver, By.css('button.first-time-flow__button'))
const nextScreen = (await findElements(driver, By.css('button.first-time-flow__button')))[1]
await nextScreen.click()
await delay(regularDelayMs)
})

View File

@ -161,7 +161,7 @@ describe('MetaMask', function () {
assert.equal(seedPhrase.split(' ').length, 12)
await delay(regularDelayMs)
const nextScreen = await findElement(driver, By.css('button.first-time-flow__button'))
const nextScreen = (await findElements(driver, By.css('button.first-time-flow__button')))[1]
await nextScreen.click()
await delay(regularDelayMs)
})

View File

@ -6,7 +6,7 @@ set -u
set -o pipefail
export PATH="$PATH:./node_modules/.bin"
export GANACHE_ARGS='--quiet --blockTime 2'
export GANACHE_ARGS='--blockTime 2 --quiet'
concurrently --kill-others \
--names 'ganache,dapp,e2e' \
@ -32,6 +32,7 @@ concurrently --kill-others \
'yarn ganache:start' \
'sleep 5 && mocha test/e2e/from-import-ui.spec'
export GANACHE_ARGS="$GANACHE_ARGS --deterministic --account=0x53CB0AB5226EEBF4D872113D98332C1555DC304443BEE1CF759D15798D3C55A9,25000000000000000000"
concurrently --kill-others \
--names 'ganache,e2e' \
@ -39,3 +40,12 @@ concurrently --kill-others \
--success first \
'npm run ganache:start' \
'sleep 5 && mocha test/e2e/send-edit.spec'
export GANACHE_ARGS="$GANACHE_ARGS --deterministic --account=0x250F458997A364988956409A164BA4E16F0F99F916ACDD73ADCD3A1DE30CF8D1,0 --account=0x53CB0AB5226EEBF4D872113D98332C1555DC304443BEE1CF759D15798D3C55A9,25000000000000000000"
concurrently --kill-others \
--names 'ganache,sendwithprivatedapp,e2e' \
--prefix '[{time}][{name}]' \
--success first \
'npm run ganache:start' \
'npm run sendwithprivatedapp' \
'sleep 5 && mocha test/e2e/incremental-security.spec'

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<title>E2E Test Dapp</title>
</head>
<body>
<div id="success"></div>
<input id="address" />
<button id="send">Send with private key</button>
<script src="web3js.js"></script>
<script src="ethereumjs-tx.js"></script>
<script src="send-eth-with-private-key.js"></script>
</body>
</html>

View File

@ -0,0 +1,28 @@
/* eslint-disable */
var Tx = ethereumjs.Tx
var privateKey = ethereumjs.Buffer.Buffer.from('53CB0AB5226EEBF4D872113D98332C1555DC304443BEE1CF759D15798D3C55A9', 'hex')
const web3 = new Web3(new Web3.providers.HttpProvider(`http://localhost:8545`))
const sendButton = document.getElementById('send')
sendButton.addEventListener('click', function () {
var rawTx = {
nonce: '0x00',
gasPrice: '0x09184e72a000',
gasLimit: '0x22710',
value: '0xde0b6b3a7640000',
r: '0x25a1bc499cd8799a2ece0fcba0df6e666e54a6e2b4e18c09838e2b621c10db71',
s: '0x6cf83e6e8f6e82a0a1d7bd10bc343fc0ae4b096c1701aa54e6389d447f98ac6f',
v: '0x2d46',
to: document.getElementById('address').value,
}
var tx = new Tx(rawTx);
tx.sign(privateKey);
var serializedTx = tx.serialize();
web3.eth.sendSignedTransaction('0x' + serializedTx.toString('hex')).on('receipt', (transactionResult) => {
document.getElementById('success').innerHTML = `Successfully sent transaction: ${transactionResult.transactionHash}`
})
})

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,50 @@
import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import Button from '../../ui/button'
import {
INITIALIZE_SEED_PHRASE_ROUTE,
} from '../../../helpers/constants/routes'
export default class BackupNotification extends PureComponent {
static propTypes = {
history: PropTypes.object,
showSeedPhraseBackupAfterOnboarding: PropTypes.func,
}
static contextTypes = {
t: PropTypes.func,
metricsEvent: PropTypes.func,
}
handleSubmit = () => {
const { history, showSeedPhraseBackupAfterOnboarding } = this.props
showSeedPhraseBackupAfterOnboarding()
history.push(INITIALIZE_SEED_PHRASE_ROUTE)
}
render () {
const { t } = this.context
return (
<div className="backup-notification">
<div className="backup-notification__header">
<img
className="backup-notification__icon"
src="images/meta-shield.svg"
/>
<div className="backup-notification__text">Backup your Secret Recovery code to keep your wallet and funds secure.</div>
<i className="fa fa-info-circle"></i>
</div>
<div className="backup-notification__buttons">
<Button
type="primary"
className="backup-notification__submit-button"
onClick={this.handleSubmit}
>
{ t('backupNow') }
</Button>
</div>
</div>
)
}
}

View File

@ -0,0 +1,16 @@
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import { compose } from 'recompose'
import BackupNotification from './backup-notification.component'
import { showSeedPhraseBackupAfterOnboarding } from '../../../store/actions'
const mapDispatchToProps = dispatch => {
return {
showSeedPhraseBackupAfterOnboarding: () => dispatch(showSeedPhraseBackupAfterOnboarding()),
}
}
export default compose(
withRouter,
connect(null, mapDispatchToProps)
)(BackupNotification)

View File

@ -0,0 +1 @@
export { default } from './backup-notification.container'

View File

@ -0,0 +1,75 @@
.backup-notification {
background: rgba(36, 41, 46, 0.9);
box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.12);
border-radius: 8px;
height: 116px;
padding: 16px;
margin: 8px;
display: flex;
flex-flow: column;
justify-content: space-between;
position: absolute;
right: 0;
bottom: 0;
&__header {
display: flex;
}
&__text {
font-family: Roboto;
font-style: normal;
font-weight: normal;
font-size: 12px;
color: #FFFFFF;
margin-left: 10px;
margin-right: 8px;
}
.fa-info-circle {
color: #6A737D;
}
&__ignore-button {
border: 2px solid #6A737D;
box-sizing: border-box;
border-radius: 6px;
color: $white;
background-color: rgba(36, 41, 46, 0.9);
height: 34px;
width: 155px;
padding: 0;
&:hover {
border-color: #6A737D;
background-color: #6A737D;
}
}
&__submit-button {
border: 2px solid #6A737D;
box-sizing: border-box;
border-radius: 6px;
color: $white;
background-color: rgba(36, 41, 46, 0.9);
height: 34px;
width: 155px;
padding: 0;
&:hover {
background-color: #3b4046;
}
&:active {
background-color:#141618;
}
}
&__buttons {
display: flex;
width: 130px;
align-self: flex-end;
}
}

View File

@ -81,3 +81,5 @@
@import '../ui/toggle-button/index';
@import 'home-notification/index';
@import 'backup-notification/index';

View File

@ -73,6 +73,7 @@ function reduceApp (state, action) {
networksTabSelectedRpcUrl: '',
networksTabIsInAddMode: false,
loadingMethodData: false,
showingSeedPhraseBackupAfterOnboarding: false,
}, state.appState)
switch (action.type) {
@ -756,6 +757,16 @@ function reduceApp (state, action) {
loadingMethodData: false,
})
case actions.SHOW_SEED_PHRASE_BACKUP_AFTER_ONBOARDING:
return extend(appState, {
showingSeedPhraseBackupAfterOnboarding: true,
})
case actions.HIDE_SEED_PHRASE_BACKUP_AFTER_ONBOARDING:
return extend(appState, {
showingSeedPhraseBackupAfterOnboarding: false,
})
default:
return appState

View File

@ -3,6 +3,7 @@ import Authenticated from './authenticated.component'
const mapStateToProps = state => {
const { metamask: { isUnlocked, completedOnboarding } } = state
return {
isUnlocked,
completedOnboarding,

View File

@ -17,6 +17,7 @@ export default class ImportWithSeedPhrase extends PureComponent {
static propTypes = {
history: PropTypes.object,
onSubmit: PropTypes.func.isRequired,
setSeedPhraseBackedUp: PropTypes.func,
}
state = {
@ -126,7 +127,7 @@ export default class ImportWithSeedPhrase extends PureComponent {
}
const { password, seedPhrase } = this.state
const { history, onSubmit } = this.props
const { history, onSubmit, setSeedPhraseBackedUp } = this.props
try {
await onSubmit(password, this.parseSeedPhrase(seedPhrase))
@ -137,7 +138,10 @@ export default class ImportWithSeedPhrase extends PureComponent {
name: 'Import Complete',
},
})
history.push(INITIALIZE_END_OF_FLOW_ROUTE)
setSeedPhraseBackedUp(true).then(() => {
history.push(INITIALIZE_END_OF_FLOW_ROUTE)
})
} catch (error) {
this.setState({ seedPhraseError: error.message })
}

View File

@ -0,0 +1,13 @@
import { connect } from 'react-redux'
import ImportWithSeedPhrase from './import-with-seed-phrase.component'
import {
setSeedPhraseBackedUp,
} from '../../../../store/actions'
const mapDispatchToProps = dispatch => {
return {
setSeedPhraseBackedUp: (seedPhraseBackupState) => dispatch(setSeedPhraseBackedUp(seedPhraseBackupState)),
}
}
export default connect(null, mapDispatchToProps)(ImportWithSeedPhrase)

View File

@ -1 +1 @@
export { default } from './import-with-seed-phrase.component'
export { default } from './import-with-seed-phrase.container'

View File

@ -30,6 +30,9 @@ export default class FirstTimeFlow extends PureComponent {
isUnlocked: PropTypes.bool,
unlockAccount: PropTypes.func,
nextRoute: PropTypes.string,
showingSeedPhraseBackupAfterOnboarding: PropTypes.bool,
seedPhraseBackedUp: PropTypes.bool,
verifySeedPhrase: PropTypes.func,
}
state = {
@ -38,9 +41,16 @@ export default class FirstTimeFlow extends PureComponent {
}
componentDidMount () {
const { completedOnboarding, history, isInitialized, isUnlocked } = this.props
const {
completedOnboarding,
history,
isInitialized,
isUnlocked,
showingSeedPhraseBackupAfterOnboarding,
seedPhraseBackedUp,
} = this.props
if (completedOnboarding) {
if (completedOnboarding && (!showingSeedPhraseBackupAfterOnboarding || seedPhraseBackedUp)) {
history.push(DEFAULT_ROUTE)
return
}
@ -88,6 +98,7 @@ export default class FirstTimeFlow extends PureComponent {
render () {
const { seedPhrase, isImportedKeyring } = this.state
const { verifySeedPhrase } = this.props
return (
<div className="first-time-flow">
@ -98,6 +109,7 @@ export default class FirstTimeFlow extends PureComponent {
<SeedPhrase
{ ...props }
seedPhrase={seedPhrase}
verifySeedPhrase={verifySeedPhrase}
/>
)}
/>

View File

@ -5,16 +5,19 @@ import {
createNewVaultAndGetSeedPhrase,
createNewVaultAndRestore,
unlockAndGetSeedPhrase,
verifySeedPhrase,
} from '../../store/actions'
const mapStateToProps = state => {
const { metamask: { completedOnboarding, isInitialized, isUnlocked } } = state
const { metamask: { completedOnboarding, isInitialized, isUnlocked, seedPhraseBackedUp }, appState: { showingSeedPhraseBackupAfterOnboarding } } = state
return {
completedOnboarding,
isInitialized,
isUnlocked,
nextRoute: getFirstTimeFlowTypeRoute(state),
showingSeedPhraseBackupAfterOnboarding,
seedPhraseBackedUp,
}
}
@ -25,6 +28,7 @@ const mapDispatchToProps = dispatch => {
return dispatch(createNewVaultAndRestore(password, seedPhrase))
},
unlockAccount: password => dispatch(unlockAndGetSeedPhrase(password)),
verifySeedPhrase: () => verifySeedPhrase(),
}
}

View File

@ -26,6 +26,10 @@
.app-header__metafox-logo {
margin-bottom: 40px;
@media screen and (max-width: $break-small) {
margin-bottom: 0px;
}
}
}

View File

@ -6,6 +6,7 @@ import Button from '../../../../components/ui/button'
import {
INITIALIZE_END_OF_FLOW_ROUTE,
INITIALIZE_SEED_PHRASE_ROUTE,
DEFAULT_ROUTE,
} from '../../../../helpers/constants/routes'
import { exportAsFile } from '../../../../helpers/utils/util'
import DraggableSeed from './draggable-seed.component'
@ -88,7 +89,7 @@ export default class ConfirmSeedPhrase extends PureComponent {
}
handleSubmit = async () => {
const { history } = this.props
const { history, setSeedPhraseBackedUp, showingSeedPhraseBackupAfterOnboarding, hideSeedPhraseBackupAfterOnboarding } = this.props
if (!this.isValid()) {
return
@ -102,7 +103,15 @@ export default class ConfirmSeedPhrase extends PureComponent {
name: 'Verify Complete',
},
})
history.push(INITIALIZE_END_OF_FLOW_ROUTE)
setSeedPhraseBackedUp(true).then(() => {
if (showingSeedPhraseBackupAfterOnboarding) {
hideSeedPhraseBackupAfterOnboarding()
history.push(DEFAULT_ROUTE)
} else {
history.push(INITIALIZE_END_OF_FLOW_ROUTE)
}
})
} catch (error) {
console.error(error.message)
}

View File

@ -0,0 +1,23 @@
import { connect } from 'react-redux'
import ConfirmSeedPhrase from './confirm-seed-phrase.component'
import {
setSeedPhraseBackedUp,
hideSeedPhraseBackupAfterOnboarding,
} from '../../../../store/actions'
const mapStateToProps = state => {
const { appState: { showingSeedPhraseBackupAfterOnboarding } } = state
return {
showingSeedPhraseBackupAfterOnboarding,
}
}
const mapDispatchToProps = dispatch => {
return {
setSeedPhraseBackedUp: (seedPhraseBackupState) => dispatch(setSeedPhraseBackedUp(seedPhraseBackupState)),
hideSeedPhraseBackupAfterOnboarding: () => dispatch(hideSeedPhraseBackupAfterOnboarding()),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(ConfirmSeedPhrase)

View File

@ -1 +1 @@
export { default } from './confirm-seed-phrase.component'
export { default } from './confirm-seed-phrase.container'

View File

@ -1 +1 @@
export { default } from './reveal-seed-phrase.component'
export { default } from './reveal-seed-phrase.container'

View File

@ -1,4 +1,12 @@
.reveal-seed-phrase {
@media screen and (max-width: 576px) {
display: flex;
flex-direction: column;
width: 96%;
margin-left: 2%;
margin-right: 2%;
}
&__secret {
position: relative;
display: flex;
@ -54,4 +62,12 @@
button {
margin-top: 0xp;
}
&__buttons {
display: flex;
.first-time-flow__button:last-of-type {
margin-left: 20px;
}
}
}

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types'
import classnames from 'classnames'
import LockIcon from '../../../../components/ui/lock-icon'
import Button from '../../../../components/ui/button'
import { INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE } from '../../../../helpers/constants/routes'
import { INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE, DEFAULT_ROUTE } from '../../../../helpers/constants/routes'
import { exportAsFile } from '../../../../helpers/utils/util'
export default class RevealSeedPhrase extends PureComponent {
@ -15,6 +15,8 @@ export default class RevealSeedPhrase extends PureComponent {
static propTypes = {
history: PropTypes.object,
seedPhrase: PropTypes.string,
setSeedPhraseBackedUp: PropTypes.func,
setCompletedOnboarding: PropTypes.func,
}
state = {
@ -45,6 +47,24 @@ export default class RevealSeedPhrase extends PureComponent {
history.push(INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE)
}
handleSkip = event => {
event.preventDefault()
const { history, setSeedPhraseBackedUp, setCompletedOnboarding } = this.props
this.context.metricsEvent({
eventOpts: {
category: 'Onboarding',
action: 'Seed Phrase Setup',
name: 'Remind me later',
},
})
Promise.all([setCompletedOnboarding(), setSeedPhraseBackedUp(false)])
.then(() => {
history.push(DEFAULT_ROUTE)
})
}
renderSecretWordsContainer () {
const { t } = this.context
const { seedPhrase } = this.props
@ -129,14 +149,23 @@ export default class RevealSeedPhrase extends PureComponent {
</div>
</div>
</div>
<Button
type="primary"
className="first-time-flow__button"
onClick={this.handleNext}
disabled={!isShowingSeedPhrase}
>
{ t('next') }
</Button>
<div className="reveal-seed-phrase__buttons">
<Button
type="secondary"
className="first-time-flow__button"
onClick={this.handleSkip}
>
{ t('remindMeLater') }
</Button>
<Button
type="primary"
className="first-time-flow__button"
onClick={this.handleNext}
disabled={!isShowingSeedPhrase}
>
{ t('next') }
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,15 @@
import { connect } from 'react-redux'
import RevealSeedPhrase from './reveal-seed-phrase.component'
import {
setCompletedOnboarding,
setSeedPhraseBackedUp,
} from '../../../../store/actions'
const mapDispatchToProps = dispatch => {
return {
setSeedPhraseBackedUp: (seedPhraseBackupState) => dispatch(setSeedPhraseBackedUp(seedPhraseBackupState)),
setCompletedOnboarding: () => dispatch(setCompletedOnboarding()),
}
}
export default connect(null, mapDispatchToProps)(RevealSeedPhrase)

View File

@ -17,18 +17,31 @@ export default class SeedPhrase extends PureComponent {
address: PropTypes.string,
history: PropTypes.object,
seedPhrase: PropTypes.string,
verifySeedPhrase: PropTypes.func,
}
state = {
verifiedSeedPhrase: '',
}
componentDidMount () {
const { seedPhrase, history } = this.props
const { seedPhrase, history, verifySeedPhrase } = this.props
if (!seedPhrase) {
history.push(DEFAULT_ROUTE)
verifySeedPhrase()
.then(verifiedSeedPhrase => {
if (!verifiedSeedPhrase) {
history.push(DEFAULT_ROUTE)
} else {
this.setState({ verifiedSeedPhrase })
}
})
}
}
render () {
const { seedPhrase } = this.props
const { verifiedSeedPhrase } = this.state
return (
<DragDropContextProvider backend={HTML5Backend}>
@ -41,7 +54,7 @@ export default class SeedPhrase extends PureComponent {
render={props => (
<ConfirmSeedPhrase
{ ...props }
seedPhrase={seedPhrase}
seedPhrase={seedPhrase || verifiedSeedPhrase}
/>
)}
/>
@ -51,7 +64,7 @@ export default class SeedPhrase extends PureComponent {
render={props => (
<RevealSeedPhrase
{ ...props }
seedPhrase={seedPhrase}
seedPhrase={seedPhrase || verifiedSeedPhrase}
/>
)}
/>

View File

@ -131,7 +131,7 @@ describe('ConfirmSeedPhrase Component', () => {
assert.deepEqual(root.state().pendingSeedIndices, [2, 0, 1])
})
it('should submit correctly', () => {
it('should submit correctly', async () => {
const originalSeed = ['鼠', '牛', '虎', '兔', '龍', '蛇', '馬', '羊', '猴', '雞', '狗', '豬']
const metricsEventSpy = sinon.spy()
const pushSpy = sinon.spy()
@ -139,6 +139,7 @@ describe('ConfirmSeedPhrase Component', () => {
{
seedPhrase: '鼠 牛 虎 兔 龍 蛇 馬 羊 猴 雞 狗 豬',
history: { push: pushSpy },
setSeedPhraseBackedUp: () => Promise.resolve(),
},
{
metricsEvent: metricsEventSpy,
@ -157,6 +158,9 @@ describe('ConfirmSeedPhrase Component', () => {
root.update()
root.find('.first-time-flow__button').simulate('click')
await (new Promise(resolve => setTimeout(resolve, 100)))
assert.deepEqual(metricsEventSpy.args[0][0], {
eventOpts: {
category: 'Onboarding',

View File

@ -6,6 +6,7 @@ import HomeNotification from '../../components/app/home-notification'
import WalletView from '../../components/app/wallet-view'
import TransactionView from '../../components/app/transaction-view'
import ProviderApproval from '../provider-approval'
import BackupNotification from '../../components/app/backup-notification'
import {
RESTORE_VAULT_ROUTE,
@ -38,6 +39,7 @@ export default class Home extends PureComponent {
unsetMigratedPrivacyMode: PropTypes.func,
viewingUnconnectedDapp: PropTypes.bool.isRequired,
forceApproveProviderRequestByOrigin: PropTypes.func,
shouldShowSeedPhraseReminder: PropTypes.bool,
}
componentWillMount () {
@ -74,6 +76,7 @@ export default class Home extends PureComponent {
unsetMigratedPrivacyMode,
viewingUnconnectedDapp,
forceApproveProviderRequestByOrigin,
shouldShowSeedPhraseReminder,
} = this.props
if (forgottenPassword) {
@ -85,7 +88,6 @@ export default class Home extends PureComponent {
<ProviderApproval providerRequest={providerRequests[0]} />
)
}
return (
<div className="main-container">
<div className="account-and-transaction-details">
@ -124,6 +126,11 @@ export default class Home extends PureComponent {
)
: null
}
{
shouldShowSeedPhraseReminder
? (<BackupNotification />)
: null
}
</TransactionView>
)
: null }

View File

@ -3,6 +3,7 @@ import { compose } from 'recompose'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import { unconfirmedTransactionsCountSelector } from '../../selectors/confirm-transaction'
import { getCurrentEthBalance } from '../../selectors/selectors'
import {
forceApproveProviderRequestByOrigin,
unsetMigratedPrivacyMode,
@ -21,7 +22,9 @@ const mapStateToProps = state => {
featureFlags: {
privacyMode,
} = {},
seedPhraseBackedUp,
} = metamask
const accountBalance = getCurrentEthBalance(state)
const { forgottenPassword } = appState
const isUnconnected = Boolean(activeTab && privacyMode && !approvedOrigins[activeTab.origin])
@ -36,6 +39,7 @@ const mapStateToProps = state => {
showPrivacyModeNotification: migratedPrivacyMode,
activeTab,
viewingUnconnectedDapp: isUnconnected && isPopup,
shouldShowSeedPhraseReminder: parseInt(accountBalance, 16) > 0 && !seedPhraseBackedUp,
}
}

View File

@ -126,6 +126,7 @@ export default class NetworksTab extends PureComponent {
renderNetworksList () {
const { networksToRender, selectedNetwork, networkIsSelected, networksTabIsInAddMode, networkDefaultedToProvider } = this.props
return (
<div
className={classnames('networks-tab__networks-list', {

View File

@ -375,6 +375,15 @@ var actions = {
LOADING_TOKEN_PARAMS_STARTED: 'LOADING_TOKEN_PARAMS_STARTED',
loadingTokenParamsFinished,
LOADING_TOKEN_PARAMS_FINISHED: 'LOADING_TOKEN_PARAMS_FINISHED',
setSeedPhraseBackedUp,
showSeedPhraseBackupAfterOnboarding,
SHOW_SEED_PHRASE_BACKUP_AFTER_ONBOARDING: 'SHOW_SEED_PHRASE_BACKUP_AFTER_ONBOARDING',
hideSeedPhraseBackupAfterOnboarding,
HIDE_SEED_PHRASE_BACKUP_AFTER_ONBOARDING: 'HIDE_SEED_PHRASE_BACKUP_AFTER_ONBOARDING',
verifySeedPhrase,
SET_SEED_PHRASE_BACKED_UP_TO_TRUE: 'SET_SEED_PHRASE_BACKED_UP_TO_TRUE',
}
module.exports = actions
@ -2772,3 +2781,30 @@ function unsetMigratedPrivacyMode () {
background.unsetMigratedPrivacyMode()
}
}
function setSeedPhraseBackedUp (seedPhraseBackupState) {
return (dispatch) => {
log.debug(`background.setSeedPhraseBackedUp`)
return new Promise((resolve, reject) => {
background.setSeedPhraseBackedUp(seedPhraseBackupState, (err) => {
if (err) {
dispatch(actions.displayWarning(err.message))
return reject(err)
}
return forceUpdateMetamaskState(dispatch).then(() => resolve())
})
})
}
}
function showSeedPhraseBackupAfterOnboarding () {
return {
type: actions.SHOW_SEED_PHRASE_BACKUP_AFTER_ONBOARDING,
}
}
function hideSeedPhraseBackupAfterOnboarding () {
return {
type: actions.HIDE_SEED_PHRASE_BACKUP_AFTER_ONBOARDING,
}
}