You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			350 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			350 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
| /* eslint-disable @typescript-eslint/no-misused-promises */
 | |
| 
 | |
| import autoBind from 'auto-bind';
 | |
| import { Component } from 'react';
 | |
| import { ToastUtils } from '../../session/utils';
 | |
| import { sessionPassword } from '../../state/ducks/modalDialog';
 | |
| import { LocalizerKeys } from '../../types/LocalizerKeys';
 | |
| import type { PasswordAction } from '../../types/ReduxTypes';
 | |
| import { assertUnreachable } from '../../types/sqlSharedTypes';
 | |
| import { matchesHash, validatePassword } from '../../util/passwordUtils';
 | |
| import { getPasswordHash } from '../../util/storage';
 | |
| import { SessionWrapperModal } from '../SessionWrapperModal';
 | |
| import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
 | |
| import { SpacerSM } from '../basic/Text';
 | |
| 
 | |
| interface Props {
 | |
|   passwordAction: PasswordAction;
 | |
|   onOk: () => void;
 | |
| }
 | |
| 
 | |
| interface State {
 | |
|   error: string | null;
 | |
|   currentPasswordEntered: string | null;
 | |
|   currentPasswordConfirmEntered: string | null;
 | |
|   currentPasswordRetypeEntered: string | null;
 | |
| }
 | |
| 
 | |
