diff --git a/renderer/components/Settings/Security/ChangePasswordDialog.js b/renderer/components/Settings/Security/ChangePasswordDialog.js index 9ba5af6dae5..9c1df26e3a4 100644 --- a/renderer/components/Settings/Security/ChangePasswordDialog.js +++ b/renderer/components/Settings/Security/ChangePasswordDialog.js @@ -11,7 +11,7 @@ const DialogWrapper = ({ loginError, clearLoginError, isOpen, onChange, onCancel const formApiRef = useRef(null) useEffect(() => { const { current: formApi } = formApiRef - if (loginError) { + if (loginError && formApi) { formApi.setFormError(loginError) clearLoginError() } diff --git a/renderer/components/Settings/Security/PasswordPromptDialog.js b/renderer/components/Settings/Security/PasswordPromptDialog.js index 70a0b262984..6bc28b19ece 100644 --- a/renderer/components/Settings/Security/PasswordPromptDialog.js +++ b/renderer/components/Settings/Security/PasswordPromptDialog.js @@ -1,13 +1,22 @@ -import React from 'react' +import React, { useEffect, useRef } from 'react' import PropTypes from 'prop-types' import { Flex } from 'rebass/styled-components' import { FormattedMessage, useIntl } from 'react-intl' -import { Dialog, Heading, Button, DialogOverlay } from 'components/UI' +import { Dialog, Heading, Button, DialogOverlay, Message } from 'components/UI' import { PasswordInput, Form } from 'components/Form' import messages from './messages' -const DialogWrapper = ({ isOpen, onOk, onCancel, isPromptMode }) => { +const DialogWrapper = ({ loginError, clearLoginError, isOpen, onOk, onCancel, isPromptMode }) => { const intl = useIntl() + const formApiRef = useRef(null) + useEffect(() => { + const { current: formApi } = formApiRef + if (loginError && formApi) { + formApi.setFormError(loginError) + clearLoginError() + } + }, [loginError, clearLoginError]) + if (!isOpen) { return null } @@ -43,20 +52,32 @@ const DialogWrapper = ({ isOpen, onOk, onCancel, isPromptMode }) => { return ( <DialogOverlay alignItems="center" justifyContent="center" position="fixed"> - <Form onSubmit={handleSubmit}> - <Dialog buttons={buttons} header={header} onClose={onCancel} width={640}> - <Flex alignItems="center" flexDirection="column" width={350}> - <PasswordInput - description={intl.formatMessage(inputDesc)} - field="password" - hasMessageSpacer - isRequired - minLength={6} - width={1} - willAutoFocus - /> - </Flex> - </Dialog> + <Form getApi={api => (formApiRef.current = api)} onSubmit={handleSubmit}> + {({ formState: { submits, error } }) => { + const willValidateInline = submits > 0 + return ( + <Dialog buttons={buttons} header={header} onClose={onCancel} width={640}> + {error && ( + <Message mb={3} variant="error"> + {error} + </Message> + )} + <Flex alignItems="center" flexDirection="column" width={350}> + <PasswordInput + description={intl.formatMessage(inputDesc)} + field="password" + hasMessageSpacer + isRequired + minLength={6} + validateOnBlur={willValidateInline} + validateOnChange={willValidateInline} + width={1} + willAutoFocus + /> + </Flex> + </Dialog> + ) + }} </Form> </DialogOverlay> ) @@ -67,9 +88,11 @@ DialogWrapper.defaultProps = { } DialogWrapper.propTypes = { + clearLoginError: PropTypes.func.isRequired, isOpen: PropTypes.bool.isRequired, isPromptMode: PropTypes.bool, isRestoreMode: PropTypes.bool, + loginError: PropTypes.string, onCancel: PropTypes.func.isRequired, onOk: PropTypes.func.isRequired, } diff --git a/renderer/components/Settings/SettingsFieldsSecurity.js b/renderer/components/Settings/SettingsFieldsSecurity.js index a7aa3927d2c..865cb18bcbe 100644 --- a/renderer/components/Settings/SettingsFieldsSecurity.js +++ b/renderer/components/Settings/SettingsFieldsSecurity.js @@ -1,48 +1,38 @@ import React from 'react' import PropTypes from 'prop-types' -import { useFieldState, useFormState } from 'informed' -import { useIntl } from 'react-intl' import { DataRow } from 'components/UI' -import { PasswordInput, FieldLabelFactory, Toggle } from 'components/Form' +import { FieldLabelFactory } from 'components/Form' import messages from './messages' +import PasswordState from './Security/PasswordState' const FieldLabel = FieldLabelFactory(messages) -const SettingsFieldsSecurity = ({ currentConfig }) => { - const { value: isPasswordActive } = useFieldState('password.active') - const { submits } = useFormState() - const willValidateInline = submits > 0 - const intl = useIntl() - +const SettingsFieldsSecurity = ({ + currentConfig, + changePassword, + enablePassword, + disablePassword, +}) => { return ( - <> - <DataRow - left={<FieldLabel itemKey="password.active" tooltip="password_tooltip" />} - right={<Toggle field="password.active" initialValue={currentConfig.password.active} />} - /> - {isPasswordActive && ( - <DataRow - pt={0} - right={ - <PasswordInput - description={intl.formatMessage({ ...messages.password_value_description })} - field="password.value" - initialValue={currentConfig.password.value} - isRequired - minLength={6} - validateOnBlur={willValidateInline} - validateOnChange={willValidateInline} - width={200} - /> - } + <DataRow + left={<FieldLabel itemKey="password.active" tooltip="password_tooltip" />} + right={ + <PasswordState + onChange={changePassword} + onDisable={disablePassword} + onEnable={enablePassword} + value={currentConfig.password.active} /> - )} - </> + } + /> ) } SettingsFieldsSecurity.propTypes = { + changePassword: PropTypes.func.isRequired, currentConfig: PropTypes.object.isRequired, + disablePassword: PropTypes.func.isRequired, + enablePassword: PropTypes.func.isRequired, } export default SettingsFieldsSecurity diff --git a/renderer/components/Settings/SettingsForm.js b/renderer/components/Settings/SettingsForm.js index 9f8c792bd16..f5b7712cf57 100644 --- a/renderer/components/Settings/SettingsForm.js +++ b/renderer/components/Settings/SettingsForm.js @@ -1,6 +1,5 @@ import React from 'react' import PropTypes from 'prop-types' -import get from 'lodash/get' import { injectIntl } from 'react-intl' import { intlShape } from '@zap/i18n' import { Form } from 'components/Form' @@ -19,11 +18,6 @@ const SettingsForm = ({ }) => { const handleSubmit = async values => { try { - // If password feature has been disabled, reset the password to null. - if (!get(values, 'password.value')) { - values.password.value = null - } - // Save the updated settings. await saveConfigOverrides(values) diff --git a/renderer/components/Settings/SettingsPage.js b/renderer/components/Settings/SettingsPage.js index 6d52df567b4..5286ee35a0a 100644 --- a/renderer/components/Settings/SettingsPage.js +++ b/renderer/components/Settings/SettingsPage.js @@ -11,7 +11,10 @@ import ZapLogo from 'components/Icon/ZapLogo' import SettingsForm from 'containers/Settings/SettingsForm' import SettingsFieldsWallet from './SettingsFieldsWallet' import SettingsFieldsGeneral from './SettingsFieldsGeneral' -import SettingsFieldsSecurity from './SettingsFieldsSecurity' +import SettingsFieldsSecurity from 'containers/Settings/SettingsFieldsSecurity' +import ChangePasswordDialog from 'containers/Settings/ChangePasswordDialog' +import PasswordPromptDialog from 'containers/Settings/PasswordPromptDialog' +import PasswordSetDialog from 'containers/Settings/PasswordSetDialog' import messages from './messages' const SettingsMenu = ({ group, setGroup, isLoggedIn, ...rest }) => { @@ -117,28 +120,33 @@ const SettingsPage = ({ currentConfig, isLoggedIn, ...rest }) => { } return ( - <Flex width={1} {...rest}> - <Sidebar.medium pt={40}> - <Panel> - <Panel.Header mb={40} px={4}> - <ZapLogo height={28} width={28} /> - </Panel.Header> - <Panel.Body sx={{ overflowY: 'overlay' }}> - <SettingsMenu group={group} isLoggedIn={isLoggedIn} setGroup={setGroup} /> - </Panel.Body> - </Panel> - </Sidebar.medium> - <MainContent pb={2} pl={5} pr={6} pt={4}> - <Heading.h1 fontSize={60}> - <FormattedMessage {...messages.settings_title} /> - </Heading.h1> - - <SettingsForm> - <FieldGroup currentConfig={currentConfig} /> - <SettingsActions currentConfig={currentConfig} /> - </SettingsForm> - </MainContent> - </Flex> + <> + <Flex width={1} {...rest}> + <Sidebar.medium pt={40}> + <Panel> + <Panel.Header mb={40} px={4}> + <ZapLogo height={28} width={28} /> + </Panel.Header> + <Panel.Body sx={{ overflowY: 'overlay' }}> + <SettingsMenu group={group} isLoggedIn={isLoggedIn} setGroup={setGroup} /> + </Panel.Body> + </Panel> + </Sidebar.medium> + <MainContent pb={2} pl={5} pr={6} pt={4}> + <Heading.h1 fontSize={60}> + <FormattedMessage {...messages.settings_title} /> + </Heading.h1> + + <SettingsForm> + <FieldGroup currentConfig={currentConfig} /> + <SettingsActions currentConfig={currentConfig} /> + </SettingsForm> + </MainContent> + </Flex> + <ChangePasswordDialog /> + <PasswordPromptDialog /> + <PasswordSetDialog /> + </> ) } diff --git a/renderer/containers/Settings/ChangePasswordDialog.js b/renderer/containers/Settings/ChangePasswordDialog.js new file mode 100644 index 00000000000..5a0ec7ef179 --- /dev/null +++ b/renderer/containers/Settings/ChangePasswordDialog.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux' +import ChangePasswordDialog from 'components/Settings/Security/ChangePasswordDialog' +import { + changePassword as onChange, + accountSelectors, + clearLoginError, + CHANGE_PASSWORD_DIALOG_ID, +} from 'reducers/account' +import { modalSelectors, closeDialog } from 'reducers/modal' + +const onCancel = () => closeDialog(CHANGE_PASSWORD_DIALOG_ID) + +const mapStateToProps = state => ({ + isOpen: modalSelectors.isDialogOpen(state, CHANGE_PASSWORD_DIALOG_ID), + loginError: accountSelectors.loginError(state), +}) + +const mapDispatchToProps = { + onChange, + onCancel, + clearLoginError, +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(ChangePasswordDialog) diff --git a/renderer/containers/Settings/PasswordPromptDialog.js b/renderer/containers/Settings/PasswordPromptDialog.js new file mode 100644 index 00000000000..d34e04ad2ed --- /dev/null +++ b/renderer/containers/Settings/PasswordPromptDialog.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux' +import PasswordPromptDialog from 'components/Settings/Security/PasswordPromptDialog' +import { + accountSelectors, + clearLoginError, + disablePassword as onOk, + PASSWORD_PROMPT_DIALOG_ID, +} from 'reducers/account' +import { modalSelectors, closeDialog } from 'reducers/modal' + +const onCancel = () => closeDialog(PASSWORD_PROMPT_DIALOG_ID) + +const mapStateToProps = state => ({ + isOpen: modalSelectors.isDialogOpen(state, PASSWORD_PROMPT_DIALOG_ID), + loginError: accountSelectors.loginError(state), +}) + +const mapDispatchToProps = { + onOk, + onCancel, + clearLoginError, +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(PasswordPromptDialog) diff --git a/renderer/containers/Settings/PasswordSetDialog.js b/renderer/containers/Settings/PasswordSetDialog.js new file mode 100644 index 00000000000..e1bb8953fc4 --- /dev/null +++ b/renderer/containers/Settings/PasswordSetDialog.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux' +import PasswordPromptDialog from 'components/Settings/Security/PasswordPromptDialog' +import { enablePassword as onOk, PASSWORD_SET_DIALOG_ID } from 'reducers/account' +import { modalSelectors, closeDialog } from 'reducers/modal' + +const onCancel = () => closeDialog(PASSWORD_SET_DIALOG_ID) + +const mapStateToProps = state => ({ + isOpen: modalSelectors.isDialogOpen(state, PASSWORD_SET_DIALOG_ID), + isPromptMode: false, +}) + +const mapDispatchToProps = { + onOk, + onCancel, +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(PasswordPromptDialog) diff --git a/renderer/containers/Settings/SettingsFieldsSecurity.js b/renderer/containers/Settings/SettingsFieldsSecurity.js new file mode 100644 index 00000000000..9ed65404e7a --- /dev/null +++ b/renderer/containers/Settings/SettingsFieldsSecurity.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux' +import SettingsFieldsSecurity from 'components/Settings/SettingsFieldsSecurity' +import { openDialog } from 'reducers/modal' +import { + CHANGE_PASSWORD_DIALOG_ID, + PASSWORD_PROMPT_DIALOG_ID, + PASSWORD_SET_DIALOG_ID, +} from 'reducers/account' + +const changePassword = () => openDialog(CHANGE_PASSWORD_DIALOG_ID) +const disablePassword = () => openDialog(PASSWORD_PROMPT_DIALOG_ID) +const enablePassword = () => openDialog(PASSWORD_SET_DIALOG_ID) + +const mapDispatchToProps = { + changePassword, + enablePassword, + disablePassword, +} + +export default connect( + null, + mapDispatchToProps +)(SettingsFieldsSecurity) diff --git a/renderer/reducers/account.js b/renderer/reducers/account.js index f45ce9ebfe7..c8759aa7088 100644 --- a/renderer/reducers/account.js +++ b/renderer/reducers/account.js @@ -1,6 +1,10 @@ +import { send } from 'redux-electron-ipc' import { getIntl } from '@zap/i18n' +import { sha256digest } from '@zap/utils/crypto' import createReducer from './utils/createReducer' -import { settingsSelectors } from './settings' +import { settingsSelectors, saveConfigOverrides } from './settings' +import { closeDialog } from './modal' +import { showNotification } from './notification' import messages from './messages' // ------------------------------------ @@ -29,6 +33,10 @@ export const LOGIN_SUCCESS = 'LOGIN_SUCCESS' export const LOGIN_FAILURE = 'LOGIN_FAILURE' export const LOGIN_CLEAR_ERROR = 'LOGIN_CLEAR_ERROR' +export const CHANGE_PASSWORD_DIALOG_ID = 'CHANGE_PASSWORD_DIALOG' +export const PASSWORD_PROMPT_DIALOG_ID = 'PASSWORD_PROMPT_DIALOG' +export const PASSWORD_SET_DIALOG_ID = 'PASSWORD_SET_DIALOG_ID' + // ------------------------------------ // Actions // ------------------------------------ @@ -53,21 +61,91 @@ export const initAccount = () => async (dispatch, getState) => { } } +/** + * setPassword - Updates wallet password. + * + * @param {string} password new password + * @returns {Function} Thunk + */ +const setPassword = password => async dispatch => { + dispatch(send('setPassword', { password: await sha256digest(password) })) +} + +export const changePassword = ({ newPassword, oldPassword }) => async dispatch => { + try { + const intl = getIntl() + await dispatch(requirePassword(oldPassword)) + await dispatch(setPassword(newPassword)) + dispatch(closeDialog(CHANGE_PASSWORD_DIALOG_ID)) + dispatch(showNotification(intl.formatMessage(messages.account_password_updated))) + } catch (error) { + dispatch({ type: LOGIN_FAILURE, error: error.message }) + } +} + +export const enablePassword = ({ password }) => async dispatch => { + try { + const intl = getIntl() + dispatch(setPassword(password)) + dispatch( + saveConfigOverrides({ + password: { + active: true, + }, + }) + ) + dispatch(closeDialog(PASSWORD_SET_DIALOG_ID)) + dispatch(showNotification(intl.formatMessage(messages.account_password_enabled))) + } catch (error) { + dispatch({ type: LOGIN_FAILURE, error: error.message }) + } +} + +export const disablePassword = ({ password }) => async dispatch => { + try { + const intl = getIntl() + await dispatch(requirePassword(password)) + dispatch(send('deletePassword')) + dispatch( + saveConfigOverrides({ + password: { + active: false, + }, + }) + ) + dispatch(closeDialog(PASSWORD_PROMPT_DIALOG_ID)) + dispatch(showNotification(intl.formatMessage(messages.account_password_disabled))) + } catch (error) { + dispatch({ type: LOGIN_FAILURE, error: error.message }) + } +} + +const requirePassword = password => dispatch => { + return new Promise((resolve, reject) => { + dispatch(send('getPassword')) + // compare hash received from the main thread to a hash of a password provided + window.ipcRenderer.once('getPassword', async (event, { password: hash }) => { + const passwordHash = await sha256digest(password) + if (hash === passwordHash) { + resolve() + } else { + reject(new Error(getIntl().formatMessage(messages.account_invalid_password))) + } + }) + }) +} + /** * login - Perform account login. * * @param {string} password Password * @returns {Function} Thunk */ -export const login = password => (dispatch, getState) => { - dispatch({ type: LOGIN, password }) +export const login = password => async dispatch => { try { - const accountPassword = accountSelectors.accountPassword(getState()) - if (accountPassword === password) { - dispatch({ type: LOGIN_SUCCESS }) - } else { - throw new Error(getIntl().formatMessage(messages.account_invalid_password)) - } + dispatch({ type: LOGIN, password }) + await dispatch(requirePassword(password)) + dispatch({ type: LOGIN_SUCCESS }) } catch (error) { dispatch({ type: LOGIN_FAILURE, error: error.message }) } @@ -133,6 +211,8 @@ const isLoggingInSelector = state => state.account.isLoggingIn const isLoggedInSelector = state => state.account.isLoggedIn const loginErrorSelector = state => state.account.loginError +const isChangePasswordDialogOpenSelector = state => state.account.isChangePasswordDialogOpen + const isAccountPasswordEnabledSelector = state => settingsSelectors.currentConfig(state).password.active const accountPasswordSelector = state => settingsSelectors.currentConfig(state).password.value @@ -149,6 +229,7 @@ accountSelectors.loginError = loginErrorSelector accountSelectors.isAccountPasswordEnabled = isAccountPasswordEnabledSelector accountSelectors.accountPassword = accountPasswordSelector +accountSelectors.isChangePasswordDialogOpen = isChangePasswordDialogOpenSelector export { accountSelectors }