Require phrase confirmation. (#5731)

* Require phrase confirmation.

* Linting issues.

* Fix dialog title.

* Confirm a backup phrase.

* Confirm recovery phrase on fist run as well.
This commit is contained in:
Tomasz Drwięga 2017-06-09 12:25:14 +02:00 committed by Gav Wood
parent 06eb561af5
commit 5c3ea4ec29
7 changed files with 300 additions and 46 deletions

View File

@ -25,9 +25,16 @@ import styles from '../createAccount.css';
@observer @observer
export default class AccountDetails extends Component { export default class AccountDetails extends Component {
static propTypes = { static propTypes = {
isConfirming: PropTypes.bool,
withRequiredBackup: PropTypes.bool,
createStore: PropTypes.object.isRequired createStore: PropTypes.object.isRequired
} }
static defaultPropTypes = {
isConfirming: false,
withRequiredBackup: false
}
render () { render () {
const { address, description, name } = this.props.createStore; const { address, description, name } = this.props.createStore;
@ -78,31 +85,103 @@ export default class AccountDetails extends Component {
); );
} }
renderPhrase () { renderRequiredBackup () {
const { phrase } = this.props.createStore; const { phraseBackedUp, phraseBackedUpError } = this.props.createStore;
if (!phrase) { if (!this.props.withRequiredBackup) {
return null; return null;
} }
return ( return (
<Input <div>
allowCopy <Input
hint={ error={ phraseBackedUpError }
<FormattedMessage hint={
id='createAccount.accountDetails.phrase.hint' <FormattedMessage
defaultMessage='the account recovery phrase' id='createAccount.accountDetails.phrase.hint'
/> defaultMessage='the account recovery phrase'
} />
label={ }
<FormattedMessage label={
id='createAccount.accountDetails.phrase.label' <FormattedMessage
defaultMessage='owner recovery phrase (keep private and secure, it allows full and unlimited access to the account)' id='createAccount.accountDetails.phrase.backedUp'
/> defaultMessage='Type "I have written down the phrase" below to confirm it is backed up.'
} />
readOnly }
value={ phrase } onChange={ this.onEditPhraseBackedUp }
/> value={ phraseBackedUp }
/>
</div>
); );
} }
renderPhrase () {
const { isConfirming } = this.props;
const { isTest, phrase, backupPhraseError } = this.props.createStore;
const hint = (
<FormattedMessage
id='createAccount.accountDetails.phrase.hint'
defaultMessage='the account recovery phrase'
/>
);
const label = (
<FormattedMessage
id='createAccount.accountDetails.phrase.label'
defaultMessage='owner recovery phrase'
/>
);
if (!isConfirming) {
if (!phrase) {
return null;
}
return (
<div>
<Input
allowCopy
hint={ hint }
label={ label }
readOnly
value={ phrase }
/>
<div className={ styles.backupPhrase }>
<FormattedMessage
id='createAccount.accountDetails.phrase.backup'
defaultMessage='Please back up the recovery phrase now. Make sure to keep it private and secure, it allows full and unlimited access to the account.'
/>
</div>
{ this.renderRequiredBackup() }
</div>
);
}
return (
<div>
<Input
allowPaste={ isTest }
error={ backupPhraseError }
hint={ hint }
label={ label }
onChange={ this.onEditPhrase }
value={ phrase }
/>
<div className={ styles.backupPhrase }>
<FormattedMessage
id='createAccount.accountDetails.phrase.backupConfirm'
defaultMessage='Type your recovery phrase now.'
/>
</div>
</div>
);
}
onEditPhraseBackedUp = (ev) => {
this.props.createStore.setPhraseBackedUp(ev.target.value);
}
onEditPhrase = (ev) => {
this.props.createStore.setPhrase(ev.target.value);
}
} }

View File

@ -131,3 +131,8 @@
padding: 0 4em 1.5em 4em; padding: 0 4em 1.5em 4em;
text-align: center; text-align: center;
} }
.backupPhrase {
line-height: 1.618em;
margin-top: 1.5em;
}

View File

