diff --git a/ts/features/itwallet/common/utils/itwCredentialStatusAttestationUtils.ts b/ts/features/itwallet/common/utils/itwCredentialStatusAttestationUtils.ts index df51ad080d5..5892d46f340 100644 --- a/ts/features/itwallet/common/utils/itwCredentialStatusAttestationUtils.ts +++ b/ts/features/itwallet/common/utils/itwCredentialStatusAttestationUtils.ts @@ -3,6 +3,7 @@ import { Credential } from "@pagopa/io-react-native-wallet"; import { isAfter } from "date-fns"; +import * as t from "io-ts"; import { StoredCredential } from "./itwTypesUtils"; export const getCredentialStatusAttestation = async ( @@ -54,3 +55,11 @@ export const shouldRequestStatusAttestation = ({ throw new Error("Unexpected credential status"); } }; + +/** + * Shape of a credential status attestation response error. + */ +export const StatusAttestationError = t.intersection([ + t.type({ error: t.string }), + t.partial({ error_description: t.string }) +]); diff --git a/ts/features/itwallet/credentials/saga/checkCredentialsStatusAttestation.ts b/ts/features/itwallet/credentials/saga/checkCredentialsStatusAttestation.ts index 9d795468ea5..cf896a843bd 100644 --- a/ts/features/itwallet/credentials/saga/checkCredentialsStatusAttestation.ts +++ b/ts/features/itwallet/credentials/saga/checkCredentialsStatusAttestation.ts @@ -7,7 +7,8 @@ import { itwCredentialsSelector } from "../store/selectors"; import { StoredCredential } from "../../common/utils/itwTypesUtils"; import { shouldRequestStatusAttestation, - getCredentialStatusAttestation + getCredentialStatusAttestation, + StatusAttestationError } from "../../common/utils/itwCredentialStatusAttestationUtils"; import { ReduxSagaEffect } from "../../../../types/utils"; import { itwLifecycleIsValidSelector } from "../../lifecycle/store/selectors"; @@ -15,7 +16,6 @@ import { itwCredentialsStore } from "../store/actions"; import { updateMixpanelProfileProperties } from "../../../../mixpanelConfig/profileProperties"; import { updateMixpanelSuperProperties } from "../../../../mixpanelConfig/superProperties"; import { GlobalState } from "../../../../store/reducers/types"; -import { StatusAttestationError } from "./types"; const { isIssuerResponseError, IssuerResponseErrorCodes: Codes } = Errors; diff --git a/ts/features/itwallet/credentials/saga/types.ts b/ts/features/itwallet/credentials/saga/types.ts deleted file mode 100644 index b63c9adef8f..00000000000 --- a/ts/features/itwallet/credentials/saga/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import * as t from "io-ts"; - -export const StatusAttestationError = t.type({ - error: t.string -}); diff --git a/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialFailureScreen.tsx b/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialFailureScreen.tsx index b976239371c..dd7a4b39a66 100644 --- a/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialFailureScreen.tsx +++ b/ts/features/itwallet/issuance/screens/ItwIssuanceCredentialFailureScreen.tsx @@ -11,9 +11,7 @@ import { useDebugInfo } from "../../../../hooks/useDebugInfo"; import I18n from "../../../../i18n"; import { CredentialIssuanceFailure, - CredentialIssuanceFailureType, - CredentialIssuanceFailureTypeEnum, - isCredentialInvalidStatusError + CredentialIssuanceFailureType } from "../../machine/credential/failure"; import { selectCredentialTypeOption, @@ -26,7 +24,6 @@ import { useAvoidHardwareBackButton } from "../../../../utils/useAvoidHardwareBa import { CREDENTIALS_MAP, trackAddCredentialFailure, - trackAddCredentialTimeout, trackCredentialInvalidStatusFailure, trackCredentialNotEntitledFailure, trackItWalletDeferredIssuing, @@ -35,6 +32,7 @@ import { import ROUTES from "../../../../navigation/routes"; import { MESSAGES_ROUTES } from "../../../messages/navigation/routes"; import { getClaimsFullLocale } from "../../common/utils/itwClaimsUtils"; +import { StatusAttestationError } from "../../common/utils/itwCredentialStatusAttestationUtils"; export const ItwIssuanceCredentialFailureScreen = () => { const failureOption = @@ -60,7 +58,7 @@ const ContentView = ({ failure }: ContentViewProps) => { selectCredentialTypeOption ); - const invalidStatusMessage = useCredentialInvalidStatusMessage(failure); + const invalidStatusDetails = useCredentialInvalidStatusDetails(failure); const closeIssuance = (cta_id: string) => { machineRef.send({ type: "close" }); @@ -81,107 +79,130 @@ const ContentView = ({ failure }: ContentViewProps) => { failure }); - const resultScreensMap: Record< - CredentialIssuanceFailureType, - OperationResultScreenContentProps - > = { - GENERIC: { - title: I18n.t("features.itWallet.issuance.genericError.title"), - subtitle: I18n.t("features.itWallet.issuance.genericError.body"), - pictogram: "umbrellaNew", - action: { - label: I18n.t("features.itWallet.issuance.genericError.primaryAction"), - onPress: () => - closeIssuance( - I18n.t("features.itWallet.issuance.genericError.primaryAction") - ) + const getOperationResultScreenContentProps = + (): OperationResultScreenContentProps => { + switch (failure.type) { + case CredentialIssuanceFailureType.UNEXPECTED: + case CredentialIssuanceFailureType.ISSUER_GENERIC: + case CredentialIssuanceFailureType.WALLET_PROVIDER_GENERIC: + return { + title: I18n.t("features.itWallet.issuance.genericError.title"), + subtitle: I18n.t("features.itWallet.issuance.genericError.body"), + pictogram: "umbrellaNew", + action: { + label: I18n.t( + "features.itWallet.issuance.genericError.primaryAction" + ), + onPress: () => + closeIssuance( + I18n.t( + "features.itWallet.issuance.genericError.primaryAction" + ) + ) + } + }; + // NOTE: only the mDL supports the async flow, so this error message is specific to mDL + case CredentialIssuanceFailureType.ASYNC_ISSUANCE: + return { + title: I18n.t( + "features.itWallet.issuance.asyncCredentialError.title" + ), + subtitle: I18n.t( + "features.itWallet.issuance.asyncCredentialError.body" + ), + pictogram: "pending", + action: { + label: I18n.t( + "features.itWallet.issuance.asyncCredentialError.primaryAction" + ), + onPress: closeAsyncIssuance + } + }; + // Dynamic errors extracted from the entity configuration, with fallback + case CredentialIssuanceFailureType.INVALID_STATUS: + return { + title: + invalidStatusDetails.message?.title ?? + defaultInvalidStatusMessage.title, + subtitle: + invalidStatusDetails.message?.description ?? + defaultInvalidStatusMessage.description, + pictogram: "accessDenied", + action: { + label: I18n.t( + "features.itWallet.issuance.notEntitledCredentialError.primaryAction" + ), + onPress: () => + closeIssuance( + I18n.t( + "features.itWallet.issuance.notEntitledCredentialError.primaryAction" + ) + ) + } + }; } - }, - // NOTE: only the mDL supports the async flow, so this error message is specific to mDL - ASYNC_ISSUANCE: { - title: I18n.t("features.itWallet.issuance.asyncCredentialError.title"), - subtitle: I18n.t("features.itWallet.issuance.asyncCredentialError.body"), - pictogram: "pending", - action: { - label: I18n.t( - "features.itWallet.issuance.asyncCredentialError.primaryAction" - ), - onPress: closeAsyncIssuance - } - }, - // Dynamic errors extracted from the entity configuration - INVALID_STATUS: { - title: - invalidStatusMessage?.title ?? - I18n.t("features.itWallet.issuance.notEntitledCredentialError.title"), - subtitle: - invalidStatusMessage?.description ?? - I18n.t("features.itWallet.issuance.notEntitledCredentialError.body"), - pictogram: "accessDenied", - action: { - label: I18n.t( - "features.itWallet.issuance.notEntitledCredentialError.primaryAction" - ), - onPress: () => - closeIssuance( - I18n.t( - "features.itWallet.issuance.notEntitledCredentialError.primaryAction" - ) - ) - } - } - }; + }; useEffect(() => { if (O.isNone(credentialType)) { return; } - if (failure.type === CredentialIssuanceFailureTypeEnum.ASYNC_ISSUANCE) { + if (failure.type === CredentialIssuanceFailureType.ASYNC_ISSUANCE) { trackItWalletDeferredIssuing(CREDENTIALS_MAP[credentialType.value]); return; } - if (failure.type === CredentialIssuanceFailureTypeEnum.INVALID_STATUS) { - const error = failure.reason as Errors.CredentialInvalidStatusError; - + if (failure.type === CredentialIssuanceFailureType.INVALID_STATUS) { const trackingFunction = - error.errorCode === "credential_not_found" + invalidStatusDetails.errorCode === "credential_not_found" ? trackCredentialNotEntitledFailure : trackCredentialInvalidStatusFailure; trackingFunction({ - reason: error.errorCode, + reason: invalidStatusDetails.errorCode, type: failure.type, credential: CREDENTIALS_MAP[credentialType.value] }); return; } - if (failure.type === CredentialIssuanceFailureTypeEnum.GENERIC) { + if ( + failure.type === CredentialIssuanceFailureType.UNEXPECTED || + failure.type === CredentialIssuanceFailureType.ISSUER_GENERIC || + failure.type === CredentialIssuanceFailureType.WALLET_PROVIDER_GENERIC + ) { trackAddCredentialFailure({ reason: failure.reason, type: failure.type, credential: CREDENTIALS_MAP[credentialType.value] }); - return; } - trackAddCredentialTimeout({ + + /* trackAddCredentialTimeout({ reason: failure.reason, type: failure.type, credential: CREDENTIALS_MAP[credentialType.value] - }); - }, [credentialType, failure]); + }); */ + }, [credentialType, failure, invalidStatusDetails.errorCode]); - const resultScreenProps = resultScreensMap[failure.type]; + const resultScreenProps = getOperationResultScreenContentProps(); return ; }; +const defaultInvalidStatusMessage = { + title: I18n.t("features.itWallet.issuance.notEntitledCredentialError.title"), + description: I18n.t( + "features.itWallet.issuance.notEntitledCredentialError.body" + ) +}; + /** - * Hook used to safely extract the localized message from an invalid status error. - * This message is dynamic and must be extracted from the EC. + * Hook used to safely extract details from an invalid status error, including the localized message. + * + * **Note:** The message is dynamic and must be extracted from the EC. */ -const useCredentialInvalidStatusMessage = ( +const useCredentialInvalidStatusDetails = ( failure: CredentialIssuanceFailure ) => { const credentialType = ItwCredentialIssuanceMachineContext.useSelector( @@ -191,17 +212,30 @@ const useCredentialInvalidStatusMessage = ( selectIssuerConfigurationOption ); - return pipe( + const errorCodeOption = pipe( + failure, + O.fromPredicate( + e => e.type === CredentialIssuanceFailureType.INVALID_STATUS + ), + O.chainEitherK(e => StatusAttestationError.decode(e.reason.reason)), + O.map(x => x.error) + ); + + const localizedMessage = pipe( sequenceS(O.Monad)({ - failure: pipe(failure, O.fromPredicate(isCredentialInvalidStatusError)), + errorCode: errorCodeOption, credentialType, issuerConf }), - // eslint-disable-next-line @typescript-eslint/no-shadow - O.map(({ failure, ...rest }) => - Errors.extractErrorMessageFromIssuerConf(failure.reason.errorCode, rest) + O.map(({ errorCode, ...rest }) => + Errors.extractErrorMessageFromIssuerConf(errorCode, rest) ), O.map(message => message?.[getClaimsFullLocale()]), O.toUndefined ); + + return { + message: localizedMessage, + errorCode: pipe(errorCodeOption, O.toUndefined) + }; }; diff --git a/ts/features/itwallet/machine/credential/failure.ts b/ts/features/itwallet/machine/credential/failure.ts index 6e5cf094dac..2c9741b5303 100644 --- a/ts/features/itwallet/machine/credential/failure.ts +++ b/ts/features/itwallet/machine/credential/failure.ts @@ -1,48 +1,43 @@ -import { enumType } from "@pagopa/ts-commons/lib/types"; -import * as t from "io-ts"; import { Errors } from "@pagopa/io-react-native-wallet"; -import { assert } from "../../../../utils/assert"; import { CredentialIssuanceEvents } from "./events"; -export enum CredentialIssuanceFailureTypeEnum { - GENERIC = "GENERIC", +const { + isIssuerResponseError, + isWalletProviderResponseError, + IssuerResponseErrorCodes: Codes +} = Errors; + +export enum CredentialIssuanceFailureType { + UNEXPECTED = "UNEXPECTED", ASYNC_ISSUANCE = "ASYNC_ISSUANCE", - INVALID_STATUS = "INVALID_STATUS" + INVALID_STATUS = "INVALID_STATUS", + ISSUER_GENERIC = "ISSUER_GENERIC", + WALLET_PROVIDER_GENERIC = "WALLET_PROVIDER_GENERIC" } -export const CredentialIssuanceFailureType = - enumType( - CredentialIssuanceFailureTypeEnum, - "CredentialIssuanceFailureTypeEnum" - ); - -export type CredentialIssuanceFailureType = t.TypeOf< - typeof CredentialIssuanceFailureType ->; - -const CredentialIssuanceFailureR = t.type({ - type: CredentialIssuanceFailureType -}); - -const CredentialIssuanceFailureO = t.partial({ - reason: t.unknown -}); - -export const CredentialIssuanceFailure = t.intersection( - [CredentialIssuanceFailureR, CredentialIssuanceFailureO], - "CredentialIssuanceFailure" -); +/** + * Type that maps known reasons with the corresponding failure, in order to avoid unknowns as much as possible. + */ +export type ReasonTypeByFailure = { + [CredentialIssuanceFailureType.ISSUER_GENERIC]: Errors.IssuerResponseError; + [CredentialIssuanceFailureType.INVALID_STATUS]: Errors.IssuerResponseError; + [CredentialIssuanceFailureType.ASYNC_ISSUANCE]: Errors.IssuerResponseError; + [CredentialIssuanceFailureType.WALLET_PROVIDER_GENERIC]: Errors.WalletProviderResponseError; + [CredentialIssuanceFailureType.UNEXPECTED]: unknown; +}; -export type CredentialIssuanceFailure = t.TypeOf< - typeof CredentialIssuanceFailure ->; +type TypedCredentialIssuanceFailures = { + [K in CredentialIssuanceFailureType]: { + type: K; + reason: ReasonTypeByFailure[K]; + }; +}; -export const isCredentialInvalidStatusError = ( - error: CredentialIssuanceFailure -): error is { - type: CredentialIssuanceFailureTypeEnum.INVALID_STATUS; - reason: Errors.CredentialInvalidStatusError; -} => error.type === CredentialIssuanceFailureTypeEnum.INVALID_STATUS; +/* + * Union type of failures with the reason properly typed. + */ +export type CredentialIssuanceFailure = + TypedCredentialIssuanceFailures[keyof TypedCredentialIssuanceFailures]; /** * Maps an event dispatched by the credential issuance machine to a failure object. @@ -54,32 +49,43 @@ export const isCredentialInvalidStatusError = ( export const mapEventToFailure = ( event: CredentialIssuanceEvents ): CredentialIssuanceFailure => { - try { - assert("error" in event && event.error, "Not an error event"); - const error = event.error; + // This should never happen + if (!("error" in event)) { + return { type: CredentialIssuanceFailureType.UNEXPECTED, reason: null }; + } + + const { error } = event; - if (error instanceof Errors.CredentialInvalidStatusError) { - return { - type: CredentialIssuanceFailureTypeEnum.INVALID_STATUS, - reason: error - }; - } + if (isIssuerResponseError(error, Codes.CredentialInvalidStatus)) { + return { + type: CredentialIssuanceFailureType.INVALID_STATUS, + reason: error + }; + } - if (error instanceof Errors.CredentialIssuingNotSynchronousError) { - return { - type: CredentialIssuanceFailureTypeEnum.ASYNC_ISSUANCE, - reason: error - }; - } + if (isIssuerResponseError(error, Codes.CredentialIssuingNotSynchronous)) { + return { + type: CredentialIssuanceFailureType.ASYNC_ISSUANCE, + reason: error + }; + } + if (isIssuerResponseError(error)) { return { - type: CredentialIssuanceFailureTypeEnum.GENERIC, + type: CredentialIssuanceFailureType.ISSUER_GENERIC, reason: error }; - } catch (e) { + } + + if (isWalletProviderResponseError(error)) { return { - type: CredentialIssuanceFailureTypeEnum.GENERIC, - reason: e + type: CredentialIssuanceFailureType.WALLET_PROVIDER_GENERIC, + reason: error }; } + + return { + type: CredentialIssuanceFailureType.UNEXPECTED, + reason: error + }; };