diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b0c98e22bb8..80d2c508644 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1042,4 +1042,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 0d78c2a7f58353c7eed2163b7e3b3747b00ff101 -COCOAPODS: 1.14.2 +COCOAPODS: 1.12.1 diff --git a/locales/en/index.yml b/locales/en/index.yml index 9caf93febb6..7dc616ad3c6 100644 --- a/locales/en/index.yml +++ b/locales/en/index.yml @@ -3467,6 +3467,10 @@ features: securityLevels: high: "High" na: "Not available" + mdl: + category: "License {{category}}" + issuedDate: "Valid from" + expirationDate: "Valid until" unrecognizedData: title: "Not recognizing some data?" body: "If there are errors in the data or if you want to better understand their meaning, you can contact the {{issuer}}." diff --git a/locales/it/index.yml b/locales/it/index.yml index edff06c5c7c..2cfb0ac0ea1 100644 --- a/locales/it/index.yml +++ b/locales/it/index.yml @@ -3468,6 +3468,10 @@ features: securityLevels: high: "Alto" na: "Non disponibile" + mdl: + category: "Licenza {{category}}" + issuedDate: "Valida dal" + expirationDate: "Valida fino" unrecognizedData: title: "Non riconosci alcuni dati?" body: "Se ci sono errori nei dati o vuoi capire meglio cosa significano, puoi contattare il Ministero dell’Interno." @@ -3490,7 +3494,7 @@ features: credential: "Credenziale" attribute: "Attributo" placeholders: - claimNotAvailable: "Attributo non presente" + claimNotAvailable: "Attributo non riconosciuto" claimLabelNotAvailable: "Etichetta attributo non presente" organizationName: "Nome ente non disponibile" webView: diff --git a/ts/features/it-wallet/components/ItwCredentialClaim.tsx b/ts/features/it-wallet/components/ItwCredentialClaim.tsx new file mode 100644 index 00000000000..2f1ce590063 --- /dev/null +++ b/ts/features/it-wallet/components/ItwCredentialClaim.tsx @@ -0,0 +1,273 @@ +import React from "react"; +import { Divider, ListItemInfo } from "@pagopa/io-app-design-system"; +import * as E from "fp-ts/Either"; +import { pipe } from "fp-ts/lib/function"; +import { View, Image } from "react-native"; +import { DateFromString } from "@pagopa/ts-commons/lib/dates"; +import { + ClaimValue, + DrivingPrivilegesClaim, + DrivingPrivilegesClaimType, + EvidenceClaim, + ImageClaim, + PlaceOfBirthClaim, + PlaceOfBirthClaimType, + PlainTextClaim +} from "../utils/itwClaimsUtils"; +import I18n from "../../../i18n"; +import { useItwInfoBottomSheet } from "../hooks/useItwInfoBottomSheet"; +import { localeDateFormat } from "../../../utils/locale"; +import { useIOBottomSheetAutoresizableModal } from "../../../utils/hooks/bottomSheet"; +import { Claim } from "./ItwCredentialClaimsList"; + +/** + * Component which renders a place of birth type claim. + * @param label - the label of the claim + * @param claim - the claim value + */ +const PlaceOfBirthClaimItem = ({ + label, + claim +}: { + label: string; + claim: PlaceOfBirthClaimType; +}) => { + const value = `${claim.locality} (${claim.country})`; + return ( + <> + + + + ); +}; + +/** + * Component which renders a generic text type claim. + * @param label - the label of the claim + * @param claim - the claim value + */ +const PlainTextClaimItem = ({ + label, + claim +}: { + label: string; + claim: string; +}) => ( + <> + + + +); + +/** + * Component which renders a date type claim. + * @param label - the label of the claim + * @param claim - the value of the claim + */ +const DateClaimItem = ({ label, claim }: { label: string; claim: Date }) => { + const value = localeDateFormat( + claim, + I18n.t("global.dateFormats.shortFormat") + ); + return ( + + + + + ); +}; + +/** + * Component which renders a evidence type claim. + * It features a bottom sheet with information about the issuer of the claim. + * @param issuerName - the organization name of the issuer of the evidence claim. + */ +const EvidenceClaimItem = ({ issuerName }: { issuerName: string }) => { + const issuedByBottomSheet = useItwInfoBottomSheet({ + title: issuerName, + content: [ + { + title: I18n.t( + "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.about.title" + ), + body: I18n.t( + "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.about.subtitle" + ) + }, + { + title: I18n.t( + "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.data.title" + ), + body: I18n.t( + "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.data.subtitle" + ) + } + ] + }); + const label = I18n.t( + "features.itWallet.verifiableCredentials.claims.issuedByNew" + ); + return ( + <> + issuedByBottomSheet.present() + } + }} + label={label} + value={issuerName} + accessibilityLabel={`${label} ${issuerName}`} + /> + {issuedByBottomSheet.bottomSheet} + + + ); +}; + +/** + * Component which renders a claim of unknown type with a placeholder. + * @param label - the label of the claim + * @param _claim - the claim value of unknown type. We are not interested in its value but it's needed for the exaustive type checking. + */ +const UnknownClaimItem = ({ label }: { label: string; _claim?: never }) => ( + +); + +/** + * Component which renders a image type claim in a square container. + * @param label - the label of the claim + * @param claim - the claim value + */ +const ImageClaimItem = ({ label, claim }: { label: string; claim: string }) => ( + <> + + } + accessibilityLabel={`${label} ${claim}`} + /> + + +); + +/** + * Component which renders a driving privileges type claim. + * It features a bottom sheet with information about the issued and expiration date of the claim. + * @param label - the label of the claim + * @param claim - the claim value + * @returns + */ +const DrivingPrivilegesClaimItem = ({ + label, + claim +}: { + label: string; + claim: DrivingPrivilegesClaimType; +}) => { + const privilegeBottomSheet = useIOBottomSheetAutoresizableModal({ + title: I18n.t( + "features.itWallet.verifiableCredentials.claims.mdl.category", + { category: claim.vehicle_category_code } + ), + component: ( + <> + + + + + ) + }); + return ( + <> + privilegeBottomSheet.present() + } + }} + accessibilityLabel={`${label} ${claim}`} + /> + + {privilegeBottomSheet.bottomSheet} + + ); +}; + +/** + * Component which renders a claim. + * It renders a different component based on the type of the claim. + * @param claim - the claim to render + */ +const ItwCredentialClaim = ({ claim }: { claim: Claim }) => + pipe( + claim.value, + ClaimValue.decode, + E.fold( + () => , + decoded => { + if (PlaceOfBirthClaim.is(decoded)) { + return ; + } else if (DateFromString.is(decoded)) { + return ; + } else if (EvidenceClaim.is(decoded)) { + return ( + + ); + } else if (ImageClaim.is(decoded)) { + return ; + } else if (DrivingPrivilegesClaim.is(decoded)) { + return ( + + ); + } else if (PlainTextClaim.is(decoded)) { + return ; // must be the last one to be checked due to overlap with IPatternStringTag + } else { + return ; + } + } + ) + ); + +export default ItwCredentialClaim; diff --git a/ts/features/it-wallet/components/ItwCredentialClaimsList.tsx b/ts/features/it-wallet/components/ItwCredentialClaimsList.tsx index 7f68463b36b..40f74673140 100644 --- a/ts/features/it-wallet/components/ItwCredentialClaimsList.tsx +++ b/ts/features/it-wallet/components/ItwCredentialClaimsList.tsx @@ -1,46 +1,24 @@ import React from "react"; -import { Divider, ListItemInfo } from "@pagopa/io-app-design-system"; +import { ListItemInfo } from "@pagopa/io-app-design-system"; import { View } from "react-native"; -import * as t from "io-ts"; -import { pipe } from "fp-ts/lib/function"; -import * as E from "fp-ts/Either"; -import { getClaimsFullLocale } from "../utils/locales"; import I18n from "../../../i18n"; import { CredentialCatalogDisplay } from "../utils/mocks"; import { StoredCredential } from "../store/reducers/itwCredentialsReducer"; import { useItwInfoBottomSheet } from "../hooks/useItwInfoBottomSheet"; import { ParsedCredential } from "../utils/types"; +import { getClaimsFullLocale } from "../utils/itwClaimsUtils"; +import ItwCredentialClaim from "./ItwCredentialClaim"; -/** - * Type of the claims list. - * Consists of a list of claims, each claim is a couple of label and value. - */ -type ClaimList = Array<{ +export type Claim = { label: string; value: unknown; -}>; - -/** - * Decoder type for the evidence field of the credential. - */ -const EvidenceClaimDecoder = t.array( - t.type({ - type: t.string, - record: t.type({ - type: t.string, - source: t.type({ - organization_name: t.string, - organization_id: t.string, - country_code: t.string - }) - }) - }) -); +}; /** - * Decoder for string claims. + * Type of the claims list. + * Consists of a list of claims, each claim is a couple of label and value. */ -const StringClaimDecoder = t.string; +export type ClaimList = Array; /** * Parses the claims from the credential. @@ -95,78 +73,42 @@ const ItwCredentialClaimsList = ({ }) => { const claims = parseClaims(sortClaims(displayData.order, parsedCredential)); - const evidence = EvidenceClaimDecoder.decode( - claims.find(claim => claim.label === "evidence")?.value - ); const releaserName = issuerConf.federation_entity.organization_name; /** - * Bottom sheet for the issuer name. - */ - const issuedByBottomSheet = useItwInfoBottomSheet({ - title: pipe( - evidence, - E.fold( - () => I18n.t("features.itWallet.generic.placeholders.organizationName"), - right => right[0].record.source.organization_name - ) - ), - content: [ - { - title: I18n.t( - "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.about.title" - ), - body: I18n.t( - "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.about.subtitle" - ) - }, - { - title: I18n.t( - "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.data.title" - ), - body: I18n.t( - "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.data.subtitle" - ) - } - ] - }); - - /** - * Bottom sheet for the releaser name. - */ - const releasedByBottomSheet = useItwInfoBottomSheet({ - title: - releaserName ?? - I18n.t("features.itWallet.generic.placeholders.organizationName"), - content: [ - { - title: I18n.t( - "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.about.title" - ), - body: I18n.t( - "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.about.subtitle" - ) - }, - { - title: I18n.t( - "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.data.title" - ), - body: I18n.t( - "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.data.subtitle" - ) - } - ] - }); - - /** - * Renders the issuer name with an info button that opens the bottom sheet. - * @param issuerName - the issuer name. - * @returns the list item with the issuer name. + * Renders the releaser name with an info button that opens the bottom sheet. + * This is not part of the claims list because it's not a claim. + * Thus it's rendered separately. + * @param releaserName - the releaser name. + * @returns the list item with the releaser name. */ - const RenderIssuerName = ({ issuerName }: { issuerName: string }) => { + const RenderReleaserName = ({ releaserName }: { releaserName: string }) => { const label = I18n.t( - "features.itWallet.verifiableCredentials.claims.issuedByNew" + "features.itWallet.verifiableCredentials.claims.releasedBy" ); + const releasedByBottomSheet = useItwInfoBottomSheet({ + title: + releaserName ?? + I18n.t("features.itWallet.generic.placeholders.organizationName"), + content: [ + { + title: I18n.t( + "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.about.title" + ), + body: I18n.t( + "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.about.subtitle" + ) + }, + { + title: I18n.t( + "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.data.title" + ), + body: I18n.t( + "features.itWallet.issuing.credentialPreviewScreen.bottomSheet.data.subtitle" + ) + } + ] + }); return ( <> issuedByBottomSheet.present() + onPress: () => releasedByBottomSheet.present() } }} label={label} - value={issuerName} - accessibilityLabel={`${label} ${issuerName}`} + value={releaserName} + accessibilityLabel={`${label} ${releaserName}`} /> - + {releasedByBottomSheet.bottomSheet} ); }; - /** - * Renders the releaser name with an info button that opens the bottom sheet. - * @param releaserName - the releaser name. - * @returns the list item with the releaser name. - */ - const RenderReleaserName = ({ releaserName }: { releaserName: string }) => { - const label = I18n.t( - "features.itWallet.verifiableCredentials.claims.releasedBy" - ); - return ( - releasedByBottomSheet.present() - } - }} - label={label} - value={releaserName} - accessibilityLabel={`${label} ${releaserName}`} - /> - ); - }; - return ( <> - {claims.map( - ({ label, value }, index, _, key = `${index}_${label}` /* 🥷 */) => - pipe( - value, - StringClaimDecoder.decode, - E.fold( - () => null, - () => ( - - - - - ) - ) - ) - )} - {E.isRight(evidence) && ( - <> - - {issuedByBottomSheet.bottomSheet} - - )} + {claims.map((elem, index) => ( + + + + ))} {releaserName && ( <> - {releasedByBottomSheet.bottomSheet} )} diff --git a/ts/features/it-wallet/utils/__tests__/itwClaimsUtilsTest.ts b/ts/features/it-wallet/utils/__tests__/itwClaimsUtilsTest.ts new file mode 100644 index 00000000000..cfe0f4f26b6 --- /dev/null +++ b/ts/features/it-wallet/utils/__tests__/itwClaimsUtilsTest.ts @@ -0,0 +1,113 @@ +import * as E from "fp-ts/lib/Either"; +import { + DateClaim, + EvidenceClaim, + ImageClaim, + PlaceOfBirthClaim +} from "../itwClaimsUtils"; + +describe("Test itwClaimsUtils", () => { + describe("Test DateClaim", () => { + it("should match a string in format YYYY-MM-DD", () => { + const date = "2020-01-01"; + expect(E.isRight(DateClaim.decode(date))).toEqual(true); + }); + + it("should not match a string with only numbers", () => { + const date = "0000001"; + expect(E.isRight(DateClaim.decode(date))).toEqual(false); + }); + }); + + describe("Test ImageClaim", () => { + it("should match a string starting with data:image/png;base64,", () => { + const image = "data:image/png;base64,testtesttest"; + expect(E.isRight(ImageClaim.decode(image))).toEqual(true); + }); + + it("should not match a string not starting with data:image/png;base64,", () => { + const image = "testtesttest"; + expect(E.isRight(ImageClaim.decode(image))).toEqual(false); + }); + }); + + describe("Test EvidenceClaim", () => { + it("should match a valid evidence claim", () => { + const evidence = [ + { + type: "test", + record: { + type: "test", + source: { + organization_name: "test", + organization_id: "test", + country_code: "test" + } + } + } + ]; + expect(E.isRight(EvidenceClaim.decode(evidence))).toEqual(true); + }); + + it("should not match an invalid evidence claim", () => { + const evidence = [ + { + type: "test", + record: { + type: "test", + source: { + organization_name: "test", + organization_id: "test" + } + } + } + ]; + expect(E.isRight(EvidenceClaim.decode(evidence))).toEqual(false); + }); + + it("should match a valid evidence claim with multiple records", () => { + const evidence = [ + { + type: "test", + record: { + type: "test", + source: { + organization_name: "test", + organization_id: "test", + country_code: "test" + } + } + }, + { + type: "test", + record: { + type: "test", + source: { + organization_name: "test", + organization_id: "test", + country_code: "test" + } + } + } + ]; + expect(E.isRight(EvidenceClaim.decode(evidence))).toEqual(true); + }); + }); + + describe("Test PlaceOfBirthClaim", () => { + it("should match a valid place of birth claim", () => { + const placeOfBirth = { + country: "test", + locality: "test" + }; + expect(E.isRight(PlaceOfBirthClaim.decode(placeOfBirth))).toEqual(true); + }); + + it("should not match an invalid place of birth claim", () => { + const placeOfBirth = { + country: "test" + }; + expect(E.isRight(PlaceOfBirthClaim.decode(placeOfBirth))).toEqual(false); + }); + }); +}); diff --git a/ts/features/it-wallet/utils/itwClaimsUtils.ts b/ts/features/it-wallet/utils/itwClaimsUtils.ts new file mode 100644 index 00000000000..3adc7bc3e3c --- /dev/null +++ b/ts/features/it-wallet/utils/itwClaimsUtils.ts @@ -0,0 +1,115 @@ +import * as t from "io-ts"; +import { PatternString } from "@pagopa/ts-commons/lib/strings"; +import { patternDateFromString } from "@pagopa/ts-commons/lib/dates"; +import { Locales } from "../../../../locales/locales"; +import I18n from "../../../i18n"; + +/** + * Enum for the claims locales. + * This is used to get the correct locale for the claims. + * Currently the only supported locales are it-IT and en-US. + */ +enum ClaimsLocales { + it = "it-IT", + en = "en-US" +} + +/** + * Map from the app locales to the claims locales. + * Currently en is mapped to en-US and it to it-IT. + */ +const localeToClaimsLocales = new Map([ + ["it", ClaimsLocales.it], + ["en", ClaimsLocales.en] +]); + +/** + * Helper function to get a full claims locale locale from the current app locale. + * @returns a enum value for the claims locale. + */ +export const getClaimsFullLocale = (): ClaimsLocales => + localeToClaimsLocales.get(I18n.currentLocale()) ?? ClaimsLocales.it; + +/** + * Regex for the date format which is used to validate the date claim as ISO 8601:2004 YYYY-MM-DD format. + */ +const DATE_FORMAT_REGEX = "^\\d{4}-\\d{2}-\\d{2}$"; + +/** + * Regex for the picture URL format which is used to validate the image claim as a base64 encoded png image. + */ +const PICTURE_URL_REGEX = "^data:image\\/png;base64,"; + +/** + * io-ts decoder for the date claim field of the credential. + * The date format is checked against the regex dateFormatRegex, which is currenlty mocked. + * This is needed because a generic date decoder would accept invalid dates like numbers, + * thus decoding properly and returning a wrong claim item to be displayed. + */ +export const DateClaim = patternDateFromString(DATE_FORMAT_REGEX, "DateClaim"); + +/** + * io-ts decoder for the evidence claim field of the credential. + */ +export const EvidenceClaim = t.array( + t.type({ + type: t.string, + record: t.type({ + type: t.string, + source: t.type({ + organization_name: t.string, + organization_id: t.string, + country_code: t.string + }) + }) + }) +); + +/** + * io-ts decoder for the place of birth claim field of the credential. + */ +export const PlaceOfBirthClaim = t.type({ + country: t.string, + locality: t.string +}); +export type PlaceOfBirthClaimType = t.TypeOf; + +/** + * io-ts decoder for the mDL driving privileges + */ +export const DrivingPrivilegesClaim = t.type({ + issue_date: t.string, + vehicle_category_code: t.string, + expiry_date: t.string +}); +export type DrivingPrivilegesClaimType = t.TypeOf< + typeof DrivingPrivilegesClaim +>; + +/** + * Alias for the string fallback of the claim field of the credential. + */ +export const PlainTextClaim = t.string; + +export const ImageClaim = PatternString(PICTURE_URL_REGEX); + +/** + * Decoder type for the claim field of the credential. + * It includes all the possible types of claims and fallbacks to string. + * To add more custom objects to the union: + * t.string.pipe(JsonFromString).pipe(t.union([PlaceOfBirthClaim, PlaceOfBirthClaim])) + */ +export const ClaimValue = t.union([ + // Parse an object representing the place of birth + PlaceOfBirthClaim, + // Parse an object representing a mDL driving privileges + DrivingPrivilegesClaim, + // Parse an object representing the claim evidence + EvidenceClaim, + // Otherwise parse a date + DateClaim, + // Otherwise parse an image + ImageClaim, + // Otherwise fallback to string + PlainTextClaim +]); diff --git a/ts/features/it-wallet/utils/locales.ts b/ts/features/it-wallet/utils/locales.ts deleted file mode 100644 index a755458aaba..00000000000 --- a/ts/features/it-wallet/utils/locales.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { pipe } from "fp-ts/lib/function"; -import * as O from "fp-ts/Option"; -import { Locales } from "../../../../locales/locales"; -import I18n from "../../../i18n"; -import { localeDateFormat } from "../../../utils/locale"; -import { dateFormatRegex } from "./mocks"; - -/** - * Enum for the claims locales. - * This is used to get the correct locale for the claims. - * Currently the only supported locales are it-IT and en-US. - */ -enum ClaimsLocales { - it = "it-IT", - en = "en-US" -} - -/** - * Map from the app locales to the claims locales. - * Currently en is mapped to en-US and it to it-IT. - */ -const localeToClaimsLocales = new Map([ - ["it", ClaimsLocales.it], - ["en", ClaimsLocales.en] -]); - -/** - * Helper function to get a full claims locale locale from the current app locale. - * @returns a enum value for the claims locale. - */ -export const getClaimsFullLocale = (): ClaimsLocales => - localeToClaimsLocales.get(I18n.currentLocale()) ?? ClaimsLocales.it; - -/** - * Converts a string in the YYYY-MM-DD format to a locale date string. - * @param str - the date string to convert - * @param format - the format to use - * @returns the converted date string or the original string if the format is not YYYY-MM-DD - */ -export const localeDateFormatOrSame = (str: string) => - pipe( - dateFormatRegex, - O.fromPredicate(p => p.test(str)), - O.fold( - () => str, - () => - localeDateFormat( - new Date(str), - I18n.t("global.dateFormats.shortFormat") - ) - ) - ); diff --git a/ts/features/it-wallet/utils/mocks.ts b/ts/features/it-wallet/utils/mocks.ts index 09eec96c0ae..fb96e020947 100644 --- a/ts/features/it-wallet/utils/mocks.ts +++ b/ts/features/it-wallet/utils/mocks.ts @@ -97,7 +97,9 @@ export const getCredentialsCatalog = (): Array => [ "birthdate", "accompanying_person_right", "expiration_date", - "serial_number" + "fiscal_code", + "serial_number", + "evidence" ] }, { @@ -120,7 +122,8 @@ export const getCredentialsCatalog = (): Array => [ "province", "nation", "institution_number_team", - "document_number_team" + "document_number_team", + "evidence" ] }, { @@ -143,7 +146,7 @@ export const getCredentialsCatalog = (): Array => [ "issuing_country", "issuing_authority", "document_number", - "un_distinguishing_sing", + "un_distinguishing_sign", "portrait", "driving_privileges", "evidence"