@ -37,7 +37,7 @@ import NewImport from './NewImport';
import NewQr from './NewQr'; import NewQr from './NewQr';
import RawKey from './RawKey'; import RawKey from './RawKey';
import RecoveryPhrase from './RecoveryPhrase'; import RecoveryPhrase from './RecoveryPhrase';
import Store, { STAGE_CREATE, STAGE_INFO, STAGE_SELECT_TYPE } from './store'; import Store, { STAGE_CREATE, STAGE_INFO, STAGE_SELECT_TYPE, STAGE_CONFIRM_BACKUP } from './store';
import TypeIcon from './TypeIcon'; import TypeIcon from './TypeIcon';
import print from './print'; import print from './print';
import recoveryPage from './recoveryPage.ejs'; import recoveryPage from './recoveryPage.ejs';
@ -61,6 +61,12 @@ const TITLES = {
defaultMessage='account information' defaultMessage='account information'
/> />
), ),
backup: (
<FormattedMessage
id='createAccount.title.backupPhrase'
defaultMessage='confirm recovery phrase'
/>
),
import: ( import: (
<FormattedMessage <FormattedMessage
id='createAccount.title.importAccount' id='createAccount.title.importAccount'
@ -80,7 +86,7 @@ const TITLES = {
/> />
) )
}; };
const STAGE_NAMES = [TITLES.type, TITLES.create, TITLES.info]; const STAGE_NAMES = [TITLES.type, TITLES.create, TITLES.info, TITLES.backup];
const STAGE_IMPORT = [TITLES.type, TITLES.import, TITLES.info]; const STAGE_IMPORT = [TITLES.type, TITLES.import, TITLES.info];
const STAGE_RESTORE = [TITLES.restore, TITLES.info]; const STAGE_RESTORE = [TITLES.restore, TITLES.info];
const STAGE_QR = [TITLES.type, TITLES.qr, TITLES.info]; const STAGE_QR = [TITLES.type, TITLES.qr, TITLES.info];
@ -213,14 +219,25 @@ class CreateAccount extends Component {
} }
return ( return (
<AccountDetails createStore={ this.createStore } /> <AccountDetails
createStore={ this.createStore }
withRequiredBackup={ createType === 'fromNew' }
/>
);
case STAGE_CONFIRM_BACKUP:
return (
<AccountDetails
createStore={ this.createStore }
isConfirming
/>
); );
} }
} }
renderDialogActions () { renderDialogActions () {
const { restore } = this.props; const { restore } = this.props;
const { createType, canCreate, isBusy, stage } = this.createStore; const { createType, canCreate, isBusy, stage, phraseBackedUpError } = this.createStore;
const cancelBtn = ( const cancelBtn = (
<Button <Button
@ -281,8 +298,8 @@ class CreateAccount extends Component {
createType === 'fromNew' createType === 'fromNew'
? ( ? (
<FormattedMessage <FormattedMessage
id='createAccount.button.create' id='createAccount.button.next'
defaultMessage='Create' defaultMessage='Next'
/> />
) )
: ( : (
@ -292,7 +309,7 @@ class CreateAccount extends Component {
/> />
) )
} }
onClick={ this.onCreate } onClick={ createType === 'fromNew' ? this.createStore.nextStage : this.onCreate }
/> />
]; ];
@ -314,6 +331,7 @@ class CreateAccount extends Component {
) )
: null, : null,
<Button <Button
disabled={ createType === 'fromNew' && !!phraseBackedUpError }
icon={ <DoneIcon /> } icon={ <DoneIcon /> }
key='done' key='done'
label={ label={
@ -322,12 +340,55 @@ class CreateAccount extends Component {
defaultMessage='Done' defaultMessage='Done'
/> />
} }
onClick={ this.onClose } onClick={ createType === 'fromNew' ? this.onConfirmPhraseBackup : this.onClose }
/>
];
case STAGE_CONFIRM_BACKUP:
return [
<Button
icon={ <DoneIcon /> }
key='done'
label={
<FormattedMessage
id='createAccount.button.create'
defaultMessage='Create'
/>
}
onClick={ this.onCreateNew }
/> />
]; ];
} }
} }
onConfirmPhraseBackup = () => {
this.createStore.clearPhrase();
this.createStore.nextStage();
}
onCreateNew = () => {
this.createStore.setBusy(true);
this.createStore.computeBackupPhraseAddress()
.then(err => {
if (err) {
this.createStore.setBusy(false);
return;
}
return this.createStore.createAccount(this.vaultStore)
.then(() => {
this.createStore.clearPhrase();
this.createStore.setBusy(false);
this.props.onUpdate && this.props.onUpdate();
this.onClose();
});
})
.catch((error) => {
this.createStore.setBusy(false);
this.props.newError(error);
});
}
onCreate = () => { onCreate = () => {
return this.createStore return this.createStore
.createAccount(this.vaultStore) .createAccount(this.vaultStore)
@ -341,6 +402,7 @@ class CreateAccount extends Component {
} }
onClose = () => { onClose = () => {
this.createStore.clearPhrase();
this.props.onClose && this.props.onClose(); this.props.onClose && this.props.onClose();
} }

View File

@ -46,6 +46,20 @@ export default {
/> />
), ),
noMatchBackupPhrase: (
<FormattedMessage
id='errors.noMatchBackupPhrase'
defaultMessage='the supplied recovery phrase does not match'
/>
),
noMatchPhraseBackedUp: (
<FormattedMessage
id='errors.noMatchPhraseBackedUp'
defaultMessage='type "I have written down the phrase"'
/>
),
noName: ( noName: (
<FormattedMessage <FormattedMessage
id='errors.noName' id='errors.noName'
@ -59,4 +73,5 @@ export default {
defaultMessage='the raw key needs to be hex, 64 characters in length and contain the prefix "0x"' defaultMessage='the raw key needs to be hex, 64 characters in length and contain the prefix "0x"'
/> />
) )
}; };