| export class SessionSetPasswordDialog extends Component<Props, State> {
 | |
|   private passportInput: HTMLInputElement | null = null;
 | |
| 
 | |
|   constructor(props: any) {
 | |
|     super(props);
 | |
| 
 | |
|     this.state = {
 | |
|       error: null,
 | |
|       currentPasswordEntered: null,
 | |
|       currentPasswordConfirmEntered: null,
 | |
|       currentPasswordRetypeEntered: null,
 | |
|     };
 | |
| 
 | |
|     autoBind(this);
 | |
|   }
 | |
| 
 | |
|   public componentDidMount() {
 | |
|     document.addEventListener('keyup', this.onEnterPressed);
 | |
| 
 | |
|     setTimeout(() => {
 | |
|       this.passportInput?.focus();
 | |
|     }, 1);
 | |
|   }
 | |
| 
 | |
|   public componentWillUnmount() {
 | |
|     document.removeEventListener('keyup', this.onEnterPressed);
 | |
|   }
 | |
| 
 | |
|   public render() {
 | |
|     const { passwordAction } = this.props;
 | |
|     let placeholders: Array<string> = [];
 | |
|     switch (passwordAction) {
 | |
|       case 'change':
 | |
|         placeholders = [
 | |
|           window.i18n('typeInOldPassword'),
 | |
|           window.i18n('enterNewPassword'),
 | |
|           window.i18n('confirmNewPassword'),
 | |
|         ];
 | |
|         break;
 | |
|       case 'remove':
 | |
|         placeholders = [window.i18n('enterPassword')];
 | |
|         break;
 | |
|       case 'enter':
 | |
|         placeholders = [window.i18n('enterPassword')];
 | |
|         break;
 | |
|       default:
 | |
|         placeholders = [window.i18n('createPassword'), window.i18n('confirmPassword')];
 | |
|     }
 | |
| 
 | |
|     const confirmButtonText =
 | |
|       passwordAction === 'remove' ? window.i18n('remove') : window.i18n('done');
 | |
|     // do this separately so typescript's compiler likes it
 | |
|     const localizedKeyAction: LocalizerKeys =
 | |
|       passwordAction === 'change'
 | |
|         ? 'changePassword'
 | |
|         : passwordAction === 'remove'
 | |
|           ? 'removePassword'
 | |
|           : passwordAction === 'enter'
 | |
|             ? 'passwordViewTitle'
 | |
|             : 'setPassword';
 | |
| 
 | |
|     return (
 | |
|       <SessionWrapperModal title={window.i18n(localizedKeyAction)} onClose={this.closeDialog}>
 | |
|         <SpacerSM />
 | |
| 
 | |
|         <div className="session-modal__input-group">
 | |
|           <input
 | |
|             type="password"
 | |
|             id="password-modal-input"
 | |
|             ref={input => {
 | |
|               this.passportInput = input;
 | |
|             }}
 | |
|             placeholder={placeholders[0]}
 | |
|             onChange={this.onPasswordInput}
 | |
|             onPaste={this.onPasswordInput}
 | |
|             data-testid="password-input"
 | |
|           />
 | |
|           {passwordAction !== 'enter' && passwordAction !== 'remove' && (
 | |
|             <input
 | |
|               type="password"
 | |
|               id="password-modal-input-confirm"
 | |
|               placeholder={placeholders[1]}
 | |
|               onChange={this.onPasswordConfirmInput}
 | |
|               onPaste={this.onPasswordConfirmInput}
 | |
|               data-testid="password-input-confirm"
 | |
|             />
 | |
|           )}
 | |
|           {passwordAction === 'change' && (
 | |
|             <input
 | |
|               type="password"
 | |
|               id="password-modal-input-reconfirm"
 | |
|               placeholder={placeholders[2]}
 | |
|               onPaste={this.onPasswordRetypeInput}
 | |
|               onChange={this.onPasswordRetypeInput}
 | |
|               data-testid="password-input-reconfirm"
 | |
|             />
 | |
|           )}
 | |
|         </div>
 | |
| 
 | |
|         <SpacerSM />
 | |
| 
 | |
|         <div className="session-modal__button-group">
 | |
|           <SessionButton
 | |
|             text={confirmButtonText}
 | |
|             buttonColor={passwordAction === 'remove' ? SessionButtonColor.Danger : undefined}
 | |
|             buttonType={SessionButtonType.Simple}
 | |
|             onClick={this.setPassword}
 | |
|           />
 | |
|           {passwordAction !== 'enter' && (
 | |
|             <SessionButton
 | |
|               text={window.i18n('cancel')}
 | |
|               buttonColor={passwordAction !== 'remove' ? SessionButtonColor.Danger : undefined}
 | |
|               buttonType={SessionButtonType.Simple}
 | |
|               onClick={this.closeDialog}
 | |
|             />
 | |
|           )}
 | |
|         </div>
 | |
|       </SessionWrapperModal>
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   public validatePasswordHash(password: string | null) {
 | |
|     // Check if the password matches the hash we have stored
 | |
|     const hash = getPasswordHash();
 | |
|     if (hash && !matchesHash(password, hash)) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     return true;
 | |
|   }
 | |
| 
 | |
|   private showError() {
 | |
|     if (this.state.error) {
 | |
|       ToastUtils.pushToastError('enterPasswordErrorToast', this.state.error);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Returns false and set the state error field in the input is not a valid password
 | |
|    * or returns true
 | |
|    */
 | |
|   private validatePassword(firstPassword: string) {
 | |
|     // if user did not fill the first password field, we can't do anything
 | |
|     const errorFirstInput = validatePassword(firstPassword);
 | |
|     if (errorFirstInput !== null) {
 | |
|       this.setState({
 | |
|         error: errorFirstInput,
 | |
|       });
 | |
|       this.showError();
 | |
|       return false;
 | |
|     }
 | |
|     return true;
 | |
|   }
 | |
| 
 | |
|   private async handleActionSet(enteredPassword: string, enteredPasswordConfirm: string) {
 | |
|     // be sure both password are valid
 | |
|     if (!this.validatePassword(enteredPassword)) {
 | |
|       return;
 | |
|     }
 | |
|     // no need to validate second password. we just need to check that enteredPassword is valid, and that both password matches
 | |
| 
 | |
|     if (enteredPassword !== enteredPasswordConfirm) {
 | |
|       this.setState({
 | |
|         error: window.i18n('setPasswordInvalid'),
 | |
|       });
 | |
|       this.showError();
 | |
|       return;
 | |
|     }
 | |
|     await window.setPassword(enteredPassword, null);
 | |
|     ToastUtils.pushToastSuccess(
 | |
|       'setPasswordSuccessToast',
 | |
|       window.i18n('setPasswordTitle'),
 | |
|       window.i18n('setPasswordToastDescription')
 | |
|     );
 | |
| 
 | |
|     this.props.onOk();
 | |
|     this.closeDialog();
 | |
|   }
 | |
| 
 | |
|   private async handleActionChange(
 | |
|     oldPassword: string,
 | |
|     newPassword: string,
 | |
|     newConfirmedPassword: string
 | |
|   ) {
 | |
|     // We don't validate oldPassword on change: this is validate on the validatePasswordHash below
 | |
|     // we only validate the newPassword here
 | |
|     if (!this.validatePassword(newPassword)) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     // Check the retyped password matches the new password
 | |
|     if (newPassword !== newConfirmedPassword) {
 | |
|       this.setState({
 | |
|         error: window.i18n('passwordsDoNotMatch'),
 | |
|       });
 | |
|       this.showError();
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const isValidWithStoredInDB = this.validatePasswordHash(oldPassword);
 | |
|     if (!isValidWithStoredInDB) {
 | |
|       this.setState({
 | |
|         error: window.i18n('changePasswordInvalid'),
 | |
|       });
 | |
|       this.showError();
 | |
|       return;
 | |
|     }
 | |
|     await window.setPassword(newPassword, oldPassword);
 | |
| 
 | |
|     ToastUtils.pushToastSuccess(
 | |
|       'setPasswordSuccessToast',
 | |
|       window.i18n('changePasswordTitle'),
 | |
|       window.i18n('changePasswordToastDescription')
 | |
|     );
 | |
| 
 | |
|     this.props.onOk();
 | |
|     this.closeDialog();
 | |
|   }
 | |
| 
 | |
|   private async handleActionRemove(oldPassword: string) {
 | |
|     // We don't validate oldPassword on change: this is validate on the validatePasswordHash below
 | |
|     const isValidWithStoredInDB = this.validatePasswordHash(oldPassword);
 | |
|     if (!isValidWithStoredInDB) {
 | |
|       this.setState({
 | |
|         error: window.i18n('removePasswordInvalid'),
 | |
|       });
 | |
|       this.showError();
 | |
|       return;
 | |
|     }
 | |
|     await window.setPassword(null, oldPassword);
 | |
| 
 | |
|     ToastUtils.pushToastWarning(
 | |
|       'setPasswordSuccessToast',
 | |
|       window.i18n('removePasswordTitle'),
 | |
|       window.i18n('removePasswordToastDescription')
 | |
|     );
 | |
| 
 | |
|     this.props.onOk();
 | |
|     this.closeDialog();
 | |
|   }
 | |
| 
 | |
|   private async onEnterPressed(event: any) {
 | |
|     if (event.key === 'Enter') {
 | |
|       event.stopPropagation();
 | |
|       await this.setPassword();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private async handleActionEnter(enteredPassword: string) {
 | |
|     // be sure the password is valid
 | |
|     if (!this.validatePassword(enteredPassword)) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const isValidWithStoredInDB = this.validatePasswordHash(enteredPassword);
 | |
|     if (!isValidWithStoredInDB) {
 | |
|       this.setState({
 | |
|         error: window.i18n('invalidPassword'),
 | |
|       });
 | |
|       this.showError();
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     this.props.onOk();
 | |
|     this.closeDialog();
 | |
|   }
 | |
| 
 | |
|   private async setPassword() {
 | |
|     const { passwordAction } = this.props;
 | |
|     const { currentPasswordEntered, currentPasswordConfirmEntered, currentPasswordRetypeEntered } =
 | |
|       this.state;
 | |
| 
 | |
|     // Trim leading / trailing whitespace for UX
 | |
|     const firstPasswordEntered = (currentPasswordEntered || '').trim();
 | |
|     const secondPasswordEntered = (currentPasswordConfirmEntered || '').trim();
 | |
|     const thirdPasswordEntered = (currentPasswordRetypeEntered || '').trim();
 | |
| 
 | |
|     switch (passwordAction) {
 | |
|       case 'set': {
 | |
|         await this.handleActionSet(firstPasswordEntered, secondPasswordEntered);
 | |
|         return;
 | |
|       }
 | |
|       case 'change': {
 | |
|         await this.handleActionChange(
 | |
|           firstPasswordEntered,
 | |
|           secondPasswordEntered,
 | |
|           thirdPasswordEntered
 | |
|         );
 | |
|         return;
 | |
|       }
 | |
|       case 'remove': {
 | |
|         await this.handleActionRemove(firstPasswordEntered);
 | |
|         return;
 | |
|       }
 | |
|       case 'enter': {
 | |
|         await this.handleActionEnter(firstPasswordEntered);
 | |
|         return;
 | |
|       }
 | |
|       default:
 | |
|         assertUnreachable(passwordAction, 'passwordAction');
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private closeDialog() {
 | |
|     window.inboxStore?.dispatch(sessionPassword(null));
 | |
|   }
 | |
| 
 | |
|   private onPasswordInput(event: any) {
 | |
|     const currentPasswordEntered = event.target.value;
 | |
|     this.setState({ currentPasswordEntered });
 | |
|   }
 | |
| 
 | |
|   private onPasswordConfirmInput(event: any) {
 | |
|     const currentPasswordConfirmEntered = event.target.value;
 | |
|     this.setState({ currentPasswordConfirmEntered });
 | |
|   }
 | |
| 
 | |
|   private onPasswordRetypeInput(event: any) {
 | |
|     const currentPasswordRetypeEntered = event.target.value;
 | |
|     this.setState({ currentPasswordRetypeEntered });
 | |
|   }
 | |
| }
 |