diff --git a/CHANGELOG.md b/CHANGELOG.md index a1907091e..75a1301b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ## [Unreleased] ### Added +* Wallet Encryption/Decryption user flows ([#1785](https://github.com/lbryio/lbry-desktop/pull/1785)) ### Changed diff --git a/package.json b/package.json index cdf37a7b8..0c2724d47 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "formik": "^0.10.4", "hast-util-sanitize": "^1.1.2", "keytar": "^4.2.1", - "lbry-redux": "lbryio/lbry-redux#e0909b08647a790d155f3189b9f9bf0b3e55bd17", + "lbry-redux": "lbryio/lbry-redux#b4fffe863df316bc73183567ab978221ee623b8c", "localforage": "^1.7.1", "mime": "^2.3.1", "mixpanel-browser": "^2.17.1", diff --git a/src/renderer/component/splash/index.js b/src/renderer/component/splash/index.js index 932265392..2464b48fa 100644 --- a/src/renderer/component/splash/index.js +++ b/src/renderer/component/splash/index.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import { selectDaemonVersionMatched } from 'redux/selectors/app'; import { selectNotification } from 'lbry-redux'; -import { doCheckDaemonVersion } from 'redux/actions/app'; +import { doCheckDaemonVersion, doNotifyUnlockWallet } from 'redux/actions/app'; import SplashScreen from './view'; const select = state => ({ @@ -11,6 +11,10 @@ const select = state => ({ const perform = dispatch => ({ checkDaemonVersion: () => dispatch(doCheckDaemonVersion()), + notifyUnlockWallet: () => dispatch(doNotifyUnlockWallet()), }); -export default connect(select, perform)(SplashScreen); +export default connect( + select, + perform +)(SplashScreen); diff --git a/src/renderer/component/splash/view.jsx b/src/renderer/component/splash/view.jsx index 5985503c9..504eced07 100644 --- a/src/renderer/component/splash/view.jsx +++ b/src/renderer/component/splash/view.jsx @@ -1,12 +1,14 @@ import * as React from 'react'; import { Lbry, MODALS } from 'lbry-redux'; import LoadScreen from './internal/load-screen'; +import ModalWalletUnlock from 'modal/modalWalletUnlock'; import ModalIncompatibleDaemon from 'modal/modalIncompatibleDaemon'; import ModalUpgrade from 'modal/modalUpgrade'; import ModalDownloading from 'modal/modalDownloading'; type Props = { checkDaemonVersion: () => Promise, + notifyUnlockWallet: () => Promise, notification: ?{ id: string, }, @@ -17,6 +19,7 @@ type State = { message: string, isRunning: boolean, isLagging: boolean, + launchedModal: boolean, }; export class SplashScreen extends React.PureComponent { @@ -28,6 +31,7 @@ export class SplashScreen extends React.PureComponent { message: __('Connecting'), isRunning: false, isLagging: false, + launchedModal: false, }; } @@ -38,8 +42,11 @@ export class SplashScreen extends React.PureComponent { } _updateStatusCallback(status) { + const { notifyUnlockWallet } = this.props; + const { launchedModal } = this.state; + const startupStatus = status.startup_status; - if (startupStatus.code == 'started') { + if (startupStatus.code === 'started') { // Wait until we are able to resolve a name before declaring // that we are done. // TODO: This is a hack, and the logic should live in the daemon @@ -61,6 +68,7 @@ export class SplashScreen extends React.PureComponent { }); return; } + if (status.blockchain_status && status.blockchain_status.blocks_behind > 0) { const format = status.blockchain_status.blocks_behind == 1 ? '%s block behind' : '%s blocks behind'; @@ -69,6 +77,17 @@ export class SplashScreen extends React.PureComponent { details: __(format, status.blockchain_status.blocks_behind), isLagging: startupStatus.is_lagging, }); + } else if (startupStatus.code === 'waiting_for_wallet_unlock') { + this.setState({ + message: __('Unlock Wallet'), + details: __('Please unlock your wallet to proceed.'), + isLagging: false, + isRunning: true, + }); + + if (launchedModal === false) { + this.setState({ launchedModal: true }, () => notifyUnlockWallet()); + } } else { this.setState({ message: __('Network Loading'), @@ -114,6 +133,7 @@ export class SplashScreen extends React.PureComponent { in the modals won't work. */} {isRunning && ( + {notificationId === MODALS.WALLET_UNLOCK && } {notificationId === MODALS.INCOMPATIBLE_DAEMON && } {notificationId === MODALS.UPGRADE && } {notificationId === MODALS.DOWNLOADING && } diff --git a/src/renderer/modal/modalRouter/view.jsx b/src/renderer/modal/modalRouter/view.jsx index 9f4cd609e..34152119f 100644 --- a/src/renderer/modal/modalRouter/view.jsx +++ b/src/renderer/modal/modalRouter/view.jsx @@ -23,6 +23,9 @@ import ModalSendTip from '../modalSendTip'; import ModalPublish from '../modalPublish'; import ModalOpenExternalLink from '../modalOpenExternalLink'; import ModalConfirmThumbnailUpload from 'modal/modalConfirmThumbnailUpload'; +import ModalWalletEncrypt from 'modal/modalWalletEncrypt'; +import ModalWalletDecrypt from 'modal/modalWalletDecrypt'; +import ModalWalletUnlock from 'modal/modalWalletUnlock'; type Props = { modal: string, @@ -165,6 +168,12 @@ class ModalRouter extends React.PureComponent { return ; case MODALS.CONFIRM_THUMBNAIL_UPLOAD: return ; + case MODALS.WALLET_ENCRYPT: + return ; + case MODALS.WALLET_DECRYPT: + return ; + case MODALS.WALLET_UNLOCK: + return ; default: return null; } diff --git a/src/renderer/modal/modalWalletDecrypt/index.js b/src/renderer/modal/modalWalletDecrypt/index.js new file mode 100644 index 000000000..5df3acdc2 --- /dev/null +++ b/src/renderer/modal/modalWalletDecrypt/index.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { + doHideNotification, + doWalletStatus, + doWalletDecrypt, + selectWalletDecryptSucceeded, +} from 'lbry-redux'; +import ModalWalletDecrypt from './view'; + +const select = state => ({ + walletDecryptSucceded: selectWalletDecryptSucceeded(state), +}); + +const perform = dispatch => ({ + closeModal: () => dispatch(doHideNotification()), + decryptWallet: password => dispatch(doWalletDecrypt(password)), + updateWalletStatus: () => dispatch(doWalletStatus()), +}); + +export default connect( + select, + perform +)(ModalWalletDecrypt); diff --git a/src/renderer/modal/modalWalletDecrypt/view.jsx b/src/renderer/modal/modalWalletDecrypt/view.jsx new file mode 100644 index 000000000..21e742725 --- /dev/null +++ b/src/renderer/modal/modalWalletDecrypt/view.jsx @@ -0,0 +1,63 @@ +// @flow +import React from 'react'; +import { Form, FormRow, FormField } from 'component/common/form'; +import { Modal } from 'modal/modal'; +import Button from 'component/button'; + +type Props = { + closeModal: () => void, + unlockWallet: string => void, + walletDecryptSucceded: boolean, + updateWalletStatus: boolean, +}; + +class ModalWalletDecrypt extends React.PureComponent { + state = { + submitted: false, // Prior actions could be marked complete + }; + + submitDecryptForm() { + this.setState({ submitted: true }); + this.props.decryptWallet(); + } + + componentDidUpdate() { + const { props, state } = this; + + if (state.submitted && props.walletDecryptSucceded === true) { + props.closeModal(); + props.updateWalletStatus(); + } + } + + render() { + const { closeModal, walletDecryptSucceded } = this.props; + + return ( + this.submitDecryptForm()} + onAborted={closeModal} + > +
this.submitDecryptForm()}> + {__( + 'Your wallet has been encrypted with a local password, performing this action will remove this password.' + )} +
+
+
+
+ ); + } +} + +export default ModalWalletDecrypt; diff --git a/src/renderer/modal/modalWalletEncrypt/index.js b/src/renderer/modal/modalWalletEncrypt/index.js new file mode 100644 index 000000000..5a7bead2e --- /dev/null +++ b/src/renderer/modal/modalWalletEncrypt/index.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { + doHideNotification, + doWalletStatus, + doWalletEncrypt, + selectWalletEncryptPending, + selectWalletEncryptSucceeded, + selectWalletEncryptResult, +} from 'lbry-redux'; +import ModalWalletEncrypt from './view'; + +const select = state => ({ + walletEncryptSucceded: selectWalletEncryptSucceeded(state), + walletEncryptResult: selectWalletEncryptResult(state), +}); + +const perform = dispatch => ({ + closeModal: () => dispatch(doHideNotification()), + encryptWallet: password => dispatch(doWalletEncrypt(password)), + updateWalletStatus: () => dispatch(doWalletStatus()), +}); + +export default connect( + select, + perform +)(ModalWalletEncrypt); diff --git a/src/renderer/modal/modalWalletEncrypt/view.jsx b/src/renderer/modal/modalWalletEncrypt/view.jsx new file mode 100644 index 000000000..c276fdc00 --- /dev/null +++ b/src/renderer/modal/modalWalletEncrypt/view.jsx @@ -0,0 +1,144 @@ +// @flow +import React from 'react'; +import { Form, FormRow, FormField } from 'component/common/form'; +import { Modal } from 'modal/modal'; +import Button from 'component/button'; + +type Props = { + closeModal: () => void, + unlockWallet: string => void, + walletEncryptSucceded: boolean, + walletEncryptResult: boolean, + updateWalletStatus: boolean, +}; + +class ModalWalletEncrypt extends React.PureComponent { + state = { + newPassword: null, + newPasswordConfirm: null, + passwordMismatch: false, + understandConfirmed: false, + understandError: false, + submitted: false, // Prior actions could be marked complete + failMessage: false, + }; + + onChangeNewPassword(event) { + this.setState({ newPassword: event.target.value }); + } + + onChangeNewPasswordConfirm(event) { + this.setState({ newPasswordConfirm: event.target.value }); + } + + onChangeUnderstandConfirm(event) { + this.setState({ + understandConfirmed: /^.?i understand.?$/i.test(event.target.value), + }); + } + + submitEncryptForm() { + const { state } = this; + + let invalidEntries = false; + + if (state.newPassword !== state.newPasswordConfirm) { + this.setState({ passwordMismatch: true }); + invalidEntries = true; + } + + if (state.understandConfirmed === false) { + this.setState({ understandError: true }); + invalidEntries = true; + } + + if (invalidEntries === true) { + return; + } + + this.setState({ submitted: true }); + this.props.encryptWallet(state.newPassword); + } + + componentDidUpdate() { + const { props, state } = this; + + if (state.submitted) { + if (props.walletEncryptSucceded === true) { + props.closeModal(); + props.updateWalletStatus(); + } else if (props.walletEncryptSucceded === false) { + // See https://github.com/lbryio/lbry/issues/1307 + this.setState({ failMessage: 'Unable to encrypt wallet.' }); + } + } + } + + render() { + const { closeModal } = this.props; + + const { passwordMismatch, understandError, failMessage } = this.state; + + return ( + this.submitEncryptForm()} + onAborted={closeModal} + > +
this.submitEncryptForm()}> + {__( + 'Encrypting your wallet will require a password to access your local wallet data when LBRY starts. Please enter a new password for your wallet.' + )} + + this.onChangeNewPassword(event)} + /> + + + this.onChangeNewPasswordConfirm(event)} + /> + +
+ {__( + 'If your password is lost, it cannot be recovered. You will not be able to access your wallet without a password.' + )} + + this.onChangeUnderstandConfirm(event)} + /> + +
+
+ {failMessage &&
{__(failMessage)}
} +
+
+ ); + } +} + +export default ModalWalletEncrypt; diff --git a/src/renderer/modal/modalWalletUnlock/index.js b/src/renderer/modal/modalWalletUnlock/index.js new file mode 100644 index 000000000..83c7c60ef --- /dev/null +++ b/src/renderer/modal/modalWalletUnlock/index.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { + doHideNotification, + doWalletUnlock, + selectWalletUnlockPending, + selectWalletUnlockSucceeded, +} from 'lbry-redux'; +import { doQuit } from 'redux/actions/app'; +import ModalWalletUnlock from './view'; + +const select = state => ({ + walletUnlockSucceded: selectWalletUnlockSucceeded(state), +}); + +const perform = dispatch => ({ + closeModal: () => dispatch(doHideNotification()), + quit: () => dispatch(doQuit()), + unlockWallet: password => dispatch(doWalletUnlock(password)), +}); + +export default connect( + select, + perform +)(ModalWalletUnlock); diff --git a/src/renderer/modal/modalWalletUnlock/view.jsx b/src/renderer/modal/modalWalletUnlock/view.jsx new file mode 100644 index 000000000..47c0b400e --- /dev/null +++ b/src/renderer/modal/modalWalletUnlock/view.jsx @@ -0,0 +1,73 @@ +// @flow +import React from 'react'; +import { Form, FormRow, FormField } from 'component/common/form'; +import { Modal } from 'modal/modal'; +import Button from 'component/button'; + +type Props = { + closeModal: () => void, + quit: () => void, + unlockWallet: string => void, + walletUnlockSucceded: boolean, +}; + +class ModalWalletUnlock extends React.PureComponent { + state = { + password: null, + }; + + componentDidUpdate() { + const { props } = this; + + if (props.walletUnlockSucceded === true) { + props.closeModal(); + } + } + + onChangePassword(event) { + this.setState({ password: event.target.value }); + } + + render() { + const { quit, unlockWallet, walletUnlockSucceded, closeModal } = this.props; + + const { password } = this.state; + + return ( + unlockWallet(password)} + onAborted={quit} + > +
unlockWallet(password)}> + {__( + 'Your wallet has been encrypted with a local password. Please enter your wallet password to proceed.' + )} + + this.onChangePassword(event)} + /> + +
+
+
+
+ ); + } +} + +export default ModalWalletUnlock; diff --git a/src/renderer/page/settings/index.js b/src/renderer/page/settings/index.js index 387b0fb88..d0c090d06 100644 --- a/src/renderer/page/settings/index.js +++ b/src/renderer/page/settings/index.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import * as settings from 'constants/settings'; -import { doClearCache } from 'redux/actions/app'; +import { doClearCache, doNotifyEncryptWallet, doNotifyDecryptWallet } from 'redux/actions/app'; import { doSetDaemonSetting, doSetClientSetting, @@ -13,6 +13,7 @@ import { selectLanguages, } from 'redux/selectors/settings'; import { selectCurrentLanguage } from 'redux/selectors/app'; +import { doWalletStatus, selectWalletIsEncrypted } from 'lbry-redux'; import SettingsPage from './view'; const select = state => ({ @@ -26,6 +27,7 @@ const select = state => ({ languages: selectLanguages(state), automaticDarkModeEnabled: makeSelectClientSetting(settings.AUTOMATIC_DARK_MODE_ENABLED)(state), autoplay: makeSelectClientSetting(settings.AUTOPLAY)(state), + walletEncrypted: selectWalletIsEncrypted(state), }); const perform = dispatch => ({ @@ -34,6 +36,9 @@ const perform = dispatch => ({ setClientSetting: (key, value) => dispatch(doSetClientSetting(key, value)), getThemes: () => dispatch(doGetThemes()), changeLanguage: newLanguage => dispatch(doChangeLanguage(newLanguage)), + encryptWallet: () => dispatch(doNotifyEncryptWallet()), + decryptWallet: () => dispatch(doNotifyDecryptWallet()), + updateWalletStatus: () => dispatch(doWalletStatus()), }); export default connect( diff --git a/src/renderer/page/settings/view.jsx b/src/renderer/page/settings/view.jsx index 1bed3cf47..64104c4b3 100644 --- a/src/renderer/page/settings/view.jsx +++ b/src/renderer/page/settings/view.jsx @@ -31,6 +31,9 @@ type Props = { themes: Array, automaticDarkModeEnabled: boolean, autoplay: boolean, + encryptWallet: () => void, + decryptWallet: () => void, + walletEncrypted: boolean, }; type State = { @@ -58,7 +61,10 @@ class SettingsPage extends React.PureComponent { } componentDidMount() { - this.props.getThemes(); + const { props } = this; + + props.getThemes(); + props.updateWalletStatus(); } onRunOnStartChange(event: SyntheticInputEvent<*>) { @@ -111,6 +117,11 @@ class SettingsPage extends React.PureComponent { this.props.setClientSetting(settings.SHOW_NSFW, event.target.checked); } + onChangeEncryptWallet() { + const { props } = this; + props.walletEncrypted ? props.decryptWallet() : props.encryptWallet(); + } + setDaemonSetting(name: string, value: boolean | string | Price) { this.props.setDaemonSetting(name, value); } @@ -138,6 +149,7 @@ class SettingsPage extends React.PureComponent { themes, automaticDarkModeEnabled, autoplay, + walletEncrypted, } = this.props; const noDaemonSettings = !daemonSettings || Object.keys(daemonSettings).length === 0; @@ -272,32 +284,43 @@ class SettingsPage extends React.PureComponent { )} /> - { -
-
{__('Theme')}
- - {themes.map(theme => ( - - ))} - - this.onAutomaticDarkModeChange(e.target.checked)} - checked={automaticDarkModeEnabled} - disabled={isDarkModeEnabled} - postfix={__('Automatic dark mode (9pm to 8am)')} - /> -
- } +
+
{__('Theme')}
+ + {themes.map(theme => ( + + ))} + + this.onAutomaticDarkModeChange(e.target.checked)} + checked={automaticDarkModeEnabled} + disabled={isDarkModeEnabled} + postfix={__('Automatic dark mode (9pm to 8am)')} + /> +
+
+
{__('Wallet Security')}
+ this.onChangeEncryptWallet(e)} + checked={walletEncrypted} + postfix={__('Encrypt my wallet with a custom password.')} + helper={__( + 'Secure your local wallet data with a custom password. Lost passwords cannot be recovered.' + )} + /> +
{__('Application Cache')}
diff --git a/src/renderer/redux/actions/app.js b/src/renderer/redux/actions/app.js index 96d215e23..5e212e9f9 100644 --- a/src/renderer/redux/actions/app.js +++ b/src/renderer/redux/actions/app.js @@ -274,6 +274,36 @@ export function doCheckDaemonVersion() { }; } +export function doNotifyEncryptWallet() { + return dispatch => { + dispatch( + doNotify({ + id: MODALS.WALLET_ENCRYPT, + }) + ); + }; +} + +export function doNotifyDecryptWallet() { + return dispatch => { + dispatch( + doNotify({ + id: MODALS.WALLET_DECRYPT, + }) + ); + }; +} + +export function doNotifyUnlockWallet() { + return dispatch => { + dispatch( + doNotify({ + id: MODALS.WALLET_UNLOCK, + }) + ); + }; +} + export function doAlertError(errorList) { return dispatch => { dispatch( diff --git a/src/renderer/scss/_gui.scss b/src/renderer/scss/_gui.scss index 8ea4b0907..c25782bc3 100644 --- a/src/renderer/scss/_gui.scss +++ b/src/renderer/scss/_gui.scss @@ -370,6 +370,10 @@ p { } } +.error-text { + color: var(--color-error); +} + .thumbnail-preview { height: var(--thumbnail-preview-height); width: var(--thumbnail-preview-width); diff --git a/yarn.lock b/yarn.lock index 8bc5a8eff..cb142e29a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5221,9 +5221,9 @@ lazy-val@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/lazy-val/-/lazy-val-1.0.3.tgz#bb97b200ef00801d94c317e29dc6ed39e31c5edc" -lbry-redux@lbryio/lbry-redux#e0909b08647a790d155f3189b9f9bf0b3e55bd17: +lbry-redux@lbryio/lbry-redux#b4fffe863df316bc73183567ab978221ee623b8c: version "0.0.1" - resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/e0909b08647a790d155f3189b9f9bf0b3e55bd17" + resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/b4fffe863df316bc73183567ab978221ee623b8c" dependencies: proxy-polyfill "0.1.6" reselect "^3.0.0"