From eb86a36b914478657234f08501537f10f0f93e13 Mon Sep 17 00:00:00 2001 From: fabriziofff Date: Wed, 13 May 2020 09:36:49 +0200 Subject: [PATCH] [#172076080] Added delay between incorrect pin insertion attempts (#1711) * change to Identification state to support fail data * serialize/deserialize Date from persistent store * lint fix * draft force Logout * draft IdentificationLockModal * display remaining attempts * link forcelogout with identification saga * add localization * styling for IdentificationLockModal * restore deltaTimespanBetweenAttempts = 30 * don't increase failCounter when biometric fail * fix unused handler * change countdown rendering and representation as Option * revert to optional ? props * identificationfailselector * update locales * fromnullable for dateisotransformer and seconds for identification reducer * fix setInterval method always executed * display the remaining attempts only if start to lock the application for too many attempts * fix lint * renaming variable canInsertPinBiometry to biometryAuthNotAvailable * revert logic and name of biometryAuthAvailable * replace map.getorelse with fold * fix compile error * don't use BiometryAuth if the app is in timeout for too many attempts * add comment Co-authored-by: Matteo Boschi --- locales/en/index.yml | 6 + locales/it/index.yml | 6 + ts/IdentificationModal.tsx | 194 +++++++++++++++---- ts/components/Pinpad/index.tsx | 24 +++ ts/sagas/identification.ts | 18 +- ts/screens/modal/IdentificationLockModal.tsx | 75 +++++++ ts/store/actions/identification.ts | 4 + ts/store/reducers/identification.ts | 79 +++++++- ts/store/reducers/index.ts | 17 +- ts/store/reducers/types.ts | 4 +- ts/store/transforms/dateISO8601Tranform.ts | 17 +- 11 files changed, 390 insertions(+), 54 deletions(-) create mode 100644 ts/screens/modal/IdentificationLockModal.tsx diff --git a/locales/en/index.yml b/locales/en/index.yml index f40b59c5303..2c1912deb70 100644 --- a/locales/en/index.yml +++ b/locales/en/index.yml @@ -1002,6 +1002,12 @@ identification: title: Identification required sensorDescription: Touch sensor fallbackLabel: Use the unlock code + fail: + wrongCode: Wrong code + remainingAttempts: You have {{attempts}} attempts left. + remainingAttemptSingle: You have {{attempts}} attempt left. + tooManyAttempts: Too many attempts. + waitMessage: "Try again in:" calendarEvents: calendarSelect: In which calendar you want to add the event? removeRequest: diff --git a/locales/it/index.yml b/locales/it/index.yml index a8129f0ddc6..f2be461a8e3 100644 --- a/locales/it/index.yml +++ b/locales/it/index.yml @@ -1038,6 +1038,12 @@ identification: title: Identificazione richiesta sensorDescription: Tocca il sensore fallbackLabel: Usa il codice di sblocco + fail: + wrongCode: Codice errato + remainingAttempts: Ti rimangono {{attempts}} tentativi. + remainingAttemptSingle: Ti rimane {{attempts}} tentativo. + tooManyAttempts: Troppi tentativi di inserimento errati. + waitMessage: "Riprova tra:" calendarEvents: calendarSelect: In quale calendario vuoi aggiungere l'evento? removeRequest: diff --git a/ts/IdentificationModal.tsx b/ts/IdentificationModal.tsx index 9d59df96827..b1ce878ae1a 100644 --- a/ts/IdentificationModal.tsx +++ b/ts/IdentificationModal.tsx @@ -1,3 +1,4 @@ +import { Millisecond } from "italia-ts-commons/lib/units"; import { Content, Text, View } from "native-base"; import * as React from "react"; import { Alert, Modal, StatusBar, StyleSheet } from "react-native"; @@ -16,14 +17,22 @@ import { BiometryPrintableSimpleType } from "./screens/onboarding/FingerprintScr import { identificationCancel, identificationFailure, + identificationForceLogout, identificationPinReset, identificationSuccess } from "./store/actions/identification"; import { ReduxProps } from "./store/actions/types"; +import { + freeAttempts, + identificationFailSelector, + maxAttempts +} from "./store/reducers/identification"; import { GlobalState } from "./store/reducers/types"; import variables from "./theme/variables"; import { authenticateConfig } from "./utils/biometric"; +import { IdentificationLockModal } from "./screens/modal/IdentificationLockModal"; + type Props = ReturnType & ReduxProps; /** @@ -40,7 +49,9 @@ type State = { identificationByPinState: IdentificationByPinState; identificationByBiometryState: IdentificationByBiometryState; biometryType?: BiometryPrintableSimpleType; - canInsertPin: boolean; + biometryAuthAvailable: boolean; + canInsertPinTooManyAttempts: boolean; + countdown?: Millisecond; }; const contextualHelpMarkdown: ContextualHelpPropsMarkdown = { @@ -48,6 +59,11 @@ const contextualHelpMarkdown: ContextualHelpPropsMarkdown = { body: "onboarding.pin.contextualHelpContent" }; +const checkPinInterval = 100 as Millisecond; + +// the threshold of attempts after which it is necessary to activate the timer check +const checkTimerThreshold = maxAttempts - freeAttempts; + const renderIdentificationByPinState = ( identificationByPinState: IdentificationByPinState ) => { @@ -111,10 +127,47 @@ class IdentificationModal extends React.PureComponent { this.state = { identificationByPinState: "unstarted", identificationByBiometryState: "unstarted", - canInsertPin: false + biometryAuthAvailable: true, + canInsertPinTooManyAttempts: this.props.identificationFailState.isNone() }; } + private idUpdateCanInsertPinTooManyAttempts?: number; + + /** + * Update the state using the actual props value of the `identificationFailState` + * return the updated value of `canInsertPinTooManyAttempts` in order to be used without waiting the state update + */ + private updateCanInsertPinTooManyAttempts = () => { + return this.props.identificationFailState.map(errorData => { + const now = new Date(); + const canInsertPinTooManyAttempts = errorData.nextLegalAttempt <= now; + this.setState({ + canInsertPinTooManyAttempts, + countdown: (errorData.nextLegalAttempt.getTime() - + now.getTime()) as Millisecond + }); + return canInsertPinTooManyAttempts; + }); + }; + + /** + * Activate the interval check on the pin state if the condition is satisfied + * @param remainingAttempts + */ + private scheduleCanInsertPinUpdate = () => { + this.props.identificationFailState.map(failState => { + if (failState.remainingAttempts < checkTimerThreshold) { + this.updateCanInsertPinTooManyAttempts(); + // tslint:disable-next-line: no-object-mutation + this.idUpdateCanInsertPinTooManyAttempts = setInterval( + this.updateCanInsertPinTooManyAttempts, + checkPinInterval + ); + } + }); + }; + public componentDidMount() { const { isFingerprintEnabled } = this.props; if (isFingerprintEnabled) { @@ -130,8 +183,19 @@ class IdentificationModal extends React.PureComponent { ); } else { // if the biometric is not available unlock the unlock code insertion - this.setState({ canInsertPin: true }); + this.setState({ biometryAuthAvailable: false }); } + + // first time the component is mounted, need to calculate the state value for `canInsertPinTooManyAttempts` + // and schedule the update if needed + this.updateCanInsertPinTooManyAttempts().map(_ => + this.scheduleCanInsertPinUpdate() + ); + } + + // atm this method is never called because the component won't be never unmount + public componentWillUnmount() { + clearInterval(this.idUpdateCanInsertPinTooManyAttempts); } /** @@ -151,9 +215,9 @@ class IdentificationModal extends React.PureComponent { updateBiometrySupportProp: boolean; }) { // check if the state of identification process is correct - const { identificationState, isFingerprintEnabled } = this.props; + const { identificationProgressState, isFingerprintEnabled } = this.props; - if (identificationState.kind !== "started") { + if (identificationProgressState.kind !== "started") { return; } @@ -169,9 +233,9 @@ class IdentificationModal extends React.PureComponent { biometryType !== "UNAVAILABLE" ? biometryType : undefined, - canInsertPin: - biometryType === "NOT_ENROLLED" || - biometryType === "UNAVAILABLE" + biometryAuthAvailable: + biometryType !== "NOT_ENROLLED" && + biometryType !== "UNAVAILABLE" }); } }, @@ -180,10 +244,7 @@ class IdentificationModal extends React.PureComponent { .then( () => { if (this.state.biometryType) { - this.onFingerprintRequest( - this.onIdentificationSuccessHandler, - this.onIdentificationFailureHandler - ); + this.onFingerprintRequest(this.onIdentificationSuccessHandler); } }, _ => undefined @@ -191,31 +252,67 @@ class IdentificationModal extends React.PureComponent { } } - public componentDidUpdate(prevProps: Props) { + public componentDidUpdate(prevProps: Props, prevState: State) { // When app becomes active from background the state of TouchID support // must be updated, because it might be switched off. + // Don't do this check if I can't authenticate for too many attempts (canInsertPinTooManyAttempts === false) if ( - (prevProps.appState === "background" && + this.state.canInsertPinTooManyAttempts && + ((prevProps.appState === "background" && this.props.appState === "active") || - (prevProps.identificationState.kind !== "started" && - this.props.identificationState.kind === "started") + (prevProps.identificationProgressState.kind !== "started" && + this.props.identificationProgressState.kind === "started")) ) { this.maybeTriggerFingerprintRequest({ updateBiometrySupportProp: prevProps.appState !== "active" && this.props.appState === "active" }); } + + const previousAttempts = prevProps.identificationFailState.fold( + Number.MAX_VALUE, + x => x.remainingAttempts + ); + + const currentAttempts = this.props.identificationFailState.fold( + Number.MAX_VALUE, + x => x.remainingAttempts + ); + + // trigger an update in the management of the updateInterval if the attempts or the state + // `canInsertPinTooManyAttempts` is changed + if ( + previousAttempts !== currentAttempts || + prevState.canInsertPinTooManyAttempts !== + this.state.canInsertPinTooManyAttempts + ) { + // trigger a state update based on the current props and use the results to choose what to do + // with the scheduled interval + const caninsertPin = this.updateCanInsertPinTooManyAttempts().getOrElse( + true + ); + // if the pin can be inserted, the timer is no longer needed + if (caninsertPin) { + clearInterval(this.idUpdateCanInsertPinTooManyAttempts); + // tslint:disable-next-line: no-object-mutation + this.idUpdateCanInsertPinTooManyAttempts = undefined; + + // if the pin can't be inserted and is not scheduled an interval, schedule an update + } else if (this.idUpdateCanInsertPinTooManyAttempts === undefined) { + this.scheduleCanInsertPinUpdate(); + } + } } private onIdentificationSuccessHandler = () => { - const { identificationState, dispatch } = this.props; + const { identificationProgressState, dispatch } = this.props; - if (identificationState.kind !== "started") { + if (identificationProgressState.kind !== "started") { return; } // The identification state is started we need to show the modal - const { identificationSuccessData } = identificationState; + const { identificationSuccessData } = identificationProgressState; if (identificationSuccessData) { identificationSuccessData.onSuccess(); @@ -224,14 +321,26 @@ class IdentificationModal extends React.PureComponent { }; private onIdentificationFailureHandler = () => { - const { dispatch } = this.props; - dispatch(identificationFailure()); + const { dispatch, identificationFailState } = this.props; + + const forceLogout = identificationFailState + .map(failState => failState.remainingAttempts === 1) + .getOrElse(false); + if (forceLogout) { + dispatch(identificationForceLogout()); + } else { + dispatch(identificationFailure()); + } }; public render() { - const { identificationState, isFingerprintEnabled, dispatch } = this.props; + const { + identificationProgressState, + isFingerprintEnabled, + dispatch + } = this.props; - if (identificationState.kind !== "started") { + if (identificationProgressState.kind !== "started") { return null; } @@ -242,18 +351,32 @@ class IdentificationModal extends React.PureComponent { identificationGenericData, identificationCancelData, shufflePad - } = identificationState; + } = identificationProgressState; const { identificationByPinState, identificationByBiometryState, - biometryType + biometryType, + countdown } = this.state; const identificationMessage = identificationGenericData ? identificationGenericData.message : this.renderBiometryType(); + const canInsertPin = + !this.state.biometryAuthAvailable && + this.state.canInsertPinTooManyAttempts; + + // display the remaining attempts number only if start to lock the application for too many attempts + const displayRemainingAttempts = this.props.identificationFailState.fold( + undefined, + failState => + failState.remainingAttempts <= maxAttempts - freeAttempts + ? failState.remainingAttempts + : undefined + ); + /** * Create handlers merging default internal actions (to manage the identification state) * with, if available, custom actions passed as props. @@ -269,7 +392,9 @@ class IdentificationModal extends React.PureComponent { dispatch(identificationPinReset()); }; - return ( + return !this.state.canInsertPinTooManyAttempts ? ( + IdentificationLockModal({ countdown }) + ) : ( { isFingerprintEnabled={isFingerprintEnabled} biometryType={biometryType} onFingerPrintReq={() => - this.onFingerprintRequest( - this.onIdentificationSuccessHandler, - this.onIdentificationFailureHandler - ) + this.onFingerprintRequest(this.onIdentificationSuccessHandler) } shufflePad={shufflePad} - disabled={!this.state.canInsertPin} + disabled={!canInsertPin} compareWithCode={pin as string} activeColor={"white"} inactiveColor={"white"} @@ -320,6 +442,7 @@ class IdentificationModal extends React.PureComponent { ? onIdentificationCancelHandler : undefined } + remainingAttempts={displayRemainingAttempts} /> {renderIdentificationByPinState(identificationByPinState)} {renderIdentificationByBiometryState(identificationByBiometryState)} @@ -369,8 +492,7 @@ class IdentificationModal extends React.PureComponent { }; private onFingerprintRequest = ( - onIdentificationSuccessHandler: () => void, - onIdentificationFailureHandler: () => void + onIdentificationSuccessHandler: () => void ) => { TouchID.authenticate( I18n.t("identification.biometric.popup.reason"), @@ -385,7 +507,7 @@ class IdentificationModal extends React.PureComponent { .catch((error: AuthenticationError) => { // some error occured, enable pin insertion this.setState({ - canInsertPin: true + biometryAuthAvailable: false }); if (isDebugBiometricIdentificationEnabled) { Alert.alert("identification.biometric.title", `KO: ${error.code}`); @@ -398,13 +520,13 @@ class IdentificationModal extends React.PureComponent { identificationByBiometryState: "failure" }); } - onIdentificationFailureHandler(); }); }; } const mapStateToProps = (state: GlobalState) => ({ - identificationState: state.identification, + identificationProgressState: state.identification.progress, + identificationFailState: identificationFailSelector(state), isFingerprintEnabled: state.persistedPreferences.isFingerprintEnabled, appState: state.appState.appState }); diff --git a/ts/components/Pinpad/index.tsx b/ts/components/Pinpad/index.tsx index eb665d520fc..2430eff1d15 100644 --- a/ts/components/Pinpad/index.tsx +++ b/ts/components/Pinpad/index.tsx @@ -4,6 +4,7 @@ import { Text, View } from "native-base"; import * as React from "react"; import { Alert, Dimensions, StyleSheet, ViewStyle } from "react-native"; +import { fromNullable } from "fp-ts/lib/Option"; import { debounce, shuffle } from "lodash"; import I18n from "../../i18n"; import { BiometryPrintableSimpleType } from "../../screens/onboarding/FingerprintScreen"; @@ -31,6 +32,7 @@ interface Props { onPinResetHandler?: () => void; onFingerPrintReq?: () => void; onDeleteLastDigit?: () => void; + remainingAttempts?: number; } interface State { @@ -271,14 +273,36 @@ class Pinpad extends React.PureComponent { this.setState({ value: "" }); }, 100); + private renderRemainingAttempts = (remainingAttempts: number) => { + const wrongCode = I18n.t("identification.fail.wrongCode"); + const remainingAttemptsString = I18n.t( + remainingAttempts > 1 + ? "identification.fail.remainingAttempts" + : "identification.fail.remainingAttemptSingle", + { attempts: remainingAttempts } + ); + + return ( + + {wrongCode}. {remainingAttemptsString} + + ); + }; + public render() { const placeholderPositions = range(0, this.state.pinLength - 1); + const remainingAttemptsMessage = fromNullable( + this.props.remainingAttempts + ).fold(null, x => this.renderRemainingAttempts(x)); + return ( {placeholderPositions.map(this.renderPlaceholder)} + {remainingAttemptsMessage} + {this.props.onPinResetHandler !== undefined && ( + | ActionType + | ActionType + | ActionType; // Wait the identification and return the result function* waitIdentificationResult(): Iterator { - const resultAction: - | ActionType - | ActionType - | ActionType = yield take([ + const resultAction: ResultAction = yield take([ getType(identificationCancel), getType(identificationPinReset), + getType(identificationForceLogout), getType(identificationSuccess) ]); @@ -60,6 +64,12 @@ function* waitIdentificationResult(): Iterator { return IdentificationResult.success; } + case getType(identificationForceLogout): { + yield put(sessionInvalid()); + yield put(identificationReset()); + return IdentificationResult.pinreset; + } + default: { ((): never => resultAction)(); } diff --git a/ts/screens/modal/IdentificationLockModal.tsx b/ts/screens/modal/IdentificationLockModal.tsx new file mode 100644 index 00000000000..109b71c8738 --- /dev/null +++ b/ts/screens/modal/IdentificationLockModal.tsx @@ -0,0 +1,75 @@ +import { format } from "date-fns"; +import { fromNullable } from "fp-ts/lib/Option"; +import { Millisecond } from "italia-ts-commons/lib/units"; +import { Text, View } from "native-base"; +import * as React from "react"; +import { Image, Modal, StyleSheet } from "react-native"; +import I18n from "../../i18n"; + +type Props = { + // milliseconds + countdown?: Millisecond; +}; + +const styles = StyleSheet.create({ + title: { + paddingVertical: 24, + fontSize: 24 + }, + text: { + fontSize: 18, + textAlign: "center" + }, + imageContainer: { + paddingTop: 96 + }, + spaced: { + flexDirection: "column", + alignItems: "center" + } +}); + +const errorIcon = require("../../../img/messages/error-message-detail-icon.png"); + +const wrongCodeText = I18n.t("identification.fail.wrongCode"); +const waitMessageText = I18n.t("identification.fail.waitMessage"); +const tooManyAttemptsText = I18n.t("identification.fail.tooManyAttempts"); + +// Convert milliseconds to a textual representation based on mm:ss + +const fromMillisecondsToTimeRepresentation = (ms: Millisecond): string => + format(new Date(ms), "mm:ss"); + +/* + This modal screen is displayed when too many wrong pin attempts have been made. + A countdown is displayed indicating how long it is to unlock the application. +*/ + +export const IdentificationLockModal: React.FunctionComponent< + Props +> = props => { + const minuteSeconds = fromNullable(props.countdown).fold("0:00", x => + fromMillisecondsToTimeRepresentation(x) + ); + + return ( + + + + + + + + {wrongCodeText} + + {tooManyAttemptsText} + + {waitMessageText} + + + {minuteSeconds} + + + + ); +}; diff --git a/ts/store/actions/identification.ts b/ts/store/actions/identification.ts index 50876207256..83e1f0ac0af 100644 --- a/ts/store/actions/identification.ts +++ b/ts/store/actions/identification.ts @@ -57,6 +57,9 @@ export const identificationSuccess = createAction("IDENTIFICATION_SUCCESS"); export const identificationFailure = createAction("IDENTIFICATION_FAILURE"); export const identificationPinReset = createAction("IDENTIFICATION_PIN_RESET"); export const identificationReset = createAction("IDENTIFICATION_RESET"); +export const identificationForceLogout = createAction( + "IDENTIFICATION_FORCE_LOGOUT" +); export type IdentificationActions = | ActionType @@ -65,4 +68,5 @@ export type IdentificationActions = | ActionType | ActionType | ActionType + | ActionType | ActionType; diff --git a/ts/store/reducers/identification.ts b/ts/store/reducers/identification.ts index 061237a9dbb..f8d6e7cf603 100644 --- a/ts/store/reducers/identification.ts +++ b/ts/store/reducers/identification.ts @@ -1,17 +1,27 @@ import { getType } from "typesafe-actions"; +import { fromNullable } from "fp-ts/lib/Option"; +import { PersistPartial } from "redux-persist"; import { PinString } from "../../types/PinString"; import { identificationCancel, + identificationFailure, identificationReset, identificationStart, identificationSuccess } from "../actions/identification"; import { Action } from "../actions/types"; +import { GlobalState } from "./types"; + +export const freeAttempts = 4; +const deltaTimespanBetweenAttempts = 30; + +export const maxAttempts = 8; export enum IdentificationResult { "cancel" = "cancel", "pinreset" = "pinreset", + "failure" = "failure", "success" = "success" } @@ -41,15 +51,47 @@ type IdentificationIdentifiedState = { kind: "identified"; }; -export type IdentificationState = +export type IdentificationProgressState = | IdentificationUnidentifiedState | IdentificationStartedState | IdentificationIdentifiedState; -const INITIAL_STATE: IdentificationUnidentifiedState = { +export type IdentificationFailData = { + remainingAttempts: number; + nextLegalAttempt: Date; + timespanBetweenAttempts: number; +}; + +export type IdentificationState = { + progress: IdentificationProgressState; + fail?: IdentificationFailData; +}; + +export type PersistedIdentificationState = IdentificationState & PersistPartial; + +const INITIAL_PROGRESS_STATE: IdentificationUnidentifiedState = { kind: "unidentified" }; +const INITIAL_STATE: IdentificationState = { + progress: INITIAL_PROGRESS_STATE, + fail: undefined +}; + +const nextErrorData = ( + errorData: IdentificationFailData +): IdentificationFailData => { + const newTimespan = + maxAttempts - errorData.remainingAttempts + 1 > freeAttempts + ? errorData.timespanBetweenAttempts + deltaTimespanBetweenAttempts + : 0; + return { + nextLegalAttempt: new Date(Date.now() + newTimespan * 1000), + remainingAttempts: errorData.remainingAttempts - 1, + timespanBetweenAttempts: newTimespan + }; +}; + const reducer = ( state: IdentificationState = INITIAL_STATE, action: Action @@ -57,26 +99,51 @@ const reducer = ( switch (action.type) { case getType(identificationStart): return { - kind: "started", - ...action.payload + ...state, + progress: { + kind: "started", + ...action.payload + } }; case getType(identificationCancel): return { - kind: "unidentified" + progress: { + kind: "unidentified" + }, + fail: state.fail }; case getType(identificationSuccess): return { - kind: "identified" + progress: { + kind: "identified" + } }; case getType(identificationReset): return INITIAL_STATE; + case getType(identificationFailure): + const newErrorData = fromNullable(state.fail).fold( + { + nextLegalAttempt: new Date(), + remainingAttempts: maxAttempts - 1, + timespanBetweenAttempts: 0 + }, + errorData => nextErrorData(errorData) + ); + return { + ...state, + fail: newErrorData + }; + default: return state; } }; +export const identificationFailSelector = (state: GlobalState) => + fromNullable(state.identification.fail); + export default reducer; diff --git a/ts/store/reducers/index.ts b/ts/store/reducers/index.ts index 34e4ff81a04..abe0531395f 100644 --- a/ts/store/reducers/index.ts +++ b/ts/store/reducers/index.ts @@ -10,6 +10,7 @@ import AsyncStorage from "@react-native-community/async-storage"; import { logoutFailure, logoutSuccess } from "../actions/authentication"; import { Action } from "../actions/types"; import createSecureStorage from "../storages/keychain"; +import { DateISO8601Transform } from "../transforms/dateISO8601Tranform"; import appStateReducer from "./appState"; import authenticationReducer, { AuthenticationState } from "./authentication"; import backendInfoReducer from "./backendInfo"; @@ -20,7 +21,7 @@ import { debugReducer } from "./debug"; import deepLinkReducer from "./deepLink"; import emailValidationReducer from "./emailValidation"; import entitiesReducer, { EntitiesState } from "./entities"; -import identificationReducer from "./identification"; +import identificationReducer, { IdentificationState } from "./identification"; import instabugUnreadMessagesReducer from "./instabug/instabugUnreadMessages"; import installationReducer from "./installation"; import navigationReducer from "./navigation"; @@ -51,6 +52,14 @@ export const entitiesPersistConfig: PersistConfig = { blacklist: ["messages"] }; +// A custom configuration to store the fail information of the identification section +export const identificationPersistConfig: PersistConfig = { + key: "identification", + storage: AsyncStorage, + blacklist: ["progress"], + transforms: [DateISO8601Transform] +}; + /** * Here we combine all the reducers. * We use the best practice of separating UI state from the DATA state. @@ -76,7 +85,6 @@ const appReducer: Reducer = combineReducers< backendInfo: backendInfoReducer, backendStatus: backendStatusReducer, preferences: preferencesReducer, - identification: identificationReducer, navigationHistory: navigationHistoryReducer, instabug: instabugUnreadMessagesReducer, search: searchReducer, @@ -93,6 +101,11 @@ const appReducer: Reducer = combineReducers< ), // standard persistor, see configureStoreAndPersistor.ts + + identification: persistReducer( + identificationPersistConfig, + identificationReducer + ), onboarding: onboardingReducer, notifications: notificationsReducer, profile: profileReducer, diff --git a/ts/store/reducers/types.ts b/ts/store/reducers/types.ts index 087fc3f01d7..f8e24aac1e8 100644 --- a/ts/store/reducers/types.ts +++ b/ts/store/reducers/types.ts @@ -12,7 +12,7 @@ import { DebugState } from "./debug"; import { DeepLinkState } from "./deepLink"; import { EmailValidationState } from "./emailValidation"; import { PersistedEntitiesState } from "./entities"; -import { IdentificationState } from "./identification"; +import { PersistedIdentificationState } from "./identification"; import { InstabugUnreadMessagesState } from "./instabug/instabugUnreadMessages"; import { InstallationState } from "./installation"; import { NavigationHistoryState } from "./navigationHistory"; @@ -51,7 +51,7 @@ export type GlobalState = Readonly<{ persistedPreferences: PersistedPreferencesState; content: ContentState; navigationHistory: NavigationHistoryState; - identification: IdentificationState; + identification: PersistedIdentificationState; installation: InstallationState; debug: DebugState; search: SearchState; diff --git a/ts/store/transforms/dateISO8601Tranform.ts b/ts/store/transforms/dateISO8601Tranform.ts index 45682ab0cb0..aa29a9c3d28 100644 --- a/ts/store/transforms/dateISO8601Tranform.ts +++ b/ts/store/transforms/dateISO8601Tranform.ts @@ -1,3 +1,4 @@ +import { fromNullable } from "fp-ts/lib/Option"; import { createTransform, TransformIn, TransformOut } from "redux-persist"; import { DateFromISOString } from "../../utils/dates"; @@ -18,7 +19,11 @@ import { DateFromISOString } from "../../utils/dates"; * * https://www.pivotaltracker.com/story/show/167507349 */ -const dateFieldsTransformable = new Set(["created_at", "due_date"]); +const dateFieldsTransformable = new Set([ + "created_at", + "due_date", + "nextLegalAttempt" +]); /** * if value is a Date object, a string in ISO8601 format is returned @@ -42,14 +47,18 @@ const dateReviver = (key: any, value: any): any => { }; const encoder: TransformIn = (value: any, _: string): any => - JSON.parse(JSON.stringify(value), dataReplacer); + fromNullable(value).fold(undefined, v => + JSON.parse(JSON.stringify(v), dataReplacer) + ); const decoder: TransformOut = (value: any, _: string): any => - JSON.parse(JSON.stringify(value), dateReviver); + fromNullable(value).fold(undefined, v => + JSON.parse(JSON.stringify(v), dateReviver) + ); /** * date tasformer will be applied only to entities (whitelist) */ export const DateISO8601Transform = createTransform(encoder, decoder, { - whitelist: ["entities"] + whitelist: ["entities", "fail"] });