From 2c83d41ccd1634703ee0e4a2d80b1b0f0cc17c30 Mon Sep 17 00:00:00 2001 From: William Grant Date: Thu, 14 Mar 2024 16:25:07 +1100 Subject: [PATCH] fix: error handling now works correctly for all stages buttons also correctly disable themselves --- .../registration/RegistrationStages.tsx | 115 ++----------- .../registration/stages/CreateAccount.tsx | 54 +++--- .../registration/stages/RestoreAccount.tsx | 157 ++++++++++++++---- ts/components/registration/utils/index.tsx | 13 +- ts/session/crypto/mnemonic.ts | 13 +- ts/util/accountManager.ts | 8 +- 6 files changed, 187 insertions(+), 173 deletions(-) diff --git a/ts/components/registration/RegistrationStages.tsx b/ts/components/registration/RegistrationStages.tsx index 0a0f1ddb5..592c9404f 100644 --- a/ts/components/registration/RegistrationStages.tsx +++ b/ts/components/registration/RegistrationStages.tsx @@ -3,13 +3,10 @@ import { useDispatch } from 'react-redux'; import { useMount } from 'react-use'; import styled from 'styled-components'; import { Data } from '../../data/data'; -import { getSwarmPollingInstance } from '../../session/apis/snode_api'; -import { ONBOARDING_TIMES } from '../../session/constants'; import { getConversationController } from '../../session/conversations'; -import { InvalidWordsError, NotEnoughWordsError, mnDecode } from '../../session/crypto/mnemonic'; -import { PromiseUtils, StringUtils, ToastUtils } from '../../session/utils'; +import { mnDecode } from '../../session/crypto/mnemonic'; +import { StringUtils } from '../../session/utils'; import { fromHex } from '../../session/utils/String'; -import { NotFoundError } from '../../session/utils/errors'; import { Onboarding, setGeneratedRecoveryPhrase, @@ -19,27 +16,12 @@ import { useOnboardGeneratedRecoveryPhrase, useOnboardStep, } from '../../state/onboarding/selectors/registration'; -import { - generateMnemonic, - registerSingleDevice, - sessionGenerateKeyPair, - signInByLinkingDevice, -} from '../../util/accountManager'; -import { Storage, setSignInByLinking, setSignWithRecoveryPhrase } from '../../util/storage'; +import { generateMnemonic, sessionGenerateKeyPair } from '../../util/accountManager'; +import { Storage } from '../../util/storage'; import { Flex } from '../basic/Flex'; import { SpacerLG, SpacerSM } from '../basic/Text'; import { SessionIcon, SessionIconButton } from '../icon'; import { CreateAccount, RestoreAccount, Start } from './stages'; -import { displayNameIsValid } from './utils'; - -const StyledRegistrationContainer = styled(Flex)` - width: 348px; - - .session-button { - width: 100%; - margin: 0; - } -`; export async function resetRegistration() { await Data.removeAll(); @@ -49,91 +31,20 @@ export async function resetRegistration() { await getConversationController().load(); } -type SignInDetails = { - userRecoveryPhrase: string; +export type RecoverDetails = { + recoveryPassword: string; + errorCallback: (error: Error) => void; displayName?: string; - errorCallback?: (error: string) => void; }; -/** - * Sign in/restore from seed. - * Ask for a display name, as we will drop incoming ConfigurationMessages if any are saved on the swarm. - * We will handle a ConfigurationMessage - */ -export async function signInWithNewDisplayName(signInDetails: SignInDetails) { - const { displayName, userRecoveryPhrase } = signInDetails; - window.log.debug(`WIP: [signInWithNewDisplayName] starting sign in with new display name....`); - const trimName = displayName ? displayNameIsValid(displayName) : undefined; - if (!trimName) { - return; - } - - try { - await resetRegistration(); - await registerSingleDevice(userRecoveryPhrase, 'english', trimName); - await setSignWithRecoveryPhrase(true); - } catch (e) { - await resetRegistration(); - ToastUtils.pushToastError('registrationError', `Error: ${e.message || 'Something went wrong'}`); - window?.log?.warn('exception during registration:', e); - } -} - -/** - * This will try to sign in with the user recovery phrase. - * If no ConfigurationMessage is received within ONBOARDING_RECOVERY_TIMEOUT, the user will be asked to enter a display name. - */ -export async function signInAndFetchDisplayName(signInDetails: SignInDetails) { - const { userRecoveryPhrase, errorCallback } = signInDetails; - window.log.debug(`WIP: [signInAndFetchDisplayName] starting sign in....`); - - try { - throw new NotFoundError('Got a config message from network but without a displayName...'); - await resetRegistration(); - await signInByLinkingDevice(userRecoveryPhrase, 'english'); - let displayNameFromNetwork = ''; - await getSwarmPollingInstance().start(); +const StyledRegistrationContainer = styled(Flex)` + width: 348px; - await PromiseUtils.waitForTask(done => { - window.Whisper.events.on('configurationMessageReceived', async (displayName: string) => { - window.Whisper.events.off('configurationMessageReceived'); - await setSignInByLinking(false); - await setSignWithRecoveryPhrase(true); - done(displayName); - displayNameFromNetwork = displayName; - }); - }, ONBOARDING_TIMES.RECOVERY_TIMEOUT); - if (displayNameFromNetwork.length) { - // display name, avatars, groups and contacts should already be handled when this event was triggered. - window.log.debug( - `WIP: [signInAndFetchDisplayName] we got a displayName from network: "${displayNameFromNetwork}"` - ); - } else { - window.log.debug( - `WIP: [signInAndFetchDisplayName] Got a config message from network but without a displayName...` - ); - throw new NotFoundError('Got a config message from network but without a displayName...'); - } - // Do not set the lastProfileUpdateTimestamp. - // We expect to get a display name from a configuration message while we are loading messages of this user - return displayNameFromNetwork; - } catch (e) { - await resetRegistration(); - if (errorCallback) { - if (e instanceof NotEnoughWordsError) { - void errorCallback(window.i18n('recoveryPasswordErrorMessageShort')); - } else if (e instanceof InvalidWordsError) { - void errorCallback(window.i18n('recoveryPasswordErrorMessageIncorrect')); - } else { - void errorCallback(window.i18n('recoveryPasswordErrorMessageGeneric')); - } - } - window.log.debug( - `WIP: [signInAndFetchDisplayName] exception during registration: ${e.message || e}` - ); - return ''; + .session-button { + width: 100%; + margin: 0; } -} +`; export const RegistrationStages = () => { const generatedRecoveryPhrase = useOnboardGeneratedRecoveryPhrase(); diff --git a/ts/components/registration/stages/CreateAccount.tsx b/ts/components/registration/stages/CreateAccount.tsx index a14d4a56d..437ad5a53 100644 --- a/ts/components/registration/stages/CreateAccount.tsx +++ b/ts/components/registration/stages/CreateAccount.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; import { SettingsKey } from '../../../data/settings-key'; -import { ToastUtils } from '../../../session/utils'; import { trigger } from '../../../shims/events'; import { AccountCreation, @@ -18,37 +17,33 @@ import { Flex } from '../../basic/Flex'; import { SessionButton, SessionButtonColor } from '../../basic/SessionButton'; import { SpacerLG, SpacerSM } from '../../basic/Text'; import { SessionInput } from '../../inputs'; -import { resetRegistration } from '../RegistrationStages'; +import { RecoverDetails, resetRegistration } from '../RegistrationStages'; import { OnboardContainer, OnboardDescription, OnboardHeading } from '../components'; import { BackButtonWithininContainer } from '../components/BackButton'; import { displayNameIsValid, sanitizeDisplayNameOrToast } from '../utils'; -async function signUp(signUpDetails: { displayName: string; generatedRecoveryPhrase: string }) { - const { displayName, generatedRecoveryPhrase } = signUpDetails; - window?.log?.info('SIGNING UP'); - - const trimName = displayNameIsValid(displayName); - if (!trimName) { - return; - } +async function signUp(signUpDetails: RecoverDetails) { + const { displayName, recoveryPassword, errorCallback } = signUpDetails; + window.log.debug(`WIP: [signUp] starting sign up....`); try { + const trimName = displayNameIsValid(displayName); + await resetRegistration(); - await registerSingleDevice(generatedRecoveryPhrase, 'english', trimName); + await registerSingleDevice(recoveryPassword, 'english', trimName); await Storage.put(SettingsKey.hasSyncedInitialConfigurationItem, Date.now()); await setSignWithRecoveryPhrase(false); trigger('openInbox'); } catch (e) { await resetRegistration(); - - ToastUtils.pushToastError('registrationError', `Error: ${e.message || 'Something went wrong'}`); - window?.log?.warn('exception during registration:', e); + void errorCallback(e); + window.log.debug(`WIP: [signUp] exception during registration: ${e.message || e}`); } } export const CreateAccount = () => { const step = useOnboardAccountCreationStep(); - const generatedRecoveryPhrase = useOnboardGeneratedRecoveryPhrase(); + const recoveryPassword = useOnboardGeneratedRecoveryPhrase(); const hexGeneratedPubKey = useOnboardHexGeneratedPubKey(); const dispatch = useDispatch(); @@ -62,18 +57,24 @@ export const CreateAccount = () => { } }, [step, hexGeneratedPubKey]); - const displayNameOK = !!displayName && !displayNameError; - const signUpWithDetails = () => { - if (!displayNameOK) { - return; - } - - void signUp({ - displayName, - generatedRecoveryPhrase, - }); + const signUpWithDetails = async () => { + try { + await signUp({ + displayName, + recoveryPassword, + errorCallback: e => { + setDisplayNameError(e.message || String(e)); + throw e; + }, + }); - dispatch(setAccountCreationStep(AccountCreation.Done)); + dispatch(setAccountCreationStep(AccountCreation.Done)); + } catch (e) { + window.log.debug( + `WIP: [recoverAndFetchDisplayName] AccountRestoration.RecoveryPassword failed to fetch display name so we need to enter it manually. Error: ${e}` + ); + dispatch(setAccountCreationStep(AccountCreation.DisplayName)); + } }; return ( @@ -107,6 +108,7 @@ export const CreateAccount = () => { buttonColor={SessionButtonColor.White} onClick={signUpWithDetails} text={window.i18n('continue')} + disabled={!(!!displayName && !displayNameError)} /> diff --git a/ts/components/registration/stages/RestoreAccount.tsx b/ts/components/registration/stages/RestoreAccount.tsx index 059f0675a..3c0eddcb5 100644 --- a/ts/components/registration/stages/RestoreAccount.tsx +++ b/ts/components/registration/stages/RestoreAccount.tsx @@ -1,28 +1,108 @@ import { useState } from 'react'; import { useDispatch } from 'react-redux'; +import { getSwarmPollingInstance } from '../../../session/apis/snode_api'; +import { ONBOARDING_TIMES } from '../../../session/constants'; +import { InvalidWordsError, NotEnoughWordsError } from '../../../session/crypto/mnemonic'; +import { PromiseUtils } from '../../../session/utils'; import { NotFoundError } from '../../../session/utils/errors'; import { AccountRestoration, setAccountRestorationStep, } from '../../../state/onboarding/ducks/registration'; import { useOnboardAccountRestorationStep } from '../../../state/onboarding/selectors/registration'; +import { registerSingleDevice, signInByLinkingDevice } from '../../../util/accountManager'; +import { setSignInByLinking, setSignWithRecoveryPhrase } from '../../../util/storage'; import { Flex } from '../../basic/Flex'; import { SessionButton, SessionButtonColor } from '../../basic/SessionButton'; import { SpacerLG, SpacerSM } from '../../basic/Text'; import { SessionIcon } from '../../icon'; import { SessionInput } from '../../inputs'; import { SessionProgressBar } from '../../loading'; -import { signInAndFetchDisplayName, signInWithNewDisplayName } from '../RegistrationStages'; +import { RecoverDetails, resetRegistration } from '../RegistrationStages'; import { OnboardContainer, OnboardDescription, OnboardHeading } from '../components'; import { BackButtonWithininContainer } from '../components/BackButton'; import { useRecoveryProgressEffect } from '../hooks'; -import { sanitizeDisplayNameOrToast } from '../utils'; +import { displayNameIsValid, sanitizeDisplayNameOrToast } from '../utils'; + +/** + * Sign in/restore from seed. + * Ask for a display name, as we will drop incoming ConfigurationMessages if any are saved on the swarm. + * We will handle a ConfigurationMessage + */ +async function signInWithNewDisplayName(signInDetails: RecoverDetails) { + const { displayName, recoveryPassword, errorCallback } = signInDetails; + window.log.debug(`WIP: [signInWithNewDisplayName] starting sign in with new display name....`); + + try { + const trimName = displayNameIsValid(displayName); + + await resetRegistration(); + await registerSingleDevice(recoveryPassword, 'english', trimName); + await setSignWithRecoveryPhrase(true); + } catch (e) { + await resetRegistration(); + void errorCallback(e); + window.log.debug( + `WIP: [signInWithNewDisplayName] exception during registration: ${e.message || e}` + ); + } +} + +/** + * This will try to sign in with the user recovery phrase. + * If no ConfigurationMessage is received within ONBOARDING_RECOVERY_TIMEOUT, the user will be asked to enter a display name. + */ +async function signInAndFetchDisplayName( + signInDetails: RecoverDetails & { + /** this is used to trigger the loading animation further down the registration pipeline */ + loadingAnimationCallback: () => void; + } +) { + const { recoveryPassword, errorCallback, loadingAnimationCallback } = signInDetails; + window.log.debug(`WIP: [signInAndFetchDisplayName] starting sign in....`); + + let displayNameFromNetwork = ''; + + try { + // throw new NotFoundError('Got a config message from network but without a displayName...'); + await resetRegistration(); + await signInByLinkingDevice(recoveryPassword, 'english', loadingAnimationCallback); + + await getSwarmPollingInstance().start(); + + await PromiseUtils.waitForTask(done => { + window.Whisper.events.on('configurationMessageReceived', async (displayName: string) => { + window.Whisper.events.off('configurationMessageReceived'); + await setSignInByLinking(false); + await setSignWithRecoveryPhrase(true); + done(displayName); + displayNameFromNetwork = displayName; + }); + }, ONBOARDING_TIMES.RECOVERY_TIMEOUT); + + if (!displayNameFromNetwork.length) { + throw new NotFoundError('Got a config message from network but without a displayName...'); + } + } catch (e) { + await resetRegistration(); + errorCallback(e); + } + // display name, avatars, groups and contacts should already be handled when this event was triggered. + window.log.debug( + `WIP: [signInAndFetchDisplayName] we got a displayName from network: "${displayNameFromNetwork}"` + ); + // Do not set the lastProfileUpdateTimestamp. + // We expect to get a display name from a configuration message while we are loading messages of this user + return displayNameFromNetwork; +} export const RestoreAccount = () => { const step = useOnboardAccountRestorationStep(); - const [recoveryPhrase, setRecoveryPhrase] = useState(''); - const [recoveryPhraseError, setRecoveryPhraseError] = useState(undefined as string | undefined); + const [recoveryPassword, setRecoveryPassword] = useState(''); + const [recoveryPasswordError, setRecoveryPasswordError] = useState( + undefined as string | undefined + ); const [displayName, setDisplayName] = useState(''); const [displayNameError, setDisplayNameError] = useState(''); @@ -35,37 +115,58 @@ export const RestoreAccount = () => { const recoverAndFetchDisplayName = async () => { setProgress(0); - dispatch(setAccountRestorationStep(AccountRestoration.Loading)); try { const displayNameFromNetwork = await signInAndFetchDisplayName({ - userRecoveryPhrase: recoveryPhrase, - errorCallback: setRecoveryPhraseError, + recoveryPassword, + errorCallback: e => { + throw e; + }, + loadingAnimationCallback: () => { + dispatch(setAccountRestorationStep(AccountRestoration.Loading)); + }, }); setDisplayName(displayNameFromNetwork); dispatch(setAccountRestorationStep(AccountRestoration.Finishing)); } catch (e) { if (e instanceof NotFoundError) { window.log.debug( - `WIP: [continueYourSession] AccountRestoration.DisplayName failed to fetch display name so we need to enter it manually. Error: ${e}` + `WIP: [recoverAndFetchDisplayName] AccountRestoration.RecoveryPassword failed to fetch display name so we need to enter it manually. Error: ${e}` ); dispatch(setAccountRestorationStep(AccountRestoration.DisplayName)); + return; + } + if (e instanceof NotEnoughWordsError) { + setRecoveryPasswordError(window.i18n('recoveryPasswordErrorMessageShort')); + } else if (e instanceof InvalidWordsError) { + setRecoveryPasswordError(window.i18n('recoveryPasswordErrorMessageIncorrect')); } else { - dispatch(setAccountRestorationStep(AccountRestoration.RecoveryPassword)); + setRecoveryPasswordError(window.i18n('recoveryPasswordErrorMessageGeneric')); } + window.log.debug( + `WIP: [recoverAndFetchDisplayName] exception during registration: ${e.message || e}` + ); + dispatch(setAccountRestorationStep(AccountRestoration.RecoveryPassword)); } }; const recoverAndEnterDisplayName = async () => { - if (!(!!displayName && !displayNameError)) { - return; + setProgress(0); + try { + await signInWithNewDisplayName({ + displayName, + recoveryPassword, + errorCallback: e => { + setDisplayNameError(e.message || String(e)); + throw e; + }, + }); + dispatch(setAccountRestorationStep(AccountRestoration.Complete)); + } catch (e) { + window.log.debug( + `WIP: [recoverAndEnterDisplayName] AccountRestoration.DisplayName failed to set the display name. Error: ${e}` + ); + dispatch(setAccountRestorationStep(AccountRestoration.DisplayName)); } - - await signInWithNewDisplayName({ - displayName, - userRecoveryPhrase: recoveryPhrase, - }); - - dispatch(setAccountRestorationStep(AccountRestoration.Complete)); }; return ( @@ -98,13 +199,15 @@ export const RestoreAccount = () => { autoFocus={true} type="password" placeholder={window.i18n('enterRecoveryPhrase')} - value={recoveryPhrase} + value={recoveryPassword} onValueChanged={(seed: string) => { - setRecoveryPhrase(seed); - setRecoveryPhraseError(!seed ? window.i18n('recoveryPhraseEmpty') : undefined); + setRecoveryPassword(seed); + setRecoveryPasswordError( + !seed ? window.i18n('recoveryPhraseEmpty') : undefined + ); }} onEnterPressed={recoverAndFetchDisplayName} - error={recoveryPhraseError} + error={recoveryPasswordError} enableShowHide={true} inputDataTestId="recovery-phrase-input" /> @@ -113,7 +216,7 @@ export const RestoreAccount = () => { buttonColor={SessionButtonColor.White} onClick={recoverAndFetchDisplayName} text={window.i18n('continue')} - disabled={!(!!recoveryPhrase && !recoveryPhraseError)} + disabled={!(!!recoveryPassword && !recoveryPasswordError)} dataTestId="continue-session-button" /> @@ -142,12 +245,8 @@ export const RestoreAccount = () => { text={window.i18n('continue')} disabled={ // TODO Fix that even if there is an error we only care if there is something in the input check Create Account - !( - !!recoveryPhrase && - !recoveryPhraseError && - !!displayName && - !displayNameError - ) + !(!!recoveryPassword && !recoveryPasswordError) || + !(!!displayName && !displayNameError) } dataTestId="continue-session-button" /> diff --git a/ts/components/registration/utils/index.tsx b/ts/components/registration/utils/index.tsx index 0bdeb2d20..fe6f84893 100644 --- a/ts/components/registration/utils/index.tsx +++ b/ts/components/registration/utils/index.tsx @@ -1,4 +1,3 @@ -import { ToastUtils } from '../../../session/utils'; import { sanitizeSessionUsername } from '../../../session/utils/String'; export function sanitizeDisplayNameOrToast( @@ -22,13 +21,15 @@ export function sanitizeDisplayNameOrToast( * * Be sure to use the trimmed userName for creating the account. */ -export const displayNameIsValid = (displayName: string): undefined | string => { - const trimName = displayName.trim(); +export const displayNameIsValid = (displayName?: string): string => { + if (!displayName) { + throw new Error(window.i18n('displayNameEmpty')); + } + const trimName = displayName.trim(); if (!trimName) { - window?.log?.warn('invalid trimmed name for registration'); - ToastUtils.pushToastError('invalidDisplayName', window.i18n('displayNameEmpty')); - return undefined; + throw new Error(window.i18n('displayNameEmpty')); } + return trimName; }; diff --git a/ts/session/crypto/mnemonic.ts b/ts/session/crypto/mnemonic.ts index 021cc7e40..dae8e3704 100644 --- a/ts/session/crypto/mnemonic.ts +++ b/ts/session/crypto/mnemonic.ts @@ -24,14 +24,6 @@ export class InvalidWordsError extends MnemonicError { } } -export class LastWordMissingError extends MnemonicError { - constructor() { - super('You seem to be missing the last word in your private key, please try again'); - // restore prototype chain - Object.setPrototypeOf(this, LastWordMissingError.prototype); - } -} - export class DecodingError extends MnemonicError { constructor() { super('Something went wrong when decoding your private key, please try again'); @@ -114,7 +106,10 @@ export function mnDecode(str: string, wordsetName: string = MN_DEFAULT_WORDSET): throw new NotEnoughWordsError(); } if (wordset.prefixLen > 0 && wlist.length % 3 === 0) { - throw new LastWordMissingError(); + window.log.error( + 'mnDecode(): You seem to be missing the last word in your private key, please try again' + ); + throw new NotEnoughWordsError(); } if (wordset.prefixLen > 0) { // Pop checksum from mnemonic diff --git a/ts/util/accountManager.ts b/ts/util/accountManager.ts index d61be9a72..4870cc9e2 100644 --- a/ts/util/accountManager.ts +++ b/ts/util/accountManager.ts @@ -77,8 +77,13 @@ export async function signInWithRecovery( * Sign in with a recovery phrase and try to recover display name and avatar from the first encountered configuration message. * @param mnemonic the mnemonic the user duly saved in a safe place. We will restore his sessionID based on this. * @param mnemonicLanguage 'english' only is supported + * @param loadingAnimationCallback a callback to trigger a loading animation */ -export async function signInByLinkingDevice(mnemonic: string, mnemonicLanguage: string) { +export async function signInByLinkingDevice( + mnemonic: string, + mnemonicLanguage: string, + loadingAnimationCallback: () => void +) { if (!mnemonic) { throw new Error('Session always needs a mnemonic. Either generated or given by the user'); } @@ -88,6 +93,7 @@ export async function signInByLinkingDevice(mnemonic: string, mnemonicLanguage: const identityKeyPair = await generateKeypair(mnemonic, mnemonicLanguage); await setSignInByLinking(true); + loadingAnimationCallback(); await createAccount(identityKeyPair); await saveRecoveryPhrase(mnemonic); const pubKeyString = toHex(identityKeyPair.pubKey);