From d346f28942935e6da45ed8cd089a33b893ce3636 Mon Sep 17 00:00:00 2001 From: William Grant Date: Tue, 7 May 2024 16:42:57 +1000 Subject: [PATCH] feat: extracted password to new modal added shiny hook to password protect anywhere --- _locales/en/messages.json | 1 + ts/components/dialog/EnterPasswordModal.tsx | 106 +++++++++++++ ts/components/dialog/ModalContainer.tsx | 8 +- ts/components/dialog/SessionSeedModal.tsx | 141 +++--------------- ...ialog.tsx => SessionSetPasswordDialog.tsx} | 2 +- ts/hooks/usePasswordModal.ts | 54 +++++++ ts/state/ducks/modalDialog.tsx | 8 + ts/state/selectors/modal.ts | 6 + ts/types/LocalizerKeys.ts | 1 + 9 files changed, 206 insertions(+), 121 deletions(-) create mode 100644 ts/components/dialog/EnterPasswordModal.tsx rename ts/components/dialog/{SessionPasswordDialog.tsx => SessionSetPasswordDialog.tsx} (99%) create mode 100644 ts/hooks/usePasswordModal.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 039bcc770..e8a659dc8 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -420,6 +420,7 @@ "readReceiptSettingDescription": "Send read receipts in one-to-one chats.", "readReceiptSettingTitle": "Read Receipts", "received": "Received", + "recoveryPasswordDescription": "Use your recovery password to load your account on new devices.
Your account cannot be recovered without your recovery password. Make sure it's stored somewhere safe and secure — and don't share it with anyone.", "recoveryPasswordEnter": "Enter your recovery password", "recoveryPasswordErrorMessageGeneric": "Please check your recovery password and try again.", "recoveryPasswordErrorMessageIncorrect": "Some of the words in your Recovery Password are incorrect. Please check and try again.", diff --git a/ts/components/dialog/EnterPasswordModal.tsx b/ts/components/dialog/EnterPasswordModal.tsx new file mode 100644 index 000000000..0eaabd6f9 --- /dev/null +++ b/ts/components/dialog/EnterPasswordModal.tsx @@ -0,0 +1,106 @@ +import { Dispatch, SetStateAction } from 'react'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; + +import { ToastUtils } from '../../session/utils'; +import { matchesHash } from '../../util/passwordUtils'; + +import { updateEnterPasswordModal } from '../../state/ducks/modalDialog'; +import { SpacerSM } from '../basic/Text'; + +import { SessionWrapperModal } from '../SessionWrapperModal'; +import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton'; + +const StyledModalContainer = styled.div` + margin: var(--margins-md) var(--margins-sm); +`; + +export type EnterPasswordModalProps = { + passwordHash: string; + passwordValid: boolean; + setPasswordValid: Dispatch>; + onClickOk: () => any; + onClickClose: () => any; + title?: string; +}; + +export const EnterPasswordModal = (props: EnterPasswordModalProps) => { + const { passwordHash, setPasswordValid, onClickOk, onClickClose, title } = props; + const dispatch = useDispatch(); + + const onClose = () => { + onClickClose(); + dispatch(updateEnterPasswordModal(null)); + }; + + const confirmPassword = () => { + const passwordValue = (document.getElementById('seed-input-password') as any)?.value; + const isPasswordValid = matchesHash(passwordValue as string, passwordHash); + + if (!passwordValue) { + ToastUtils.pushToastError('enterPasswordErrorToast', window.i18n('noGivenPassword')); + + return; + } + + if (passwordHash && !isPasswordValid) { + ToastUtils.pushToastError('enterPasswordErrorToast', window.i18n('invalidPassword')); + return; + } + + setPasswordValid(true); + + window.removeEventListener('keyup', onEnter); + + void onClickOk(); + }; + + const onEnter = (event: any) => { + if (event.key === 'Enter') { + confirmPassword(); + } + }; + + return ( + + + + +
+ +
+ + + +
+ + +
+
+
+ ); +}; diff --git a/ts/components/dialog/ModalContainer.tsx b/ts/components/dialog/ModalContainer.tsx index 7092ffd90..8d61f51fa 100644 --- a/ts/components/dialog/ModalContainer.tsx +++ b/ts/components/dialog/ModalContainer.tsx @@ -7,6 +7,7 @@ import { getDeleteAccountModalState, getEditProfileDialog, getEditProfilePictureModalState, + getEnterPasswordModalState, getInviteContactModal, getOnionPathDialog, getReactClearAllDialog, @@ -22,6 +23,7 @@ import { BanOrUnBanUserDialog } from './BanOrUnbanUserDialog'; import { DeleteAccountModal } from './DeleteAccountModal'; import { EditProfileDialog } from './EditProfileDialog'; import { EditProfilePictureModal } from './EditProfilePictureModal'; +import { EnterPasswordModal } from './EnterPasswordModal'; import { InviteContactsDialog } from './InviteContactsDialog'; import { AddModeratorsDialog } from './ModeratorsAddDialog'; import { RemoveModeratorsDialog } from './ModeratorsRemoveDialog'; @@ -30,8 +32,8 @@ import { ReactClearAllModal } from './ReactClearAllModal'; import { ReactListModal } from './ReactListModal'; import { SessionConfirm } from './SessionConfirm'; import { SessionNicknameDialog } from './SessionNicknameDialog'; -import { SessionPasswordDialog } from './SessionPasswordDialog'; import { SessionSeedModal } from './SessionSeedModal'; +import { SessionSetPasswordDialog } from './SessionSetPasswordDialog'; import { UpdateGroupMembersDialog } from './UpdateGroupMembersDialog'; import { UpdateGroupNameDialog } from './UpdateGroupNameDialog'; import { UserDetailsDialog } from './UserDetailsDialog'; @@ -48,6 +50,7 @@ export const ModalContainer = () => { const editProfileModalState = useSelector(getEditProfileDialog); const onionPathModalState = useSelector(getOnionPathDialog); const recoveryPhraseModalState = useSelector(getRecoveryPhraseDialog); + const enterPasswordModalState = useSelector(getEnterPasswordModalState); const sessionPasswordModalState = useSelector(getSessionPasswordDialog); const deleteAccountModalState = useSelector(getDeleteAccountModalState); const banOrUnbanUserModalState = useSelector(getBanOrUnbanUserModalState); @@ -70,7 +73,8 @@ export const ModalContainer = () => { {editProfileModalState && } {onionPathModalState && } {recoveryPhraseModalState && } - {sessionPasswordModalState && } + {enterPasswordModalState && } + {sessionPasswordModalState && } {deleteAccountModalState && } {confirmModalState && } {reactListModalState && } diff --git a/ts/components/dialog/SessionSeedModal.tsx b/ts/components/dialog/SessionSeedModal.tsx index 3def16ac3..91ceaff1d 100644 --- a/ts/components/dialog/SessionSeedModal.tsx +++ b/ts/components/dialog/SessionSeedModal.tsx @@ -1,97 +1,20 @@ import { useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import useMount from 'react-use/lib/useMount'; import styled from 'styled-components'; -import { Data } from '../../data/data'; import { ToastUtils } from '../../session/utils'; -import { matchesHash } from '../../util/passwordUtils'; import { mnDecode } from '../../session/crypto/mnemonic'; import { recoveryPhraseModal } from '../../state/ducks/modalDialog'; import { SpacerSM } from '../basic/Text'; +import { usePasswordModal } from '../../hooks/usePasswordModal'; import { getTheme } from '../../state/selectors/theme'; import { getThemeValue } from '../../themes/globals'; import { getCurrentRecoveryPhrase } from '../../util/storage'; import { SessionQRCode } from '../SessionQRCode'; import { SessionWrapperModal } from '../SessionWrapperModal'; -import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton'; - -interface PasswordProps { - setPasswordValid: (val: boolean) => any; - passwordHash: string; -} - -const Password = (props: PasswordProps) => { - const { setPasswordValid, passwordHash } = props; - const i18n = window.i18n; - const dispatch = useDispatch(); - - const onClose = () => dispatch(recoveryPhraseModal(null)); - - const confirmPassword = () => { - const passwordValue = (document.getElementById('seed-input-password') as any)?.value; - const isPasswordValid = matchesHash(passwordValue as string, passwordHash); - - if (!passwordValue) { - ToastUtils.pushToastError('enterPasswordErrorToast', i18n('noGivenPassword')); - - return false; - } - - if (passwordHash && !isPasswordValid) { - ToastUtils.pushToastError('enterPasswordErrorToast', i18n('invalidPassword')); - return false; - } - - setPasswordValid(true); - - window.removeEventListener('keyup', onEnter); - return true; - }; - - const onEnter = (event: any) => { - if (event.key === 'Enter') { - confirmPassword(); - } - }; - - return ( - <> -
- -
- - - -
- - -
- - ); -}; +import { SessionButton, SessionButtonType } from '../basic/SessionButton'; interface SeedProps { recoveryPhrase: string; @@ -102,7 +25,6 @@ const StyledRecoveryPhrase = styled.i``; const Seed = (props: SeedProps) => { const { recoveryPhrase, onClickCopy } = props; - const i18n = window.i18n; const dispatch = useDispatch(); const theme = useSelector(getTheme); @@ -129,7 +51,7 @@ const Seed = (props: SeedProps) => { maxWidth: '600px', }} > - {i18n('recoveryPhraseSavePromptMain')} + {window.i18n('recoveryPhraseSavePromptMain')}

{ style={{ justifyContent: 'center', width: '100%' }} > { copyRecoveryPhrase(recoveryPhrase); @@ -184,52 +106,35 @@ const SessionSeedModalInner = (props: ModalInnerProps) => { const { onClickOk } = props; const [loadingSeed, setLoadingSeed] = useState(true); const [recoveryPhrase, setRecoveryPhrase] = useState(''); - const [hasPassword, setHasPassword] = useState(null); - const [passwordValid, setPasswordValid] = useState(false); - const [passwordHash, setPasswordHash] = useState(''); const dispatch = useDispatch(); - useMount(() => { - async function validateAccess() { - if (passwordHash || recoveryPhrase) { - return; - } - - const hash = await Data.getPasswordHash(); - setHasPassword(!!hash); - setPasswordHash(hash || ''); + const onClose = () => dispatch(recoveryPhraseModal(null)); + usePasswordModal({ + onSuccess: () => { const newRecoveryPhrase = getCurrentRecoveryPhrase(); setRecoveryPhrase(newRecoveryPhrase); setLoadingSeed(false); - } - - setTimeout(() => (document.getElementById('seed-input-password') as any)?.focus(), 100); - void validateAccess(); + }, + onClose, + title: window.window.i18n('sessionRecoveryPassword'), }); - const onClose = () => dispatch(recoveryPhraseModal(null)); + if (loadingSeed) { + return null; + } return ( - <> - {!loadingSeed && ( - - - - - {hasPassword && !passwordValid ? ( - - ) : ( - - )} - - - )} - + + + + + + ); }; diff --git a/ts/components/dialog/SessionPasswordDialog.tsx b/ts/components/dialog/SessionSetPasswordDialog.tsx similarity index 99% rename from ts/components/dialog/SessionPasswordDialog.tsx rename to ts/components/dialog/SessionSetPasswordDialog.tsx index 1f492e48d..f9d88c551 100644 --- a/ts/components/dialog/SessionPasswordDialog.tsx +++ b/ts/components/dialog/SessionSetPasswordDialog.tsx @@ -25,7 +25,7 @@ interface State { currentPasswordRetypeEntered: string | null; } -export class SessionPasswordDialog extends Component { +export class SessionSetPasswordDialog extends Component { private passportInput: HTMLInputElement | null = null; constructor(props: any) { diff --git a/ts/hooks/usePasswordModal.ts b/ts/hooks/usePasswordModal.ts new file mode 100644 index 000000000..b4f4c3087 --- /dev/null +++ b/ts/hooks/usePasswordModal.ts @@ -0,0 +1,54 @@ +import { isEmpty } from 'lodash'; +import { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useMount } from 'react-use'; +import { Data } from '../data/data'; +import { updateEnterPasswordModal } from '../state/ducks/modalDialog'; + +export function usePasswordModal({ + onSuccess, + onClose, + title, +}: { + onSuccess: () => void; + onClose: () => void; + title?: string; +}) { + const [passwordHash, setPasswordHash] = useState(''); + const [passwordValid, setPasswordValid] = useState(false); + + const dispatch = useDispatch(); + + const validateAccess = async () => { + if (!isEmpty(passwordHash)) { + return; + } + + const hash = await Data.getPasswordHash(); + if (hash && !isEmpty(hash)) { + setPasswordHash(hash); + dispatch( + updateEnterPasswordModal({ + passwordHash, + passwordValid, + setPasswordValid, + onClickOk: () => { + onSuccess(); + setPasswordHash(''); + dispatch(updateEnterPasswordModal(null)); + }, + onClickClose: () => { + onClose(); + setPasswordHash(''); + dispatch(updateEnterPasswordModal(null)); + }, + title, + }) + ); + } + }; + + useMount(() => { + void validateAccess(); + }); +} diff --git a/ts/state/ducks/modalDialog.tsx b/ts/state/ducks/modalDialog.tsx index 715c8c5c0..d72cbe257 100644 --- a/ts/state/ducks/modalDialog.tsx +++ b/ts/state/ducks/modalDialog.tsx @@ -1,4 +1,5 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { EnterPasswordModalProps } from '../../components/dialog/EnterPasswordModal'; import { SessionConfirmDialogProps } from '../../components/dialog/SessionConfirm'; import type { EditProfilePictureModalProps, PasswordAction } from '../../types/ReduxTypes'; @@ -19,6 +20,7 @@ export type ChangeNickNameModalState = InviteContactModalState; export type EditProfileModalState = object | null; export type OnionPathModalState = EditProfileModalState; export type RecoveryPhraseModalState = EditProfileModalState; +export type EnterPasswordModalState = EnterPasswordModalProps | null; export type DeleteAccountModalState = EditProfileModalState; export type SessionPasswordModalState = { passwordAction: PasswordAction; onOk: () => void } | null; @@ -49,6 +51,7 @@ export type ModalState = { editProfileModal: EditProfileModalState; onionPathModal: OnionPathModalState; recoveryPhraseModal: RecoveryPhraseModalState; + enterPasswordModal: EnterPasswordModalState; sessionPasswordModal: SessionPasswordModalState; deleteAccountModal: DeleteAccountModalState; reactListModalState: ReactModalsState; @@ -69,6 +72,7 @@ export const initialModalState: ModalState = { editProfileModal: null, onionPathModal: null, recoveryPhraseModal: null, + enterPasswordModal: null, sessionPasswordModal: null, deleteAccountModal: null, reactListModalState: null, @@ -116,6 +120,9 @@ const ModalSlice = createSlice({ recoveryPhraseModal(state, action: PayloadAction) { return { ...state, recoveryPhraseModal: action.payload }; }, + updateEnterPasswordModal(state, action: PayloadAction) { + return { ...state, enterPasswordModal: action.payload }; + }, sessionPassword(state, action: PayloadAction) { return { ...state, sessionPasswordModal: action.payload }; }, @@ -147,6 +154,7 @@ export const { editProfileModal, onionPathModal, recoveryPhraseModal, + updateEnterPasswordModal, sessionPassword, updateDeleteAccountModal, updateBanOrUnbanUserModal, diff --git a/ts/state/selectors/modal.ts b/ts/state/selectors/modal.ts index beaa1ce41..d682048fb 100644 --- a/ts/state/selectors/modal.ts +++ b/ts/state/selectors/modal.ts @@ -8,6 +8,7 @@ import { DeleteAccountModalState, EditProfileModalState, EditProfilePictureModalState, + EnterPasswordModalState, InviteContactModalState, ModalState, OnionPathModalState, @@ -85,6 +86,11 @@ export const getRecoveryPhraseDialog = createSelector( (state: ModalState): RecoveryPhraseModalState => state.recoveryPhraseModal ); +export const getEnterPasswordModalState = createSelector( + getModal, + (state: ModalState): EnterPasswordModalState => state.enterPasswordModal +); + export const getSessionPasswordDialog = createSelector( getModal, (state: ModalState): SessionPasswordModalState => state.sessionPasswordModal diff --git a/ts/types/LocalizerKeys.ts b/ts/types/LocalizerKeys.ts index 4f4d2ff8e..8b04bd121 100644 --- a/ts/types/LocalizerKeys.ts +++ b/ts/types/LocalizerKeys.ts @@ -420,6 +420,7 @@ export type LocalizerKeys = | 'readReceiptSettingDescription' | 'readReceiptSettingTitle' | 'received' + | 'recoveryPasswordDescription' | 'recoveryPasswordEnter' | 'recoveryPasswordErrorMessageGeneric' | 'recoveryPasswordErrorMessageIncorrect'