diff --git a/web/settings/password-change-modal.css b/web/settings/password-change-modal.css new file mode 100644 index 000000000..9b0230e25 --- /dev/null +++ b/web/settings/password-change-modal.css @@ -0,0 +1,144 @@ +div.modal-body { + padding: 6px 6px; + width: 100%; + box-sizing: border-box; + background-color: var(--modal-bg); + border-bottom-left-radius: 15px; + border-bottom-right-radius: 15px; + flex: 1; + display: flex; + flex-direction: column; +} +div.resized-modal-body { + min-height: 250px; +} +div.modal-body p { + padding: 1px 3px 4px 3px; + font-size: 14px; + text-align: center; +} +div.modal-body p.form-pre-footer { + padding-top: 5px; + font-size: 12px; + font-style: italic; +} +div.modal-body textarea { + margin: 3px; +} +div.modal-body textarea { + font-size: 14px; + padding: 1px; + width: 175px; +} +div.large-modal-container div.modal-body textarea { + width: 275px; +} +div.modal-body p.confirm-account-password { + color: #777777; + margin-bottom: 4px; + color: var(--fg); +} +div.modal-body div.form-footer { + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + padding-top: 8px; +} +div.modal-body div.form-footer div.modal-form-error { + font-size: 12px; + color: red; + font-style: italic; + padding-left: 6px; + align-self: center; +} +div.modal-body div.form-footer div.modal-form-error ol { + padding-left: 20px; +} +div.modal-body div.form-title { + display: inline-block; + text-align: right; + padding-right: 5px; + padding-top: 5px; + font-size: 14px; + font-weight: 600; + vertical-align: top; + width: 110px; + color: var(--fg); +} +div.large-modal-container div.modal-body div.form-title { + width: 140px; +} +div.modal-body div.form-content { + display: inline-block; + font-family: var(--font-stack); + color: var(--fg); +} +div.modal-body div.form-content input { + margin-bottom: 4px; +} +div.modal-body div.form-subtitle { + font-size: 12px; + padding-left: 4px; + font-style: italic; +} + +div.form-text { + display: flex; + align-items: baseline; +} +div.form-text > div.form-title { + vertical-align: initial; + flex-shrink: 0; +} +div.form-text > div.form-content { + margin-left: 3px; + margin-bottom: 3px; + word-break: break-word; +} + +div.form-text > div.form-float-title { + float: left; + text-align: right; + padding-right: 5px; + font-size: 14px; + font-weight: 600; + width: 110px; +} +div.form-text > div.form-float-content { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 14px; + padding: 1px 20px 3px 4px; + margin-top: 5px; +} + +.italic { + font-style: italic; + color: var(--fg); +} + +ul.tab-panel { + background-color: var(--modal-bg); + padding-left: 10px; + padding-top: 5px; +} +ul.tab-panel > li { + display: inline-block; + list-style-type: none; + font-size: 13px; + font-weight: 600; + cursor: pointer; + padding: 3px 10px 3px 10px; +} +ul.tab-panel > li > a { + color: #555555; +} +ul.tab-panel > li.delete-tab > a { + color: #ff0000 !important; +} + +div.user-settings-current-password { + padding-top: 4px; + margin-top: 5px; +} diff --git a/web/settings/password-change-modal.js b/web/settings/password-change-modal.js new file mode 100644 index 000000000..66ae9c9e6 --- /dev/null +++ b/web/settings/password-change-modal.js @@ -0,0 +1,440 @@ +// @flow + +import classNames from 'classnames'; +import invariant from 'invariant'; +import * as React from 'react'; + +import { + deleteAccountActionTypes, + deleteAccount, + changeUserPasswordActionTypes, + changeUserPassword, + logOut, + logOutActionTypes, +} from 'lib/actions/user-actions'; +import { preRequestUserStateSelector } from 'lib/selectors/account-selectors'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import type { LogOutResult } from 'lib/types/account-types'; +import { type PreRequestUserState } from 'lib/types/session-types'; +import { + type PasswordUpdate, + type CurrentUserInfo, +} from 'lib/types/user-types'; +import { + type DispatchActionPromise, + useDispatchActionPromise, + useServerCall, +} from 'lib/utils/action-utils'; + +import Button from '../components/button.react'; +import Input from '../modals/input.react'; +import { useModalContext } from '../modals/modal-provider.react'; +import Modal from '../modals/modal.react'; +import { useSelector } from '../redux/redux-utils'; +import css from './password-change-modal.css'; + +type TabType = 'general' | 'delete'; +type TabProps = { + +name: string, + +tabType: TabType, + +selected: boolean, + +onClick: (tabType: TabType) => void, +}; +class Tab extends React.PureComponent { + render() { + const { selected, name, tabType } = this.props; + const classNamesForTab = classNames({ + [css['current-tab']]: selected, + [css['delete-tab']]: selected && tabType === 'delete', + }); + return ( +
  • + {name} +
  • + ); + } + + onClick = () => { + return this.props.onClick(this.props.tabType); + }; +} + +type Props = { + +currentUserInfo: ?CurrentUserInfo, + +preRequestUserState: PreRequestUserState, + +inputDisabled: boolean, + +dispatchActionPromise: DispatchActionPromise, + +deleteAccount: ( + password: string, + preRequestUserState: PreRequestUserState, + ) => Promise, + +changeUserPassword: (passwordUpdate: PasswordUpdate) => Promise, + +logOut: (preRequestUserState: PreRequestUserState) => Promise, + +clearModal: () => void, +}; +type State = { + +newPassword: string, + +confirmNewPassword: string, + +currentPassword: string, + +errorMessage: string, + +currentTabType: TabType, +}; + +class PasswordChangeModal extends React.PureComponent { + newPasswordInput: ?HTMLInputElement; + currentPasswordInput: ?HTMLInputElement; + + constructor(props: Props) { + super(props); + this.state = { + newPassword: '', + confirmNewPassword: '', + currentPassword: '', + errorMessage: '', + currentTabType: 'general', + }; + } + + componentDidMount() { + invariant(this.newPasswordInput, 'newPasswordInput ref unset'); + this.newPasswordInput.focus(); + } + + get username() { + return this.props.currentUserInfo && !this.props.currentUserInfo.anonymous + ? this.props.currentUserInfo.username + : undefined; + } + + onLogOut = (event: SyntheticEvent) => { + event.preventDefault(); + this.props.dispatchActionPromise(logOutActionTypes, this.logOut()); + }; + + logOut = async () => { + await this.props.logOut(this.props.preRequestUserState); + this.props.clearModal(); + }; + + render() { + const { inputDisabled } = this.props; + let mainContent = null; + if (this.state.currentTabType === 'general') { + mainContent = ( +
    +
    +
    Username
    +
    {this.username}
    +
    +
    +
    New password
    +
    +
    + +
    +
    + +
    +
    +
    +
    + ); + } else if (this.state.currentTabType === 'delete') { + mainContent = ( +

    + Your account will be permanently deleted. There is no way to reverse + this. +

    + ); + } + + let buttons = null; + if (this.state.currentTabType === 'delete') { + buttons = ( + + ); + } else { + buttons = ( + <> + + + + ); + } + + let errorMsg; + if (this.state.errorMessage) { + errorMsg = ( +
    {this.state.errorMessage}
    + ); + } + + return ( + +
      + + +
    +
    +
    + {mainContent} +
    +

    + Please enter your current password to confirm your identity +

    +
    Current password
    +
    + +
    +
    +
    + {buttons} + {errorMsg} +
    +
    +
    +
    + ); + } + + newPasswordInputRef = (newPasswordInput: ?HTMLInputElement) => { + this.newPasswordInput = newPasswordInput; + }; + + currentPasswordInputRef = (currentPasswordInput: ?HTMLInputElement) => { + this.currentPasswordInput = currentPasswordInput; + }; + + setTab = (tabType: TabType) => { + this.setState({ currentTabType: tabType }); + }; + + onChangeNewPassword = (event: SyntheticEvent) => { + const target = event.target; + invariant(target instanceof HTMLInputElement, 'target not input'); + this.setState({ newPassword: target.value }); + }; + + onChangeConfirmNewPassword = (event: SyntheticEvent) => { + const target = event.target; + invariant(target instanceof HTMLInputElement, 'target not input'); + this.setState({ confirmNewPassword: target.value }); + }; + + onChangeCurrentPassword = (event: SyntheticEvent) => { + const target = event.target; + invariant(target instanceof HTMLInputElement, 'target not input'); + this.setState({ currentPassword: target.value }); + }; + + onSubmit = (event: SyntheticEvent) => { + event.preventDefault(); + + if (this.state.newPassword === '') { + this.setState( + { + newPassword: '', + confirmNewPassword: '', + errorMessage: 'empty password', + currentTabType: 'general', + }, + () => { + invariant(this.newPasswordInput, 'newPasswordInput ref unset'); + this.newPasswordInput.focus(); + }, + ); + } else if (this.state.newPassword !== this.state.confirmNewPassword) { + this.setState( + { + newPassword: '', + confirmNewPassword: '', + errorMessage: "passwords don't match", + currentTabType: 'general', + }, + () => { + invariant(this.newPasswordInput, 'newPasswordInput ref unset'); + this.newPasswordInput.focus(); + }, + ); + return; + } + + this.props.dispatchActionPromise( + changeUserPasswordActionTypes, + this.changeUserSettingsAction(), + ); + }; + + async changeUserSettingsAction() { + try { + await this.props.changeUserPassword({ + updatedFields: { + password: this.state.newPassword, + }, + currentPassword: this.state.currentPassword, + }); + this.props.clearModal(); + } catch (e) { + if (e.message === 'invalid_credentials') { + this.setState( + { + currentPassword: '', + errorMessage: 'wrong current password', + }, + () => { + invariant( + this.currentPasswordInput, + 'currentPasswordInput ref unset', + ); + this.currentPasswordInput.focus(); + }, + ); + } else { + this.setState( + { + newPassword: '', + confirmNewPassword: '', + currentPassword: '', + errorMessage: 'unknown error', + currentTabType: 'general', + }, + () => { + invariant(this.newPasswordInput, 'newPasswordInput ref unset'); + this.newPasswordInput.focus(); + }, + ); + } + throw e; + } + } + + onDelete = (event: SyntheticEvent) => { + event.preventDefault(); + this.props.dispatchActionPromise( + deleteAccountActionTypes, + this.deleteAction(), + ); + }; + + async deleteAction() { + try { + const response = await this.props.deleteAccount( + this.state.currentPassword, + this.props.preRequestUserState, + ); + this.props.clearModal(); + return response; + } catch (e) { + const errorMessage = + e.message === 'invalid_credentials' + ? 'wrong password' + : 'unknown error'; + this.setState( + { + currentPassword: '', + errorMessage: errorMessage, + }, + () => { + invariant( + this.currentPasswordInput, + 'currentPasswordInput ref unset', + ); + this.currentPasswordInput.focus(); + }, + ); + throw e; + } + } +} + +const deleteAccountLoadingStatusSelector = createLoadingStatusSelector( + deleteAccountActionTypes, +); +const changeUserPasswordLoadingStatusSelector = createLoadingStatusSelector( + changeUserPasswordActionTypes, +); +const ConnectedPasswordChangeModal: React.ComponentType<{}> = React.memo<{}>( + function ConnectedPasswordChangeModal(): React.Node { + const currentUserInfo = useSelector(state => state.currentUserInfo); + const preRequestUserState = useSelector(preRequestUserStateSelector); + const inputDisabled = useSelector( + state => + deleteAccountLoadingStatusSelector(state) === 'loading' || + changeUserPasswordLoadingStatusSelector(state) === 'loading', + ); + const callDeleteAccount = useServerCall(deleteAccount); + const callChangeUserPassword = useServerCall(changeUserPassword); + const dispatchActionPromise = useDispatchActionPromise(); + const boundLogOut = useServerCall(logOut); + + const modalContext = useModalContext(); + + return ( + + ); + }, +); + +export default ConnectedPasswordChangeModal;