diff --git a/.babelrc b/.babelrc index bca3364fc..307583ffd 100644 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,4 @@ { - "presets": ["es2015", "stage-0"], + "presets": ["es2015", "stage-0", "react"], "plugins": ["transform-runtime", "transform-async-to-generator"] } diff --git a/.eslintrc b/.eslintrc index 2eb0dc8b5..20a2a7a00 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,4 +1,5 @@ { + "parser": "babel-eslint", "parserOptions": { "sourceType": "module", "ecmaVersion": 2017, @@ -10,10 +11,14 @@ "arrowFunctions": true, "objectLiteralShorthandMethods": true, "objectLiteralShorthandProperties": true, - "templateStrings": true + "templateStrings": true, + "classes": true, + "jsx": true }, }, + "extends": ["plugin:react/recommended"], + "env": { "es6": true, "node": true, @@ -23,7 +28,8 @@ "plugins": [ "mocha", - "chai" + "chai", + "react" ], "globals": { @@ -51,7 +57,7 @@ "generator-star-spacing": [2, { "before": true, "after": true }], "handle-callback-err": [1, "^(err|error)$" ], "indent": "off", - "jsx-quotes": [2, "prefer-single"], + "jsx-quotes": [2, "prefer-double"], "key-spacing": 1, "keyword-spacing": [2, { "before": true, "after": true }], "new-cap": [2, { "newIsCap": true, "capIsNew": false }], diff --git a/app/images/caret-right.svg b/app/images/caret-right.svg new file mode 100644 index 000000000..8981ac254 --- /dev/null +++ b/app/images/caret-right.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mascara/server/util.js b/mascara/server/util.js index 6ab41b729..af2daddb9 100644 --- a/mascara/server/util.js +++ b/mascara/server/util.js @@ -23,7 +23,7 @@ function createBundle (entryPoint) { cache: {}, packageCache: {}, plugin: [watchify], - }) + }).transform('babelify') bundler.on('update', bundle) bundle() diff --git a/mascara/src/app/first-time/backup-phrase-screen.js b/mascara/src/app/first-time/backup-phrase-screen.js new file mode 100644 index 000000000..c68dacea2 --- /dev/null +++ b/mascara/src/app/first-time/backup-phrase-screen.js @@ -0,0 +1,254 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux'; +import classnames from 'classnames' +import shuffle from 'lodash.shuffle' +import {compose, onlyUpdateForPropTypes} from 'recompose' +import Identicon from '../../../../ui/app/components/identicon' +import {confirmSeedWords} from '../../../../ui/app/actions' +import Breadcrumbs from './breadcrumbs' +import LoadingScreen from './loading-screen' + +const LockIcon = props => ( + + + + + +); + +class BackupPhraseScreen extends Component { + static propTypes = { + isLoading: PropTypes.bool.isRequired, + address: PropTypes.string.isRequired, + seedWords: PropTypes.string.isRequired, + next: PropTypes.func.isRequired, + confirmSeedWords: PropTypes.func.isRequired, + }; + + static defaultProps = { + seedWords: '' + }; + + static PAGE = { + SECRET: 'secret', + CONFIRM: 'confirm' + }; + + constructor(props) { + const {seedWords} = props + super(props) + this.state = { + isShowingSecret: false, + page: BackupPhraseScreen.PAGE.SECRET, + selectedSeeds: [], + shuffledSeeds: seedWords && shuffle(seedWords.split(' ')), + } + } + + renderSecretWordsContainer () { + const { isShowingSecret } = this.state + + return ( +
+
+ {this.props.seedWords} +
+ {!isShowingSecret && ( +
+ + +
+ )} +
+ ); + } + + renderSecretScreen() { + const { isShowingSecret } = this.state + + return ( +
+
+
Secret Backup Phrase
+
+ Your secret backup phrase makes it easy to back up and restore your account. +
+
+ WARNING: Never disclose your backup phrase. Anyone with this phrase can take your Ether forever. +
+ {this.renderSecretWordsContainer()} + + +
+
+
Tips:
+
+ Store this phrase in a password manager like 1password. +
+
+ Write this phrase on a piece of paper and store in a secure location. If you want even more security, write it down on multiple pieces of paper and store each in 2 - 3 different locations. +
+
+ Memorize this phrase. +
+
+
+ ) + } + + renderConfirmationScreen() { + const { seedWords, confirmSeedWords, next } = this.props; + const { selectedSeeds, shuffledSeeds } = this.state; + const isValid = seedWords === selectedSeeds.map(([_, seed]) => seed).join(' ') + + return ( +
+
+
Confirm your Secret Backup Phrase
+
+ Please select each phrase in order to make sure it is correct. +
+
+ {selectedSeeds.map(([_, word], i) => ( + + ))} +
+
+ {shuffledSeeds.map((word, i) => { + const isSelected = selectedSeeds + .filter(([index, seed]) => seed === word && index === i) + .length + + return ( + + ) + })} +
+ +
+
+ ) + } + + renderBack () { + return this.state.page === BackupPhraseScreen.PAGE.CONFIRM + ? ( + { + e.preventDefault() + this.setState({ + page: BackupPhraseScreen.PAGE.SECRET + }) + }} + href="#" + > + {`< Back`} + + ) + : null + } + + renderContent () { + switch (this.state.page) { + case BackupPhraseScreen.PAGE.CONFIRM: + return this.renderConfirmationScreen() + case BackupPhraseScreen.PAGE.SECRET: + default: + return this.renderSecretScreen() + } + } + + render () { + return this.props.isLoading + ? + : ( +
+ {this.renderBack()} + + {this.renderContent()} +
+ ) + } +} + +export default compose( + onlyUpdateForPropTypes, + connect( + ({ metamask: { selectedAddress, seedWords }, appState: { isLoading } }) => ({ + seedWords, + isLoading, + address: selectedAddress, + }), + dispatch => ({ + confirmSeedWords: () => dispatch(confirmSeedWords()), + }) + ) +)(BackupPhraseScreen) diff --git a/mascara/src/app/first-time/breadcrumbs.js b/mascara/src/app/first-time/breadcrumbs.js new file mode 100644 index 000000000..f8460d200 --- /dev/null +++ b/mascara/src/app/first-time/breadcrumbs.js @@ -0,0 +1,25 @@ +import React, {Component, PropTypes} from 'react' + +export default class Breadcrumbs extends Component { + + static propTypes = { + total: PropTypes.number, + currentIndex: PropTypes.number + }; + + render() { + const {total, currentIndex} = this.props + return ( +
+ {Array(total).fill().map((_, i) => ( +
+ ))} +
+ ); + } + +} diff --git a/mascara/src/app/first-time/buy-ether-screen.js b/mascara/src/app/first-time/buy-ether-screen.js new file mode 100644 index 000000000..45b2df1c8 --- /dev/null +++ b/mascara/src/app/first-time/buy-ether-screen.js @@ -0,0 +1,199 @@ +import React, {Component, PropTypes} from 'react' +import classnames from 'classnames' +import {connect} from 'react-redux' +import {qrcode} from 'qrcode-npm' +import copyToClipboard from 'copy-to-clipboard' +import ShapeShiftForm from '../shapeshift-form' +import Identicon from '../../../../ui/app/components/identicon' +import {buyEth, showAccountDetail} from '../../../../ui/app/actions' + +class BuyEtherScreen extends Component { + static OPTION_VALUES = { + COINBASE: 'coinbase', + SHAPESHIFT: 'shapeshift', + QR_CODE: 'qr_code', + }; + + static OPTIONS = [ + { + name: 'Direct Deposit', + value: BuyEtherScreen.OPTION_VALUES.QR_CODE, + }, + { + name: 'Buy with Dollars', + value: BuyEtherScreen.OPTION_VALUES.COINBASE, + }, + { + name: 'Buy with Cryptos', + value: BuyEtherScreen.OPTION_VALUES.SHAPESHIFT, + }, + ]; + + static propTypes = { + address: PropTypes.string, + goToCoinbase: PropTypes.func.isRequired, + showAccountDetail: PropTypes.func.isRequired, + } + + state = { + selectedOption: BuyEtherScreen.OPTION_VALUES.QR_CODE, + justCopied: false, + } + + copyToClipboard = () => { + const { address } = this.props + + this.setState({ justCopied: true }, () => copyToClipboard(address)) + + setTimeout(() => this.setState({ justCopied: false }), 1000) + } + + renderSkip () { + const {showAccountDetail, address} = this.props + + return ( +
showAccountDetail(address)} + > + Do it later +
+ ) + } + + renderCoinbaseLogo () { + return ( + + + + + + + + + + + + + + + ) + } + + renderCoinbaseForm () { + const {goToCoinbase, address} = this.props + + return ( +
+
{this.renderCoinbaseLogo()}
+
Coinbase is the world’s most popular way to buy and sell bitcoin, ethereum, and litecoin.
+ What is Ethereum? +
+ +
+
+ ) + } + + renderContent () { + const { OPTION_VALUES } = BuyEtherScreen + const { address } = this.props + const { justCopied } = this.state + const qrImage = qrcode(4, 'M') + qrImage.addData(address) + qrImage.make() + + switch (this.state.selectedOption) { + case OPTION_VALUES.COINBASE: + return this.renderCoinbaseForm() + case OPTION_VALUES.SHAPESHIFT: + return ( +
+
+
+ Trade any leading blockchain asset for any other. Protection by Design. No Account Needed. +
+ +
+ ) + case OPTION_VALUES.QR_CODE: + return ( +
+
+
Deposit Ether directly into your account.
+
(This is the account address that MetaMask created for you to recieve funds.)
+
+ +
+
+ ) + default: + return null + } + } + + render () { + const { OPTIONS } = BuyEtherScreen + const { selectedOption } = this.state + + return ( +
+ +
Deposit Ether
+
+ MetaMask works best if you have Ether in your account to pay for transaction gas fees and more. To get Ether, choose from one of these methods. +
+
+
+
Deposit Options
+ {this.renderSkip()} +
+
+
+ {OPTIONS.map(({ name, value }) => ( +
this.setState({ selectedOption: value })} + > +
{name}
+ {value === selectedOption && ( + + + + )} +
+ ))} +
+
+ {this.renderContent()} +
+
+
+
+ ) + } +} + +export default connect( + ({ metamask: { selectedAddress } }) => ({ + address: selectedAddress, + }), + dispatch => ({ + goToCoinbase: address => dispatch(buyEth({ network: '1', address, amount: 0 })), + showAccountDetail: address => dispatch(showAccountDetail(address)), + }) +)(BuyEtherScreen) diff --git a/mascara/src/app/first-time/create-password-screen.js b/mascara/src/app/first-time/create-password-screen.js new file mode 100644 index 000000000..2f4b81e7c --- /dev/null +++ b/mascara/src/app/first-time/create-password-screen.js @@ -0,0 +1,109 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux'; +import {createNewVaultAndKeychain} from '../../../../ui/app/actions' +import LoadingScreen from './loading-screen' +import Breadcrumbs from './breadcrumbs' + +class CreatePasswordScreen extends Component { + static propTypes = { + isLoading: PropTypes.bool.isRequired, + createAccount: PropTypes.func.isRequired, + goToImportWithSeedPhrase: PropTypes.func.isRequired, + goToImportAccount: PropTypes.func.isRequired, + next: PropTypes.func.isRequired + } + + state = { + password: '', + confirmPassword: '' + } + + isValid() { + const {password, confirmPassword} = this.state; + + if (!password || !confirmPassword) { + return false; + } + + if (password.length < 8) { + return false; + } + + return password === confirmPassword; + } + + createAccount = () => { + if (!this.isValid()) { + return; + } + + const {password} = this.state; + const {createAccount, next} = this.props; + + createAccount(password) + .then(next); + } + + render() { + const { isLoading, goToImportAccount, goToImportWithSeedPhrase } = this.props + + return isLoading + ? + : ( +
+
+ Create Password +
+ this.setState({password: e.target.value})} + /> + this.setState({confirmPassword: e.target.value})} + /> + + { + e.preventDefault() + goToImportWithSeedPhrase() + }} + > + Import with seed phrase + + { /* } + { + e.preventDefault() + goToImportAccount() + }} + > + Import an account + + { */ } + +
+ ) + } +} + +export default connect( + ({ appState: { isLoading } }) => ({ isLoading }), + dispatch => ({ + createAccount: password => dispatch(createNewVaultAndKeychain(password)), + }) +)(CreatePasswordScreen) diff --git a/mascara/src/app/first-time/import-account-screen.js b/mascara/src/app/first-time/import-account-screen.js new file mode 100644 index 000000000..bf8e209e4 --- /dev/null +++ b/mascara/src/app/first-time/import-account-screen.js @@ -0,0 +1,203 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' +import classnames from 'classnames' +import LoadingScreen from './loading-screen' +import {importNewAccount, hideWarning} from '../../../../ui/app/actions' + +const Input = ({ label, placeholder, onChange, errorMessage, type = 'text' }) => ( +
+
{label}
+ +
{errorMessage}
+
+) + +Input.prototype.propTypes = { + label: PropTypes.string.isRequired, + placeholder: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + errorMessage: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +} + +class ImportAccountScreen extends Component { + static OPTIONS = { + PRIVATE_KEY: 'private_key', + JSON_FILE: 'json_file', + }; + + static propTypes = { + warning: PropTypes.string, + back: PropTypes.func.isRequired, + next: PropTypes.func.isRequired, + importNewAccount: PropTypes.func.isRequired, + hideWarning: PropTypes.func.isRequired, + isLoading: PropTypes.bool.isRequired, + }; + + state = { + selectedOption: ImportAccountScreen.OPTIONS.PRIVATE_KEY, + privateKey: '', + jsonFile: {}, + } + + isValid () { + const { OPTIONS } = ImportAccountScreen + const { privateKey, jsonFile, password } = this.state + + switch (this.state.selectedOption) { + case OPTIONS.JSON_FILE: + return Boolean(jsonFile && password) + case OPTIONS.PRIVATE_KEY: + default: + return Boolean(privateKey) + } + } + + onClick = () => { + const { OPTIONS } = ImportAccountScreen + const { importNewAccount, next } = this.props + const { privateKey, jsonFile, password } = this.state + + switch (this.state.selectedOption) { + case OPTIONS.JSON_FILE: + return importNewAccount('JSON File', [ jsonFile, password ]) + .then(next) + case OPTIONS.PRIVATE_KEY: + default: + return importNewAccount('Private Key', [ privateKey ]) + .then(next) + } + } + + renderPrivateKey () { + return Input({ + label: 'Add Private Key String', + placeholder: 'Enter private key', + onChange: e => this.setState({ privateKey: e.target.value }), + errorMessage: this.props.warning && 'Something went wrong. Please make sure your private key is correct.', + }) + } + + renderJsonFile () { + const { jsonFile: { name } } = this.state + const { warning } = this.props + + return ( +
+
+
Upload File
+
+ this.setState({ jsonFile: e.target.files[0] })} + /> + +
{name}
+
+
+ {warning && 'Something went wrong. Please make sure your JSON file is properly formatted.'} +
+
+ {Input({ + label: 'Enter Password', + placeholder: 'Enter Password', + type: 'password', + onChange: e => this.setState({ password: e.target.value }), + errorMessage: warning && 'Please make sure your password is correct.', + })} +
+ ) + } + + renderContent () { + const { OPTIONS } = ImportAccountScreen + + switch (this.state.selectedOption) { + case OPTIONS.JSON_FILE: + return this.renderJsonFile() + case OPTIONS.PRIVATE_KEY: + default: + return this.renderPrivateKey() + } + } + + render () { + const { OPTIONS } = ImportAccountScreen + const { selectedOption } = this.state + + return this.props.isLoading + ? + : ( +
+ { + e.preventDefault() + this.props.back() + }} + href="#" + > + {`< Back`} + +
+ Import an Account +
+
+ How would you like to import your account? +
+ + {this.renderContent()} + + + File import not working? + +
+ ) + } +} + +export default connect( + ({ appState: { isLoading, warning } }) => ({ isLoading, warning }), + dispatch => ({ + importNewAccount: (strategy, args) => dispatch(importNewAccount(strategy, args)), + hideWarning: () => dispatch(hideWarning()), + }) +)(ImportAccountScreen) diff --git a/mascara/src/app/first-time/import-seed-phrase-screen.js b/mascara/src/app/first-time/import-seed-phrase-screen.js new file mode 100644 index 000000000..d2eed61b7 --- /dev/null +++ b/mascara/src/app/first-time/import-seed-phrase-screen.js @@ -0,0 +1,103 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' +import LoadingScreen from './loading-screen' +import {createNewVaultAndRestore, hideWarning} from '../../../../ui/app/actions' + +class ImportSeedPhraseScreen extends Component { + static propTypes = { + warning: PropTypes.string, + back: PropTypes.func.isRequired, + next: PropTypes.func.isRequired, + createNewVaultAndRestore: PropTypes.func.isRequired, + hideWarning: PropTypes.func.isRequired, + isLoading: PropTypes.bool.isRequired, + }; + + state = { + seedPhrase: '', + password: '', + confirmPassword: '', + } + + onClick = () => { + const { password, seedPhrase } = this.state + const { createNewVaultAndRestore, next } = this.props + + createNewVaultAndRestore(password, seedPhrase) + .then(next) + } + + isValid () { + const { seedPhrase, password, confirmPassword } = this.state + + if (seedPhrase.split(' ').length !== 12) { + return false + } + + if (password.length < 8) { + return false + } + + if (password !== confirmPassword) { + return false + } + + return true + } + + render () { + return this.props.isLoading + ? + : ( +
+ { + e.preventDefault() + this.props.back() + }} + href="#" + > + {`< Back`} + +
+ Import an Account with Seed Phrase +
+
+ Enter your secret twelve word phrase here to restore your vault. +
+