diff --git a/libs/importer/spec/protonpass-json-importer.spec.ts b/libs/importer/spec/protonpass-json-importer.spec.ts index d5f4653c6434..39a09127c277 100644 --- a/libs/importer/spec/protonpass-json-importer.spec.ts +++ b/libs/importer/spec/protonpass-json-importer.spec.ts @@ -85,7 +85,7 @@ describe("Protonpass Json Importer", () => { // "My Secure Note" is assigned to folder "Personal" expect(result.folderRelationships[1]).toEqual([1, 0]); // "Other vault login" is assigned to folder "Test" - expect(result.folderRelationships[3]).toEqual([3, 1]); + expect(result.folderRelationships[4]).toEqual([4, 1]); }); it("should create collections if part of an organization", async () => { @@ -102,7 +102,7 @@ describe("Protonpass Json Importer", () => { // "My Secure Note" is assigned to folder "Personal" expect(result.collectionRelationships[1]).toEqual([1, 0]); // "Other vault login" is assigned to folder "Test" - expect(result.collectionRelationships[3]).toEqual([3, 1]); + expect(result.collectionRelationships[4]).toEqual([4, 1]); }); it("should not add deleted items", async () => { @@ -114,7 +114,7 @@ describe("Protonpass Json Importer", () => { expect(cipher.name).not.toBe("My Deleted Note"); } - expect(ciphers.length).toBe(4); + expect(ciphers.length).toBe(5); }); it("should set favorites", async () => { @@ -126,4 +126,97 @@ describe("Protonpass Json Importer", () => { expect(ciphers[1].favorite).toBe(false); expect(ciphers[2].favorite).toBe(true); }); + + it("should skip unsupported items", async () => { + const testDataJson = JSON.stringify(testData); + const result = await importer.parse(testDataJson); + expect(result != null).toBe(true); + + const ciphers = result.ciphers; + expect(ciphers.length).toBe(5); + expect(ciphers[4].type).toEqual(CipherType.Login); + }); + + it("should parse identity data", async () => { + const testDataJson = JSON.stringify(testData); + const result = await importer.parse(testDataJson); + expect(result != null).toBe(true); + + result.ciphers.shift(); + result.ciphers.shift(); + result.ciphers.shift(); + + const cipher = result.ciphers.shift(); + expect(cipher.type).toEqual(CipherType.Identity); + expect(cipher.identity.firstName).toBe("Test"); + expect(cipher.identity.middleName).toBe("1"); + expect(cipher.identity.lastName).toBe("1"); + expect(cipher.identity.email).toBe("test@gmail.com"); + expect(cipher.identity.phone).toBe("7507951789"); + expect(cipher.identity.company).toBe("Bitwarden"); + expect(cipher.identity.ssn).toBe("98378264782"); + expect(cipher.identity.passportNumber).toBe("7173716378612"); + expect(cipher.identity.licenseNumber).toBe("21234"); + expect(cipher.identity.address1).toBe("Bitwarden"); + expect(cipher.identity.address2).toBe("23 Street"); + expect(cipher.identity.address3).toBe("12th Foor Test County"); + expect(cipher.identity.city).toBe("New York"); + expect(cipher.identity.state).toBe("Test"); + expect(cipher.identity.postalCode).toBe("4038456"); + expect(cipher.identity.country).toBe("US"); + + expect(cipher.fields.length).toEqual(13); + + expect(cipher.fields.at(0).name).toEqual("gender"); + expect(cipher.fields.at(0).value).toEqual("Male"); + expect(cipher.fields.at(0).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(1).name).toEqual("TestPersonal"); + expect(cipher.fields.at(1).value).toEqual("Personal"); + expect(cipher.fields.at(1).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(2).name).toEqual("TestAddress"); + expect(cipher.fields.at(2).value).toEqual("Address"); + expect(cipher.fields.at(2).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(3).name).toEqual("xHandle"); + expect(cipher.fields.at(3).value).toEqual("@twiter"); + expect(cipher.fields.at(3).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(4).name).toEqual("secondPhoneNumber"); + expect(cipher.fields.at(4).value).toEqual("243538978"); + expect(cipher.fields.at(4).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(5).name).toEqual("instagram"); + expect(cipher.fields.at(5).value).toEqual("@insta"); + expect(cipher.fields.at(5).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(6).name).toEqual("TestContact"); + expect(cipher.fields.at(6).value).toEqual("Contact"); + expect(cipher.fields.at(6).type).toEqual(FieldType.Hidden); + + expect(cipher.fields.at(7).name).toEqual("jobTitle"); + expect(cipher.fields.at(7).value).toEqual("Engineer"); + expect(cipher.fields.at(7).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(8).name).toEqual("workPhoneNumber"); + expect(cipher.fields.at(8).value).toEqual("78236476238746"); + expect(cipher.fields.at(8).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(9).name).toEqual("TestWork"); + expect(cipher.fields.at(9).value).toEqual("Work"); + expect(cipher.fields.at(9).type).toEqual(FieldType.Hidden); + + expect(cipher.fields.at(10).name).toEqual("TestSection"); + expect(cipher.fields.at(10).value).toEqual("Section"); + expect(cipher.fields.at(10).type).toEqual(FieldType.Text); + + expect(cipher.fields.at(11).name).toEqual("TestSectionHidden"); + expect(cipher.fields.at(11).value).toEqual("SectionHidden"); + expect(cipher.fields.at(11).type).toEqual(FieldType.Hidden); + + expect(cipher.fields.at(12).name).toEqual("TestExtra"); + expect(cipher.fields.at(12).value).toEqual("Extra"); + expect(cipher.fields.at(12).type).toEqual(FieldType.Text); + }); }); diff --git a/libs/importer/spec/test-data/protonpass-json/protonpass.json.ts b/libs/importer/spec/test-data/protonpass-json/protonpass.json.ts index a508a03debc7..367c2b37e14a 100644 --- a/libs/importer/spec/test-data/protonpass-json/protonpass.json.ts +++ b/libs/importer/spec/test-data/protonpass-json/protonpass.json.ts @@ -138,6 +138,144 @@ export const testData: ProtonPassJsonFile = { modifyTime: 1689182908, pinned: false, }, + { + itemId: + "gliCOyyJOsoBf5QIijvCF4QsPij3q_MR4nCXZ2sXm7YCJCfHjrRD_p2XG9vLsaytErsQvMhcLISVS7q8-7SCkg==", + shareId: + "TpawpLbs1nuUlQUCtgKZgb3zgAvbrGrOaqOylKqVe_RLROEyUvMq8_ZEuGw73PGRUSr89iNtQ2NosuggP54nwA==", + data: { + metadata: { + name: "Identity", + note: "", + itemUuid: "c2e52768", + }, + extraFields: [ + { + fieldName: "TestExtra", + type: "text", + data: { + content: "Extra", + }, + }, + ], + type: "identity", + content: { + fullName: "Test 1", + email: "test@gmail.com", + phoneNumber: "7507951789", + firstName: "Test", + middleName: "1", + lastName: "Test", + birthdate: "", + gender: "Male", + extraPersonalDetails: [ + { + fieldName: "TestPersonal", + type: "text", + data: { + content: "Personal", + }, + }, + ], + organization: "Bitwarden", + streetAddress: "23 Street", + zipOrPostalCode: "4038456", + city: "New York", + stateOrProvince: "Test", + countryOrRegion: "US", + floor: "12th Foor", + county: "Test County", + extraAddressDetails: [ + { + fieldName: "TestAddress", + type: "text", + data: { + content: "Address", + }, + }, + ], + socialSecurityNumber: "98378264782", + passportNumber: "7173716378612", + licenseNumber: "21234", + website: "", + xHandle: "@twiter", + secondPhoneNumber: "243538978", + linkedin: "", + reddit: "", + facebook: "", + yahoo: "", + instagram: "@insta", + extraContactDetails: [ + { + fieldName: "TestContact", + type: "hidden", + data: { + content: "Contact", + }, + }, + ], + company: "Bitwarden", + jobTitle: "Engineer", + personalWebsite: "", + workPhoneNumber: "78236476238746", + workEmail: "", + extraWorkDetails: [ + { + fieldName: "TestWork", + type: "hidden", + data: { + content: "Work", + }, + }, + ], + extraSections: [ + { + sectionName: "TestSection", + sectionFields: [ + { + fieldName: "TestSection", + type: "text", + data: { + content: "Section", + }, + }, + { + fieldName: "TestSectionHidden", + type: "hidden", + data: { + content: "SectionHidden", + }, + }, + ], + }, + ], + }, + }, + state: 1, + aliasEmail: null, + contentFormatVersion: 6, + createTime: 1725707298, + modifyTime: 1725707298, + pinned: false, + }, + { + itemId: + "WTKLZtKfHIC3Gv7gRXUANifNjj0gN3P_52I4MznAzig9GSb_OgJ0qcZ8taOZyfsFTLOWBslXwI-HSMWXVmnKzQ==", + shareId: + "TpawpLbs1nuUlQUCtgKZgb3zgAvbrGrOaqOylKqVe_RLROEyUvMq8_ZEuGw73PGRUSr89iNtQ2NosuggP54nwA==", + data: { + metadata: { name: "Alias", note: "", itemUuid: "576f14fa" }, + extraFields: [], + type: "alias", + content: {}, + }, + state: 1, + aliasEmail: "alias.removing005@passinbox.com", + contentFormatVersion: 6, + createTime: 1725708208, + modifyTime: 1725708208, + pinned: false, + }, ], }, REDACTED_VAULT_ID_B: { diff --git a/libs/importer/src/importers/protonpass/protonpass-import-utils.spec.ts b/libs/importer/src/importers/protonpass/protonpass-import-utils.spec.ts new file mode 100644 index 000000000000..efa91eb98f4d --- /dev/null +++ b/libs/importer/src/importers/protonpass/protonpass-import-utils.spec.ts @@ -0,0 +1,66 @@ +import { processNames } from "./protonpass-import-utils"; + +describe("processNames", () => { + it("should use only fullName to map names if it contains at least three words, ignoring individual name fields", () => { + const result = processNames("Alice Beth Carter", "Kevin", "", ""); + expect(result).toEqual({ + mappedFirstName: "Alice", + mappedMiddleName: "Beth", + mappedLastName: "Carter", + }); + }); + + it("should map extra words to the middle name if fullName contains more than three words", () => { + const result = processNames("Alice Beth Middle Carter", "", "", ""); + expect(result).toEqual({ + mappedFirstName: "Alice", + mappedMiddleName: "Beth Middle", + mappedLastName: "Carter", + }); + }); + + it("should map names correctly even if fullName has words separated by more than one space", () => { + const result = processNames("Alice Carter", "", "", ""); + expect(result).toEqual({ + mappedFirstName: "Alice", + mappedMiddleName: "", + mappedLastName: "Carter", + }); + }); + + it("should handle a single name in fullName and use middleName and lastName to populate rest of names", () => { + const result = processNames("Alice", "", "Beth", "Carter"); + expect(result).toEqual({ + mappedFirstName: "Alice", + mappedMiddleName: "Beth", + mappedLastName: "Carter", + }); + }); + + it("should correctly map fullName when it only contains two words", () => { + const result = processNames("Alice Carter", "", "", ""); + expect(result).toEqual({ + mappedFirstName: "Alice", + mappedMiddleName: "", + mappedLastName: "Carter", + }); + }); + + it("should map middle name from middleName if fullName only contains two words", () => { + const result = processNames("Alice Carter", "", "Beth", ""); + expect(result).toEqual({ + mappedFirstName: "Alice", + mappedMiddleName: "Beth", + mappedLastName: "Carter", + }); + }); + + it("should fall back to firstName, middleName, and lastName if fullName is empty", () => { + const result = processNames("", "Alice", "Beth", "Carter"); + expect(result).toEqual({ + mappedFirstName: "Alice", + mappedMiddleName: "Beth", + mappedLastName: "Carter", + }); + }); +}); diff --git a/libs/importer/src/importers/protonpass/protonpass-import-utils.ts b/libs/importer/src/importers/protonpass/protonpass-import-utils.ts new file mode 100644 index 000000000000..d8e0a096ab71 --- /dev/null +++ b/libs/importer/src/importers/protonpass/protonpass-import-utils.ts @@ -0,0 +1,21 @@ +export function processNames( + fullname: string | null, + firstname: string | null, + middlename: string | null, + lastname: string | null, +) { + let mappedFirstName = firstname; + let mappedMiddleName = middlename; + let mappedLastName = lastname; + + if (fullname) { + const parts = fullname.trim().split(/\s+/); + + // Assign parts to first, middle, and last name based on the number of parts + mappedFirstName = parts[0] || firstname; + mappedLastName = parts.length > 1 ? parts[parts.length - 1] : lastname; + mappedMiddleName = parts.length > 2 ? parts.slice(1, -1).join(" ") : middlename; + } + + return { mappedFirstName, mappedMiddleName, mappedLastName }; +} diff --git a/libs/importer/src/importers/protonpass/protonpass-json-importer.ts b/libs/importer/src/importers/protonpass/protonpass-json-importer.ts index b8f6bc170c27..94d21f8521c0 100644 --- a/libs/importer/src/importers/protonpass/protonpass-json-importer.ts +++ b/libs/importer/src/importers/protonpass/protonpass-json-importer.ts @@ -1,24 +1,110 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { FieldType, SecureNoteType, CipherType } from "@bitwarden/common/vault/enums"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; import { ImportResult } from "../../models/import-result"; import { BaseImporter } from "../base-importer"; import { Importer } from "../importer"; +import { processNames } from "./protonpass-import-utils"; import { ProtonPassCreditCardItemContent, + ProtonPassIdentityItemContent, + ProtonPassIdentityItemExtraSection, + ProtonPassItemExtraField, ProtonPassItemState, ProtonPassJsonFile, ProtonPassLoginItemContent, } from "./types/protonpass-json-type"; export class ProtonPassJsonImporter extends BaseImporter implements Importer { + private mappedIdentityItemKeys = [ + "fullName", + "firstName", + "middleName", + "lastName", + "email", + "phoneNumber", + "company", + "socialSecurityNumber", + "passportNumber", + "licenseNumber", + "organization", + "streetAddress", + "floor", + "county", + "city", + "stateOrProvince", + "zipOrPostalCode", + "countryOrRegion", + ]; + + private identityItemExtraFieldsKeys = [ + "extraPersonalDetails", + "extraAddressDetails", + "extraContactDetails", + "extraWorkDetails", + "extraSections", + ]; + constructor(private i18nService: I18nService) { super(); } + private processIdentityItemUnmappedAndExtraFields( + cipher: CipherView, + identityItem: ProtonPassIdentityItemContent, + ) { + Object.keys(identityItem).forEach((key) => { + if ( + !this.mappedIdentityItemKeys.includes(key) && + !this.identityItemExtraFieldsKeys.includes(key) + ) { + this.processKvp( + cipher, + key, + identityItem[key as keyof ProtonPassIdentityItemContent] as string, + ); + return; + } + + if (this.identityItemExtraFieldsKeys.includes(key)) { + if (key !== "extraSections") { + const extraFields = identityItem[ + key as keyof ProtonPassIdentityItemContent + ] as ProtonPassItemExtraField[]; + + extraFields?.forEach((extraField) => { + this.processKvp( + cipher, + extraField.fieldName, + extraField.data.content, + extraField.type === "hidden" ? FieldType.Hidden : FieldType.Text, + ); + }); + } else { + const extraSections = identityItem[ + key as keyof ProtonPassIdentityItemContent + ] as ProtonPassIdentityItemExtraSection[]; + + extraSections?.forEach((extraSection) => { + extraSection.sectionFields?.forEach((extraField) => { + this.processKvp( + cipher, + extraField.fieldName, + extraField.data.content, + extraField.type === "hidden" ? FieldType.Hidden : FieldType.Text, + ); + }); + }); + } + } + }); + } + parse(data: string): Promise { const result = new ImportResult(); const results: ProtonPassJsonFile = JSON.parse(data); @@ -38,7 +124,6 @@ export class ProtonPassJsonImporter extends BaseImporter implements Importer { if (item.state == ProtonPassItemState.TRASHED) { continue; } - this.processFolder(result, vault.name); const cipher = this.initLoginCipher(); cipher.name = this.getValueOrDefault(item.data.metadata.name, "--"); @@ -96,8 +181,55 @@ export class ProtonPassJsonImporter extends BaseImporter implements Importer { break; } + case "identity": { + const identityContent = item.data.content as ProtonPassIdentityItemContent; + cipher.type = CipherType.Identity; + cipher.identity = new IdentityView(); + + const { mappedFirstName, mappedMiddleName, mappedLastName } = processNames( + this.getValueOrDefault(identityContent.fullName), + this.getValueOrDefault(identityContent.firstName), + this.getValueOrDefault(identityContent.middleName), + this.getValueOrDefault(identityContent.lastName), + ); + cipher.identity.firstName = mappedFirstName; + cipher.identity.middleName = mappedMiddleName; + cipher.identity.lastName = mappedLastName; + + cipher.identity.email = this.getValueOrDefault(identityContent.email); + cipher.identity.phone = this.getValueOrDefault(identityContent.phoneNumber); + cipher.identity.company = this.getValueOrDefault(identityContent.company); + cipher.identity.ssn = this.getValueOrDefault(identityContent.socialSecurityNumber); + cipher.identity.passportNumber = this.getValueOrDefault(identityContent.passportNumber); + cipher.identity.licenseNumber = this.getValueOrDefault(identityContent.licenseNumber); + + const address3 = + `${identityContent.floor ?? ""} ${identityContent.county ?? ""}`.trim(); + cipher.identity.address1 = this.getValueOrDefault(identityContent.organization); + cipher.identity.address2 = this.getValueOrDefault(identityContent.streetAddress); + cipher.identity.address3 = this.getValueOrDefault(address3); + + cipher.identity.city = this.getValueOrDefault(identityContent.city); + cipher.identity.state = this.getValueOrDefault(identityContent.stateOrProvince); + cipher.identity.postalCode = this.getValueOrDefault(identityContent.zipOrPostalCode); + cipher.identity.country = this.getValueOrDefault(identityContent.countryOrRegion); + this.processIdentityItemUnmappedAndExtraFields(cipher, identityContent); + + for (const extraField of item.data.extraFields) { + this.processKvp( + cipher, + extraField.fieldName, + extraField.data.content, + extraField.type === "hidden" ? FieldType.Hidden : FieldType.Text, + ); + } + break; + } + default: + continue; } + this.processFolder(result, vault.name); this.cleanupCipher(cipher); result.ciphers.push(cipher); } diff --git a/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts b/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts index eb3b4bba5ac5..20fa314a3144 100644 --- a/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts +++ b/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts @@ -36,8 +36,11 @@ export type ProtonPassItemData = { metadata: ProtonPassItemMetadata; extraFields: ProtonPassItemExtraField[]; platformSpecific?: any; - type: "login" | "alias" | "creditCard" | "note"; - content: ProtonPassLoginItemContent | ProtonPassCreditCardItemContent; + type: "login" | "alias" | "creditCard" | "note" | "identity"; + content: + | ProtonPassLoginItemContent + | ProtonPassCreditCardItemContent + | ProtonPassIdentityItemContent; }; export type ProtonPassItemMetadata = { @@ -74,3 +77,48 @@ export type ProtonPassCreditCardItemContent = { expirationDate?: string; pin?: string; }; + +export type ProtonPassIdentityItemExtraSection = { + sectionName?: string; + sectionFields?: ProtonPassItemExtraField[]; +}; + +export type ProtonPassIdentityItemContent = { + fullName?: string; + email?: string; + phoneNumber?: string; + firstName?: string; + middleName?: string; + lastName?: string; + birthdate?: string; + gender?: string; + extraPersonalDetails?: ProtonPassItemExtraField[]; + organization?: string; + streetAddress?: string; + zipOrPostalCode?: string; + city?: string; + stateOrProvince?: string; + countryOrRegion?: string; + floor?: string; + county?: string; + extraAddressDetails?: ProtonPassItemExtraField[]; + socialSecurityNumber?: string; + passportNumber?: string; + licenseNumber?: string; + website?: string; + xHandle?: string; + secondPhoneNumber?: string; + linkedin?: string; + reddit?: string; + facebook?: string; + yahoo?: string; + instagram?: string; + extraContactDetails?: ProtonPassItemExtraField[]; + company?: string; + jobTitle?: string; + personalWebsite?: string; + workPhoneNumber?: string; + workEmail?: string; + extraWorkDetails?: ProtonPassItemExtraField[]; + extraSections?: ProtonPassIdentityItemExtraSection[]; +};