View File

@ -24,6 +24,7 @@ const FAKEPATH = 'C:\\fakepath\\';
const STAGE_SELECT_TYPE = 0; const STAGE_SELECT_TYPE = 0;
const STAGE_CREATE = 1; const STAGE_CREATE = 1;
const STAGE_INFO = 2; const STAGE_INFO = 2;
const STAGE_CONFIRM_BACKUP = 3;
export default class Store { export default class Store {
@observable accounts = null; @observable accounts = null;
@ -41,6 +42,8 @@ export default class Store {
@observable passwordHint = ''; @observable passwordHint = '';
@observable passwordRepeat = ''; @observable passwordRepeat = '';
@observable phrase = ''; @observable phrase = '';
@observable backupPhraseAddress = null;
@observable phraseBackedUp = '';
@observable qrAddress = null; @observable qrAddress = null;
@observable rawKey = ''; @observable rawKey = '';
@observable rawKeyError = ERRORS.nokey; @observable rawKeyError = ERRORS.nokey;
@ -104,17 +107,39 @@ export default class Store {
: ERRORS.noMatchPassword; : ERRORS.noMatchPassword;
} }
@computed get backupPhraseError () {
return !this.backupPhraseAddress || this.address === this.backupPhraseAddress
? null
: ERRORS.noMatchBackupPhrase;
}
@computed get phraseBackedUpError () {
return this.phraseBackedUp === 'I have written down the phrase'
? null
: ERRORS.noMatchPhraseBackedUp;
}
@computed get qrAddressValid () { @computed get qrAddressValid () {
console.log('qrValid', this.qrAddress, this._api.util.isAddressValid(this.qrAddress)); console.log('qrValid', this.qrAddress, this._api.util.isAddressValid(this.qrAddress));
return this._api.util.isAddressValid(this.qrAddress); return this._api.util.isAddressValid(this.qrAddress);
} }
@action clearPhrase = () => {
transaction(() => {
this.phrase = '';
this.phraseBackedUp = '';
});
}
@action clearErrors = () => { @action clearErrors = () => {
transaction(() => { transaction(() => {
this.address = '';
this.description = ''; this.description = '';
this.password = ''; this.password = '';
this.passwordRepeat = ''; this.passwordRepeat = '';
this.phrase = ''; this.phrase = '';
this.backupPhraseAddress = null;
this.phraseBackedUp = '';
this.name = ''; this.name = '';
this.nameError = ERRORS.noName; this.nameError = ERRORS.noName;
this.qrAddress = null; this.qrAddress = null;
@ -192,6 +217,26 @@ export default class Store {
}); });
} }
@action setBackupPhraseAddress = (address) => {
this.backupPhraseAddress = address;
}
@action computeBackupPhraseAddress = () => {
return this._api.parity.phraseToAddress(this.phrase)
.then(address => {
this.setBackupPhraseAddress(address);
return address !== this.address;
})
.catch((error) => {
console.error('createAccount', error);
throw error;
});
}
@action setPhraseBackedUp = (backedUp) => {
this.phraseBackedUp = backedUp;
}
@action setPassword = (password) => { @action setPassword = (password) => {
this.password = password; this.password = password;
} }
@ -217,6 +262,7 @@ export default class Store {
.filter((part) => part.length); .filter((part) => part.length);
this.phrase = phraseParts.join(' '); this.phrase = phraseParts.join(' ');
this.backupPhraseAddress = null;
} }
@action setRawKey = (rawKey) => { @action setRawKey = (rawKey) => {
@ -460,7 +506,8 @@ export default class Store {
} }
export { export {
STAGE_CREATE,
STAGE_INFO, STAGE_INFO,
STAGE_CONFIRM_BACKUP,
STAGE_CREATE,
STAGE_SELECT_TYPE STAGE_SELECT_TYPE
}; };

View File

@ -52,6 +52,10 @@ const STAGE_NAMES = [
id='firstRun.title.recovery' id='firstRun.title.recovery'
defaultMessage='recovery' defaultMessage='recovery'
/>, />,
<FormattedMessage
id='firstRun.title.confirmation'
defaultMessage='confirmation'
/>,
<FormattedMessage <FormattedMessage
id='firstRun.title.completed' id='firstRun.title.completed'
defaultMessage='completed' defaultMessage='completed'
@ -129,9 +133,19 @@ class FirstRun extends Component {
); );
case 3: case 3:
return ( return (
<AccountDetails createStore={ this.createStore } /> <AccountDetails
createStore={ this.createStore }
withRequiredBackup
/>
); );
case 4: case 4:
return (
<AccountDetails
createStore={ this.createStore }
isConfirming
/>
);
case 5:
return ( return (
<Completed /> <Completed />
); );
@ -141,7 +155,7 @@ class FirstRun extends Component {
renderDialogActions () { renderDialogActions () {
const { hasAccounts } = this.props; const { hasAccounts } = this.props;
const { stage, hasAcceptedTnc } = this.state; const { stage, hasAcceptedTnc } = this.state;
const { canCreate } = this.createStore; const { canCreate, phraseBackedUpError } = this.createStore;
switch (stage) { switch (stage) {
case 0: case 0:
@ -169,15 +183,10 @@ class FirstRun extends Component {
const buttons = [ const buttons = [
<Button <Button
disabled={ !canCreate } disabled={ !canCreate }
icon={ <CheckIcon /> } icon={ <NextIcon /> }
key='create' key='next'
label={ label={ BUTTON_LABEL_NEXT }
<FormattedMessage onClick={ this.onNext }
id='firstRun.button.create'
defaultMessage='Create'
/>
}
onClick={ this.onCreate }
/> />
]; ];
@ -212,14 +221,30 @@ class FirstRun extends Component {
onClick={ this.printPhrase } onClick={ this.printPhrase }
/>, />,
<Button <Button
disabled={ !!phraseBackedUpError }
icon={ <NextIcon /> } icon={ <NextIcon /> }
key='next' key='next'
label={ BUTTON_LABEL_NEXT } label={ BUTTON_LABEL_NEXT }
onClick={ this.onNext } onClick={ this.onConfirmPhraseBackup }
/> />
]; ];
case 4: case 4:
return (
<Button
icon={ <CheckIcon /> }
key='create'
label={
<FormattedMessage
id='firstRun.button.create'
defaultMessage='Create'
/>
}
onClick={ this.onCreate }
/>
);
case 5:
return ( return (
<Button <Button
icon={ <DoneIcon /> } icon={ <DoneIcon /> }
@ -244,6 +269,11 @@ class FirstRun extends Component {
}, onClose); }, onClose);
} }
onConfirmPhraseBackup = () => {
this.createStore.clearPhrase();
this.onNext();
}
onNext = () => { onNext = () => {
const { stage } = this.state; const { stage } = this.state;
@ -261,11 +291,19 @@ class FirstRun extends Component {
onCreate = () => { onCreate = () => {
this.createStore.setBusy(true); this.createStore.setBusy(true);
return this.createStore this.createStore.computeBackupPhraseAddress()
.createAccount() .then(err => {
.then(() => { if (err) {
this.onNext(); this.createStore.setBusy(false);
this.createStore.setBusy(false); return;
}
return this.createStore.createAccount()
.then(() => {
this.createStore.clearPhrase();
this.createStore.setBusy(false);
this.onNext();
});
}) })
.catch((error) => { .catch((error) => {
this.createStore.setBusy(false); this.createStore.setBusy(false);

View File

@ -57,6 +57,7 @@ export default class Input extends Component {
PropTypes.string, PropTypes.string,
PropTypes.bool PropTypes.bool
]), ]),
allowPaste: PropTypes.bool,
autoFocus: PropTypes.bool, autoFocus: PropTypes.bool,
children: PropTypes.node, children: PropTypes.node,
className: PropTypes.string, className: PropTypes.string,
@ -97,6 +98,7 @@ export default class Input extends Component {
static defaultProps = { static defaultProps = {
allowCopy: false, allowCopy: false,
allowPaste: true,
escape: 'initial', escape: 'initial',
hideUnderline: false, hideUnderline: false,
onBlur: noop, onBlur: noop,
@ -221,6 +223,12 @@ export default class Input extends Component {
} }
onChange = (event, value) => { onChange = (event, value) => {
if (!this.props.allowPaste) {
if (value.length - this.state.value.length > 8) {
return;
}
}
event.persist(); event.persist();
this.setValue(value, () => { this.setValue(value, () => {