diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index b29bf8ea33c9..f0c111f6748c 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1976,12 +1976,48 @@ "brand": { "message": "Brand" }, + "birthDay": { + "message": "Birth day" + }, + "birthMonth": { + "message": "Birth month" + }, + "birthYear": { + "message": "Birth year" + }, + "expirationDay": { + "message": "Expiration day" + }, "expirationMonth": { "message": "Expiration month" }, "expirationYear": { "message": "Expiration year" }, + "issueDay": { + "message": "Issue day" + }, + "issueMonth": { + "message": "Issue month" + }, + "issueYear": { + "message": "Issue year" + }, + "enterMonth": { + "message": "Enter month." + }, + "enterDay": { + "message": "Enter day." + }, + "enterYear": { + "message": "Enter year." + }, + "invalidDay": { + "message": "Enter a valid day." + }, + "invalidYear": { + "message": "Enter a valid year." + }, "monthly": { "message": "month" }, @@ -2075,6 +2111,51 @@ "passportNumber": { "message": "Passport number" }, + "dateOfBirth": { + "message": "Date of birth" + }, + "sex": { + "message": "Sex" + }, + "copySex": { + "message": "Copy sex" + }, + "birthPlace": { + "message": "Birth place" + }, + "copyBirthPlace": { + "message": "Copy birth place" + }, + "nationality": { + "message": "Nationality" + }, + "copyNationality": { + "message": "Copy nationality" + }, + "issuingCountry": { + "message": "Issuing country" + }, + "copyIssuingCountry": { + "message": "Copy issuing country" + }, + "passportType": { + "message": "Passport type" + }, + "copyPassportType": { + "message": "Copy passport type" + }, + "nationalIdentificationNumber": { + "message": "National Identification number" + }, + "issuingAuthority": { + "message": "Issuing authority / office" + }, + "copyIssuingAuthority": { + "message": "Copy issuing authority / office" + }, + "issueDate": { + "message": "Issue date" + }, "licenseNumber": { "message": "License number" }, @@ -2165,6 +2246,12 @@ "typeBankAccountSubtitle": { "message": "Banking details" }, + "typePassport": { + "message": "Passport" + }, + "typePassportSubtitle": { + "message": "Travel document" + }, "newItemHeaderLogin": { "message": "New Login", "description": "Header for new login item type" @@ -5003,6 +5090,12 @@ "copyAddress": { "message": "Copy address" }, + "copyFirstName": { + "message": "Copy first name" + }, + "copyLastName": { + "message": "Copy last name" + }, "myItems": { "message": "My Items" }, @@ -6387,6 +6480,9 @@ "searchBankAccount": { "message": "Search bank account" }, + "searchPassport": { + "message": "Search passports" + }, "copyAccountNumber": { "message": "Copy account number" }, diff --git a/apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts b/apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts index fc0d1eb29036..09e1aa76e070 100644 --- a/apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts @@ -93,6 +93,7 @@ describe("ViewComponent", () => { permissions: {}, card: {}, bankAccount: {}, + passport: {}, } as unknown as CipherView; const mockPasswordRepromptService = { @@ -307,6 +308,13 @@ describe("ViewComponent", () => { flush(); // Resolve all promises expect(component.headerText).toEqual("viewItemHeaderNote"); + + // Set header text for a passport + mockCipher.type = CipherType.Passport; + params$.next({ cipherId: mockCipher.id }); + flush(); // Resolve all promises + + expect(component.headerText).toEqual("viewItemHeaderPassport"); })); it("sends viewed event", fakeAsync(() => { diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index 6c7982c286cc..7ff140a331f0 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -216,6 +216,7 @@ describe("VaultPopupListFiltersService", () => { CipherType.Login, CipherType.Card, CipherType.BankAccount, + CipherType.Passport, CipherType.Identity, CipherType.SecureNote, CipherType.SshKey, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 43b26273ef21..86947a8161db 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -56,6 +56,12 @@ "typeBankAccountSubtitle": { "message": "Banking details" }, + "typePassport": { + "message": "Passport" + }, + "typePassportSubtitle": { + "message": "Travel document" + }, "folderSubtitle": { "message": "Organize your items" }, @@ -373,6 +379,51 @@ "passportNumber": { "message": "Passport number" }, + "dateOfBirth": { + "message": "Date of birth" + }, + "sex": { + "message": "Sex" + }, + "copySex": { + "message": "Copy sex" + }, + "birthPlace": { + "message": "Birth place" + }, + "copyBirthPlace": { + "message": "Copy birth place" + }, + "nationality": { + "message": "Nationality" + }, + "copyNationality": { + "message": "Copy nationality" + }, + "issuingCountry": { + "message": "Issuing country" + }, + "copyIssuingCountry": { + "message": "Copy issuing country" + }, + "passportType": { + "message": "Passport type" + }, + "copyPassportType": { + "message": "Copy passport type" + }, + "nationalIdentificationNumber": { + "message": "National Identification number" + }, + "issuingAuthority": { + "message": "Issuing authority / office" + }, + "copyIssuingAuthority": { + "message": "Copy issuing authority / office" + }, + "issueDate": { + "message": "Issue date" + }, "licenseNumber": { "message": "License number" }, @@ -560,12 +611,48 @@ "dr": { "message": "Dr" }, + "birthDay": { + "message": "Birth day" + }, + "birthMonth": { + "message": "Birth month" + }, + "birthYear": { + "message": "Birth year" + }, + "expirationDay": { + "message": "Expiration day" + }, "expirationMonth": { "message": "Expiration month" }, "expirationYear": { "message": "Expiration year" }, + "issueDay": { + "message": "Issue day" + }, + "issueMonth": { + "message": "Issue month" + }, + "issueYear": { + "message": "Issue year" + }, + "enterMonth": { + "message": "Enter month." + }, + "enterDay": { + "message": "Enter day." + }, + "enterYear": { + "message": "Enter year." + }, + "invalidDay": { + "message": "Enter a valid day." + }, + "invalidYear": { + "message": "Enter a valid year." + }, "select": { "message": "Select" }, @@ -1739,6 +1826,12 @@ "copyAddress": { "message": "Copy address" }, + "copyFirstName": { + "message": "Copy first name" + }, + "copyLastName": { + "message": "Copy last name" + }, "copyPhone": { "message": "Copy phone" }, @@ -5025,6 +5118,9 @@ "searchBankAccount": { "message": "Search bank account" }, + "searchPassport": { + "message": "Search passports" + }, "copyAccountNumber": { "message": "Copy account number" }, diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index 1e868a549028..177e6bb14083 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -89,6 +89,12 @@ export class VaultFilterComponent { type: CipherType.Card, icon: "bwi-credit-card", }, + { + id: "passport", + name: this.i18nService.t("typePassport"), + type: CipherType.Passport, + icon: "bwi-globe", + }, { id: "identity", name: this.i18nService.t("typeIdentity"), @@ -270,6 +276,7 @@ export class VaultFilterComponent { const excludeTypes: CipherStatus[] = ["favorites"]; if (!newTypesEnabled) { excludeTypes.push(CipherType.BankAccount); + excludeTypes.push(CipherType.Passport); } const builderFilter = {} as VaultFilterList; diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts index 493231cc8914..c9c2e430a60b 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts @@ -93,6 +93,9 @@ export class EmergencyViewDialogComponent { case CipherType.BankAccount: this.title = this.i18nService.t("viewItemHeaderBankAccount"); break; + case CipherType.Passport: + this.title = this.i18nService.t("viewItemHeaderPassport"); + break; } } diff --git a/apps/web/src/app/dirt/event-logs/services/event.service.ts b/apps/web/src/app/dirt/event-logs/services/event.service.ts index 26c1b634bf0d..1d2a8e6178f0 100644 --- a/apps/web/src/app/dirt/event-logs/services/event.service.ts +++ b/apps/web/src/app/dirt/event-logs/services/event.service.ts @@ -229,6 +229,13 @@ export class EventService { this.getShortId(ev.cipherId), ); break; + case EventType.Cipher_ClientCopiedPassportNumber: + msg = this.i18nService.t("copiedPassportNumberItemId", this.formatCipherId(ev, options)); + humanReadableMsg = this.i18nService.t( + "copiedPassportNumberItemId", + this.getShortId(ev.cipherId), + ); + break; // Collection case EventType.Collection_Created: msg = this.i18nService.t("createdCollectionId", this.formatCollectionId(ev)); diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index d7d47d429a96..6ec4b1141353 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -235,6 +235,13 @@ } + @if (isPassportCipher) { + + } + @if (showMenuDivider) { } diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index 9a3ba36f894a..790e8b1900fb 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -375,6 +375,10 @@ export class VaultCipherRowComponent implements OnInit ); } + protected get isPassportCipher(): boolean { + return CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Passport && !this.isDeleted; + } + protected get isSecureNoteCipher() { return ( CipherViewLikeUtils.getType(this.cipher) === this.CipherType.SecureNote && @@ -398,13 +402,20 @@ export class VaultCipherRowComponent implements OnInit ); } + protected get hasPassportOptions(): boolean { + return ( + this.isPassportCipher && CipherViewLikeUtils.hasCopyableValue(this.cipher, "passportNumber") + ); + } + protected get showMenuDivider(): boolean { return ( this.hasVisibleLoginOptions || this.hasVisibleCardOptions || this.hasVisibleIdentityOptions || this.hasVisibleSecureNoteOptions || - this.hasBankAccountOptions + this.hasBankAccountOptions || + this.hasPassportOptions ); } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 55771df6c11b..7d14d42b0daf 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -99,6 +99,12 @@ export class VaultFilterComponent implements OnInit, OnDestroy { type: CipherType.Card, icon: "bwi-credit-card", }, + { + id: "passport", + name: this.i18nService.t("typePassport"), + type: CipherType.Passport, + icon: "bwi-globe", + }, { id: "identity", name: this.i18nService.t("typeIdentity"), @@ -153,6 +159,9 @@ export class VaultFilterComponent implements OnInit, OnDestroy { if (this.activeFilter.cipherType === CipherType.BankAccount) { return "searchBankAccount"; } + if (this.activeFilter.cipherType === CipherType.Passport) { + return "searchPassport"; + } if (this.activeFilter.selectedFolderNode?.node) { return "searchFolder"; } @@ -278,6 +287,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { const excludeTypes: CipherStatus[] = []; if (!newItemTypesEnabled) { excludeTypes.push(CipherType.BankAccount); + excludeTypes.push(CipherType.Passport); } const builderFilter = {} as VaultFilterList; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 5ea19394a5c9..032f64821c68 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -673,6 +673,39 @@ "passportNumber": { "message": "Passport number" }, + "dateOfBirth": { + "message": "Date of birth" + }, + "sex": { + "message": "Sex" + }, + "copySex": { + "message": "Copy sex" + }, + "birthPlace": { + "message": "Birth place" + }, + "nationality": { + "message": "Nationality" + }, + "copyNationality": { + "message": "Copy nationality" + }, + "issuingCountry": { + "message": "Issuing country" + }, + "passportType": { + "message": "Passport type" + }, + "nationalIdentificationNumber": { + "message": "National Identification number" + }, + "issuingAuthority": { + "message": "Issuing authority / office" + }, + "issueDate": { + "message": "Issue date" + }, "licenseNumber": { "message": "License number" }, @@ -745,12 +778,33 @@ "cardExpiredMessage": { "message": "If you've renewed it, update the card's information" }, + "birthDay": { + "message": "Birth day" + }, + "birthMonth": { + "message": "Birth month" + }, + "birthYear": { + "message": "Birth year" + }, + "expirationDay": { + "message": "Expiration day" + }, "expirationMonth": { "message": "Expiration month" }, "expirationYear": { "message": "Expiration year" }, + "issueDay": { + "message": "Issue day" + }, + "issueMonth": { + "message": "Issue month" + }, + "issueYear": { + "message": "Issue year" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, @@ -1015,6 +1069,12 @@ "typeBankAccountSubtitle": { "message": "Banking details" }, + "typePassport": { + "message": "Passport" + }, + "typePassportSubtitle": { + "message": "Travel document" + }, "folderSubtitle": { "message": "Organize your items" }, @@ -1302,6 +1362,9 @@ "copyAddress": { "message": "Copy address" }, + "copyBirthPlace": { + "message": "Copy birth place" + }, "copyPhone": { "message": "Copy phone" }, @@ -1317,6 +1380,15 @@ "copyPassportNumber": { "message": "Copy passport number" }, + "copyPassportType": { + "message": "Copy passport type" + }, + "copyFirstName": { + "message": "Copy first name" + }, + "copyLastName": { + "message": "Copy last name" + }, "copyLicenseNumber": { "message": "Copy license number" }, @@ -1329,6 +1401,12 @@ "copyFingerprint": { "message": "Copy fingerprint" }, + "copyIssuingAuthority": { + "message": "Copy issuing authority / office" + }, + "copyIssuingCountry": { + "message": "Copy issuing country" + }, "copyName": { "message": "Copy name" }, @@ -4400,6 +4478,15 @@ } } }, + "copiedPassportNumberItemId": { + "message": "Copied passport number for item $ID$.", + "placeholders": { + "id": { + "content": "$1", + "example": "Google" + } + } + }, "autofilledItemId": { "message": "Auto-filled item $ID$.", "placeholders": { @@ -8020,6 +8107,21 @@ "required": { "message": "required" }, + "enterMonth": { + "message": "Enter month." + }, + "enterDay": { + "message": "Enter day." + }, + "enterYear": { + "message": "Enter year." + }, + "invalidDay": { + "message": "Enter a valid day." + }, + "invalidYear": { + "message": "Enter a valid year." + }, "charactersCurrentAndMaximum": { "message": "$CURRENT$/$MAX$ character maximum", "placeholders": { @@ -13571,6 +13673,9 @@ "searchBankAccount": { "message": "Search bank account" }, + "searchPassport": { + "message": "Search passports" + }, "copyAccountNumber": { "message": "Copy account number" }, diff --git a/libs/common/src/dirt/event-logs/enums/event-category.enum.ts b/libs/common/src/dirt/event-logs/enums/event-category.enum.ts index f7af8044dc23..88a7de47fcec 100644 --- a/libs/common/src/dirt/event-logs/enums/event-category.enum.ts +++ b/libs/common/src/dirt/event-logs/enums/event-category.enum.ts @@ -51,6 +51,7 @@ export const EventCategoryEventTypes: Record = { EventType.Cipher_ClientCopiedBankAccountPin, EventType.Cipher_ClientToggledBankAccountNumberVisible, EventType.Cipher_ClientToggledBankAccountPinVisible, + EventType.Cipher_ClientCopiedPassportNumber, ], [EventCategory.CollectionEvents]: [ EventType.Collection_Created, diff --git a/libs/common/src/dirt/event-logs/enums/event-type.enum.ts b/libs/common/src/dirt/event-logs/enums/event-type.enum.ts index cd394e27bfd7..043e073a4bb4 100644 --- a/libs/common/src/dirt/event-logs/enums/event-type.enum.ts +++ b/libs/common/src/dirt/event-logs/enums/event-type.enum.ts @@ -38,6 +38,7 @@ export enum EventType { Cipher_ClientCopiedBankAccountPin = 1120, Cipher_ClientToggledBankAccountNumberVisible = 1121, Cipher_ClientToggledBankAccountPinVisible = 1122, + Cipher_ClientCopiedPassportNumber = 1123, Collection_Created = 1300, Collection_Updated = 1301, diff --git a/libs/common/src/vault/icon/build-cipher-icon.spec.ts b/libs/common/src/vault/icon/build-cipher-icon.spec.ts index fcd56ecd708a..9413a90e67df 100644 --- a/libs/common/src/vault/icon/build-cipher-icon.spec.ts +++ b/libs/common/src/vault/icon/build-cipher-icon.spec.ts @@ -167,4 +167,32 @@ describe("buildCipherIcon", () => { }); }); }); + + describe("Passport cipher", () => { + const cipher = { + type: CipherType.Passport, + } as any as CipherView; + + it("returns bwi-globe icon", () => { + const iconDetails = buildCipherIcon(iconServerUrl, cipher, true); + + expect(iconDetails).toEqual({ + icon: "bwi-globe", + image: null, + fallbackImage: "", + imageEnabled: true, + }); + }); + + it("returns bwi-globe icon when showFavicon is false", () => { + const iconDetails = buildCipherIcon(iconServerUrl, cipher, false); + + expect(iconDetails).toEqual({ + icon: "bwi-globe", + image: null, + fallbackImage: "", + imageEnabled: false, + }); + }); + }); }); diff --git a/libs/common/src/vault/icon/build-cipher-icon.ts b/libs/common/src/vault/icon/build-cipher-icon.ts index 655644c7ced9..98bc378ae4de 100644 --- a/libs/common/src/vault/icon/build-cipher-icon.ts +++ b/libs/common/src/vault/icon/build-cipher-icon.ts @@ -103,6 +103,9 @@ export function buildCipherIcon( case CipherType.BankAccount: icon = "bwi-bank"; break; + case CipherType.Passport: + icon = "bwi-globe"; + break; default: break; } diff --git a/libs/common/src/vault/models/api/passport.api.ts b/libs/common/src/vault/models/api/passport.api.ts new file mode 100644 index 000000000000..3932d05b1172 --- /dev/null +++ b/libs/common/src/vault/models/api/passport.api.ts @@ -0,0 +1,37 @@ +import { BaseResponse } from "../../../models/response/base.response"; + +export class PassportApi extends BaseResponse { + surname: string | undefined = undefined; + givenName: string | undefined = undefined; + dateOfBirth: string | undefined = undefined; + sex: string | undefined = undefined; + birthPlace: string | undefined = undefined; + nationality: string | undefined = undefined; + issuingCountry: string | undefined = undefined; + passportNumber: string | undefined = undefined; + passportType: string | undefined = undefined; + nationalIdentificationNumber: string | undefined = undefined; + issuingAuthority: string | undefined = undefined; + issueDate: string | undefined = undefined; + expirationDate: string | undefined = undefined; + + constructor(data: any = null) { + super(data); + if (data == null) { + return; + } + this.surname = this.getResponseProperty("Surname"); + this.givenName = this.getResponseProperty("GivenName"); + this.dateOfBirth = this.getResponseProperty("DateOfBirth"); + this.sex = this.getResponseProperty("Sex"); + this.birthPlace = this.getResponseProperty("BirthPlace"); + this.nationality = this.getResponseProperty("Nationality"); + this.issuingCountry = this.getResponseProperty("IssuingCountry"); + this.passportNumber = this.getResponseProperty("PassportNumber"); + this.passportType = this.getResponseProperty("PassportType"); + this.nationalIdentificationNumber = this.getResponseProperty("NationalIdentificationNumber"); + this.issuingAuthority = this.getResponseProperty("IssuingAuthority"); + this.issueDate = this.getResponseProperty("IssueDate"); + this.expirationDate = this.getResponseProperty("ExpirationDate"); + } +} diff --git a/libs/common/src/vault/models/data/cipher.data.ts b/libs/common/src/vault/models/data/cipher.data.ts index f83b304739f9..33ae544f42df 100644 --- a/libs/common/src/vault/models/data/cipher.data.ts +++ b/libs/common/src/vault/models/data/cipher.data.ts @@ -11,6 +11,7 @@ import { CardData } from "./card.data"; import { FieldData } from "./field.data"; import { IdentityData } from "./identity.data"; import { LoginData } from "./login.data"; +import { PassportData } from "./passport.data"; import { PasswordHistoryData } from "./password-history.data"; import { SecureNoteData } from "./secure-note.data"; import { SshKeyData } from "./ssh-key.data"; @@ -34,6 +35,7 @@ export class CipherData { identity?: IdentityData; sshKey?: SshKeyData; bankAccount?: BankAccountData; + passport?: PassportData; fields?: FieldData[]; attachments?: AttachmentData[]; passwordHistory?: PasswordHistoryData[]; @@ -88,6 +90,9 @@ export class CipherData { case CipherType.BankAccount: this.bankAccount = new BankAccountData(response.bankAccount); break; + case CipherType.Passport: + this.passport = new PassportData(response.passport); + break; default: break; } diff --git a/libs/common/src/vault/models/data/passport.data.ts b/libs/common/src/vault/models/data/passport.data.ts new file mode 100644 index 000000000000..7c244cb293e8 --- /dev/null +++ b/libs/common/src/vault/models/data/passport.data.ts @@ -0,0 +1,37 @@ +import { PassportApi } from "../api/passport.api"; + +export class PassportData { + surname?: string; + givenName?: string; + dateOfBirth?: string; + sex?: string; + birthPlace?: string; + nationality?: string; + issuingCountry?: string; + passportNumber?: string; + passportType?: string; + nationalIdentificationNumber?: string; + issuingAuthority?: string; + issueDate?: string; + expirationDate?: string; + + constructor(data?: PassportApi) { + if (data == null) { + return; + } + + this.surname = data.surname; + this.givenName = data.givenName; + this.dateOfBirth = data.dateOfBirth; + this.sex = data.sex; + this.birthPlace = data.birthPlace; + this.nationality = data.nationality; + this.issuingCountry = data.issuingCountry; + this.passportNumber = data.passportNumber; + this.passportType = data.passportType; + this.nationalIdentificationNumber = data.nationalIdentificationNumber; + this.issuingAuthority = data.issuingAuthority; + this.issueDate = data.issueDate; + this.expirationDate = data.expirationDate; + } +} diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index 1b6cd72f1e91..b42c4c3adf5c 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -30,6 +30,7 @@ import { Card } from "./card"; import { Field } from "./field"; import { Identity } from "./identity"; import { Login } from "./login"; +import { Passport } from "./passport"; import { Password } from "./password"; import { SecureNote } from "./secure-note"; import { SshKey } from "./ssh-key"; @@ -56,6 +57,7 @@ export class Cipher extends Domain implements Decryptable { secureNote?: SecureNote; sshKey?: SshKey; bankAccount?: BankAccount; + passport?: Passport; attachments?: Attachment[]; fields?: Field[]; passwordHistory?: Password[]; @@ -112,6 +114,9 @@ export class Cipher extends Domain implements Decryptable { case CipherType.BankAccount: this.bankAccount = new BankAccount(obj.bankAccount); break; + case CipherType.Passport: + this.passport = new Passport(obj.passport); + break; default: break; } @@ -196,6 +201,14 @@ export class Cipher extends Domain implements Decryptable { ); } break; + case CipherType.Passport: + if (this.passport != null) { + model.passport = await this.passport.decrypt( + cipherDecryptionKey, + `Cipher Id: ${this.id}`, + ); + } + break; default: break; } @@ -300,6 +313,11 @@ export class Cipher extends Domain implements Decryptable { c.bankAccount = this.bankAccount.toBankAccountData(); } break; + case CipherType.Passport: + if (this.passport != null) { + c.passport = this.passport.toPassportData(); + } + break; default: break; } @@ -387,6 +405,11 @@ export class Cipher extends Domain implements Decryptable { domain.bankAccount = BankAccount.fromJSON(obj.bankAccount); } break; + case CipherType.Passport: + if (obj.passport != null) { + domain.passport = Passport.fromJSON(obj.passport); + } + break; default: break; } @@ -471,6 +494,11 @@ export class Cipher extends Domain implements Decryptable { sdkCipher.bankAccount = this.bankAccount.toSdkBankAccount(); } break; + case CipherType.Passport: + if (this.passport != null) { + sdkCipher.passport = this.passport.toSdkPassport(); + } + break; default: break; } @@ -527,6 +555,7 @@ export class Cipher extends Domain implements Decryptable { cipher.identity = Identity.fromSdkIdentity(sdkCipher.identity); cipher.sshKey = SshKey.fromSdkSshKey(sdkCipher.sshKey); cipher.bankAccount = BankAccount.fromSdkBankAccount(sdkCipher.bankAccount); + cipher.passport = Passport.fromSdkPassport(sdkCipher.passport); return cipher; } diff --git a/libs/common/src/vault/models/domain/passport.ts b/libs/common/src/vault/models/domain/passport.ts new file mode 100644 index 000000000000..adcb055f05ed --- /dev/null +++ b/libs/common/src/vault/models/domain/passport.ts @@ -0,0 +1,155 @@ +import { Jsonify } from "type-fest"; + +import { Passport as SdkPassport } from "@bitwarden/sdk-internal"; + +import { EncString } from "../../../key-management/crypto/models/enc-string"; +import Domain from "../../../platform/models/domain/domain-base"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { conditionalEncString, encStringFrom } from "../../utils/domain-utils"; +import { PassportData } from "../data/passport.data"; +import { PassportView } from "../view/passport.view"; + +export class Passport extends Domain { + surname?: EncString; + givenName?: EncString; + dateOfBirth?: EncString; + sex?: EncString; + birthPlace?: EncString; + nationality?: EncString; + issuingCountry?: EncString; + passportNumber?: EncString; + passportType?: EncString; + nationalIdentificationNumber?: EncString; + issuingAuthority?: EncString; + issueDate?: EncString; + expirationDate?: EncString; + + constructor(obj?: PassportData) { + super(); + if (obj == null) { + return; + } + + this.surname = conditionalEncString(obj.surname); + this.givenName = conditionalEncString(obj.givenName); + this.dateOfBirth = conditionalEncString(obj.dateOfBirth); + this.sex = conditionalEncString(obj.sex); + this.birthPlace = conditionalEncString(obj.birthPlace); + this.nationality = conditionalEncString(obj.nationality); + this.issuingCountry = conditionalEncString(obj.issuingCountry); + this.passportNumber = conditionalEncString(obj.passportNumber); + this.passportType = conditionalEncString(obj.passportType); + this.nationalIdentificationNumber = conditionalEncString(obj.nationalIdentificationNumber); + this.issuingAuthority = conditionalEncString(obj.issuingAuthority); + this.issueDate = conditionalEncString(obj.issueDate); + this.expirationDate = conditionalEncString(obj.expirationDate); + } + + decrypt(encKey: SymmetricCryptoKey, context = "No Cipher Context"): Promise { + return this.decryptObj( + this, + new PassportView(), + [ + "surname", + "givenName", + "dateOfBirth", + "sex", + "birthPlace", + "nationality", + "issuingCountry", + "passportNumber", + "passportType", + "nationalIdentificationNumber", + "issuingAuthority", + "issueDate", + "expirationDate", + ], + encKey, + "DomainType: Passport; " + context, + ); + } + + toPassportData(): PassportData { + const c = new PassportData(); + this.buildDataModel(this, c, { + surname: null, + givenName: null, + dateOfBirth: null, + sex: null, + birthPlace: null, + nationality: null, + issuingCountry: null, + passportNumber: null, + passportType: null, + nationalIdentificationNumber: null, + issuingAuthority: null, + issueDate: null, + expirationDate: null, + }); + return c; + } + + static fromJSON(obj: Jsonify | undefined): Passport | undefined { + if (obj == null) { + return undefined; + } + + const passport = new Passport(); + passport.surname = encStringFrom(obj.surname); + passport.givenName = encStringFrom(obj.givenName); + passport.dateOfBirth = encStringFrom(obj.dateOfBirth); + passport.sex = encStringFrom(obj.sex); + passport.birthPlace = encStringFrom(obj.birthPlace); + passport.nationality = encStringFrom(obj.nationality); + passport.issuingCountry = encStringFrom(obj.issuingCountry); + passport.passportNumber = encStringFrom(obj.passportNumber); + passport.passportType = encStringFrom(obj.passportType); + passport.nationalIdentificationNumber = encStringFrom(obj.nationalIdentificationNumber); + passport.issuingAuthority = encStringFrom(obj.issuingAuthority); + passport.issueDate = encStringFrom(obj.issueDate); + passport.expirationDate = encStringFrom(obj.expirationDate); + + return passport; + } + + toSdkPassport(): SdkPassport { + return { + surname: this.surname?.toSdk(), + givenName: this.givenName?.toSdk(), + dateOfBirth: this.dateOfBirth?.toSdk(), + sex: this.sex?.toSdk(), + birthPlace: this.birthPlace?.toSdk(), + nationality: this.nationality?.toSdk(), + issuingCountry: this.issuingCountry?.toSdk(), + passportNumber: this.passportNumber?.toSdk(), + passportType: this.passportType?.toSdk(), + nationalIdentificationNumber: this.nationalIdentificationNumber?.toSdk(), + issuingAuthority: this.issuingAuthority?.toSdk(), + issueDate: this.issueDate?.toSdk(), + expirationDate: this.expirationDate?.toSdk(), + }; + } + + static fromSdkPassport(obj?: SdkPassport): Passport | undefined { + if (!obj) { + return undefined; + } + + const passport = new Passport(); + passport.surname = encStringFrom(obj.surname); + passport.givenName = encStringFrom(obj.givenName); + passport.dateOfBirth = encStringFrom(obj.dateOfBirth); + passport.sex = encStringFrom(obj.sex); + passport.birthPlace = encStringFrom(obj.birthPlace); + passport.nationality = encStringFrom(obj.nationality); + passport.issuingCountry = encStringFrom(obj.issuingCountry); + passport.passportNumber = encStringFrom(obj.passportNumber); + passport.passportType = encStringFrom(obj.passportType); + passport.nationalIdentificationNumber = encStringFrom(obj.nationalIdentificationNumber); + passport.issuingAuthority = encStringFrom(obj.issuingAuthority); + passport.issueDate = encStringFrom(obj.issueDate); + passport.expirationDate = encStringFrom(obj.expirationDate); + + return passport; + } +} diff --git a/libs/common/src/vault/models/request/cipher.request.ts b/libs/common/src/vault/models/request/cipher.request.ts index bd6627caabab..7f33ff4b2290 100644 --- a/libs/common/src/vault/models/request/cipher.request.ts +++ b/libs/common/src/vault/models/request/cipher.request.ts @@ -11,6 +11,7 @@ import { FieldApi } from "../api/field.api"; import { IdentityApi } from "../api/identity.api"; import { LoginUriApi } from "../api/login-uri.api"; import { LoginApi } from "../api/login.api"; +import { PassportApi } from "../api/passport.api"; import { SecureNoteApi } from "../api/secure-note.api"; import { SshKeyApi } from "../api/ssh-key.api"; @@ -31,6 +32,7 @@ export class CipherRequest { identity: IdentityApi; sshKey: SshKeyApi; bankAccount: BankAccountApi; + passport: PassportApi; fields: FieldApi[]; passwordHistory: PasswordHistoryRequest[]; // Deprecated, remove at some point and rename attachments2 to attachments @@ -207,6 +209,47 @@ export class CipherRequest { ? cipher.bankAccount.bankContactPhone.encryptedString : null; break; + case CipherType.Passport: + this.passport = new PassportApi(); + this.passport.surname = + cipher.passport.surname != null ? cipher.passport.surname.encryptedString : null; + this.passport.givenName = + cipher.passport.givenName != null ? cipher.passport.givenName.encryptedString : null; + this.passport.dateOfBirth = + cipher.passport.dateOfBirth != null ? cipher.passport.dateOfBirth.encryptedString : null; + this.passport.sex = + cipher.passport.sex != null ? cipher.passport.sex.encryptedString : null; + this.passport.birthPlace = + cipher.passport.birthPlace != null ? cipher.passport.birthPlace.encryptedString : null; + this.passport.nationality = + cipher.passport.nationality != null ? cipher.passport.nationality.encryptedString : null; + this.passport.issuingCountry = + cipher.passport.issuingCountry != null + ? cipher.passport.issuingCountry.encryptedString + : null; + this.passport.passportNumber = + cipher.passport.passportNumber != null + ? cipher.passport.passportNumber.encryptedString + : null; + this.passport.passportType = + cipher.passport.passportType != null + ? cipher.passport.passportType.encryptedString + : null; + this.passport.nationalIdentificationNumber = + cipher.passport.nationalIdentificationNumber != null + ? cipher.passport.nationalIdentificationNumber.encryptedString + : null; + this.passport.issuingAuthority = + cipher.passport.issuingAuthority != null + ? cipher.passport.issuingAuthority.encryptedString + : null; + this.passport.issueDate = + cipher.passport.issueDate != null ? cipher.passport.issueDate.encryptedString : null; + this.passport.expirationDate = + cipher.passport.expirationDate != null + ? cipher.passport.expirationDate.encryptedString + : null; + break; default: break; } diff --git a/libs/common/src/vault/models/response/cipher.response.ts b/libs/common/src/vault/models/response/cipher.response.ts index c409481e5fd2..f5a2d36c8f91 100644 --- a/libs/common/src/vault/models/response/cipher.response.ts +++ b/libs/common/src/vault/models/response/cipher.response.ts @@ -9,6 +9,7 @@ import { CipherPermissionsApi } from "../api/cipher-permissions.api"; import { FieldApi } from "../api/field.api"; import { IdentityApi } from "../api/identity.api"; import { LoginApi } from "../api/login.api"; +import { PassportApi } from "../api/passport.api"; import { SecureNoteApi } from "../api/secure-note.api"; import { SshKeyApi } from "../api/ssh-key.api"; @@ -34,6 +35,7 @@ export class CipherResponse extends BaseResponse { secureNote: SecureNoteApi; sshKey: SshKeyApi; bankAccount: BankAccountApi; + passport: PassportApi; favorite: boolean; edit: boolean; viewPassword: boolean; @@ -102,6 +104,11 @@ export class CipherResponse extends BaseResponse { this.bankAccount = new BankAccountApi(bankAccount); } + const passport = this.getResponseProperty("Passport"); + if (passport != null) { + this.passport = new PassportApi(passport); + } + const fields = this.getResponseProperty("Fields"); if (fields != null) { this.fields = fields.map((f: any) => new FieldApi(f)); diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index f388dd187397..2f617d74ab82 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -26,6 +26,7 @@ import { Fido2CredentialView } from "./fido2-credential.view"; import { FieldView } from "./field.view"; import { IdentityView } from "./identity.view"; import { LoginView } from "./login.view"; +import { PassportView } from "./passport.view"; import { PasswordHistoryView } from "./password-history.view"; import { SecureNoteView } from "./secure-note.view"; import { SshKeyView } from "./ssh-key.view"; @@ -51,6 +52,7 @@ export class CipherView implements View, InitializerMetadata { secureNote = new SecureNoteView(); sshKey = new SshKeyView(); bankAccount = new BankAccountView(); + passport = new PassportView(); attachments: AttachmentView[] = []; fields: FieldView[] = []; passwordHistory: PasswordHistoryView[] = []; @@ -274,6 +276,9 @@ export class CipherView implements View, InitializerMetadata { case CipherType.BankAccount: view.bankAccount = BankAccountView.fromJSON(obj.bankAccount); break; + case CipherType.Passport: + view.passport = PassportView.fromJSON(obj.passport); + break; default: break; } @@ -367,6 +372,11 @@ export class CipherView implements View, InitializerMetadata { ? BankAccountView.fromSdkBankAccountView(obj.bankAccount) : new BankAccountView(); break; + case CipherType.Passport: + cipherView.passport = obj.passport + ? PassportView.fromSdkPassportView(obj.passport) + : new PassportView(); + break; default: break; } @@ -460,6 +470,9 @@ export class CipherView implements View, InitializerMetadata { case CipherType.BankAccount: viewType = { bankAccount: this.bankAccount?.toSdkBankAccountView() }; break; + case CipherType.Passport: + viewType = { passport: this.passport?.toSdkPassportView() }; + break; default: viewType = { // Default to empty login - should not be valid code path. @@ -538,6 +551,9 @@ export class CipherView implements View, InitializerMetadata { case CipherType.BankAccount: sdkCipherView.bankAccount = this.bankAccount?.toSdkBankAccountView(); break; + case CipherType.Passport: + sdkCipherView.passport = this.passport?.toSdkPassportView(); + break; default: break; } diff --git a/libs/common/src/vault/models/view/passport.view.ts b/libs/common/src/vault/models/view/passport.view.ts new file mode 100644 index 000000000000..31ef739168f3 --- /dev/null +++ b/libs/common/src/vault/models/view/passport.view.ts @@ -0,0 +1,55 @@ +import { Jsonify } from "type-fest"; + +import { PassportView as SdkPassportView } from "@bitwarden/sdk-internal"; + +import { ItemView } from "./item.view"; + +export class PassportView extends ItemView implements SdkPassportView { + surname: string | undefined; + givenName: string | undefined; + dateOfBirth: string | undefined; + sex: string | undefined; + birthPlace: string | undefined; + nationality: string | undefined; + issuingCountry: string | undefined; + passportNumber: string | undefined; + passportType: string | undefined; + nationalIdentificationNumber: string | undefined; + issuingAuthority: string | undefined; + issueDate: string | undefined; + expirationDate: string | undefined; + + get subTitle(): string { + const name = [this.givenName, this.surname].filter(Boolean).join(" "); + const issuingCountry = this.issuingCountry; + return [name, issuingCountry].filter(Boolean).join(", "); + } + + static fromJSON(obj: Partial> | undefined): PassportView { + return Object.assign(new PassportView(), obj); + } + + static fromSdkPassportView(obj: SdkPassportView): PassportView { + const view = new PassportView(); + + view.surname = obj.surname; + view.givenName = obj.givenName; + view.dateOfBirth = obj.dateOfBirth; + view.sex = obj.sex; + view.birthPlace = obj.birthPlace; + view.nationality = obj.nationality; + view.issuingCountry = obj.issuingCountry; + view.passportNumber = obj.passportNumber; + view.passportType = obj.passportType; + view.nationalIdentificationNumber = obj.nationalIdentificationNumber; + view.issuingAuthority = obj.issuingAuthority; + view.issueDate = obj.issueDate; + view.expirationDate = obj.expirationDate; + + return view; + } + + toSdkPassportView(): SdkPassportView { + return this; + } +} diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index c561cfc61f27..a0786f008ad6 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -61,6 +61,7 @@ import { Field } from "../models/domain/field"; import { Identity } from "../models/domain/identity"; import { Login } from "../models/domain/login"; import { LoginUri } from "../models/domain/login-uri"; +import { Passport } from "../models/domain/passport"; import { Password } from "../models/domain/password"; import { SecureNote } from "../models/domain/secure-note"; import { SortedCiphersCache } from "../models/domain/sorted-ciphers-cache"; @@ -2116,6 +2117,29 @@ export class CipherService implements CipherServiceAbstraction { key, ); return; + case CipherType.Passport: + cipher.passport = new Passport(); + await this.encryptObjProperty( + model.passport, + cipher.passport, + { + surname: null, + givenName: null, + dateOfBirth: null, + sex: null, + birthPlace: null, + nationality: null, + issuingCountry: null, + passportNumber: null, + passportType: null, + nationalIdentificationNumber: null, + issuingAuthority: null, + issueDate: null, + expirationDate: null, + }, + key, + ); + return; default: throw new Error("Unknown cipher type."); } diff --git a/libs/common/src/vault/types/cipher-menu-items.ts b/libs/common/src/vault/types/cipher-menu-items.ts index 85518cbad187..82b8bb766f2b 100644 --- a/libs/common/src/vault/types/cipher-menu-items.ts +++ b/libs/common/src/vault/types/cipher-menu-items.ts @@ -57,6 +57,13 @@ const bankAccountItem: CipherMenuItem = { subtitleKey: "typeBankAccountSubtitle", }; +const passportItem: CipherMenuItem = { + type: CipherType.Passport, + icon: "bwi-globe", + labelKey: "typePassport", + subtitleKey: "typePassportSubtitle", +}; + /** * Updated menu items for new item dialog. This list should only be used * when `FeatureFlag.PM32009NewItemTypes` is enabled, otherwise use `CIPHER_MENU_ITEMS`. @@ -64,6 +71,7 @@ const bankAccountItem: CipherMenuItem = { export const DIALOG_CIPHER_MENU_ITEMS = [ ...CIPHER_MENU_ITEMS.slice(0, 2), bankAccountItem, + passportItem, ...CIPHER_MENU_ITEMS.slice(2), ].map((item) => { if (item.type === CipherType.Login) { diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.ts b/libs/common/src/vault/utils/cipher-view-like-utils.ts index 1d8803949e78..40fdd2204063 100644 --- a/libs/common/src/vault/utils/cipher-view-like-utils.ts +++ b/libs/common/src/vault/utils/cipher-view-like-utils.ts @@ -132,6 +132,8 @@ export class CipherViewLikeUtils { return CipherType.SshKey; case cipher.type === "bankAccount": return CipherType.BankAccount; + case cipher.type === "passport": + return CipherType.Passport; case cipher.type === "identity": return CipherType.Identity; case typeof cipher.type === "object" && "card" in cipher.type: @@ -290,6 +292,8 @@ export class CipherViewLikeUtils { return !!cipher.bankAccount?.pin; case "iban": return !!cipher.bankAccount?.iban; + case "passportNumber": + return !!cipher.passport?.passportNumber; default: return false; } @@ -397,6 +401,7 @@ const copyActionToCopyableFieldMap: Record = { routingNumber: "BankAccountRoutingNumber", pin: "BankAccountPin", iban: "BankAccountIban", + passportNumber: "PassportPassportNumber", }; /** Converts a `LoginListUriView` to a `LoginUriView`. */ diff --git a/libs/vault/src/cipher-form/cipher-form-container.ts b/libs/vault/src/cipher-form/cipher-form-container.ts index f5d74b2b8e43..028408a4d062 100644 --- a/libs/vault/src/cipher-form/cipher-form-container.ts +++ b/libs/vault/src/cipher-form/cipher-form-container.ts @@ -13,6 +13,7 @@ import { CustomFieldsComponent } from "./components/custom-fields/custom-fields. import { IdentitySectionComponent } from "./components/identity/identity.component"; import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component"; import { LoginDetailsSectionComponent } from "./components/login-details-section/login-details-section.component"; +import { PassportSectionComponent } from "./components/passport-section/passport-section.component"; import { SshKeySectionComponent } from "./components/sshkey-section/sshkey-section.component"; /** @@ -27,6 +28,7 @@ export type CipherForm = { identityDetails?: IdentitySectionComponent["identityForm"]; sshKeyDetails?: SshKeySectionComponent["sshKeyForm"]; bankAccountDetails?: BankAccountSectionComponent["bankAccountForm"]; + passportDetails?: PassportSectionComponent["passportForm"]; customFields?: CustomFieldsComponent["customFieldsForm"]; }; diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.html b/libs/vault/src/cipher-form/components/cipher-form.component.html index a5e104562854..4b83d0d181cf 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.html +++ b/libs/vault/src/cipher-form/components/cipher-form.component.html @@ -41,6 +41,13 @@ > } + @if (config.cipherType === CipherType.Passport) { + + } + diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.ts b/libs/vault/src/cipher-form/components/cipher-form.component.ts index 3e76766434c7..55e19f475ce2 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -47,6 +47,7 @@ import { IdentitySectionComponent } from "./identity/identity.component"; import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component"; import { LoginDetailsSectionComponent } from "./login-details-section/login-details-section.component"; import { NewItemNudgeComponent } from "./new-item-nudge/new-item-nudge.component"; +import { PassportSectionComponent } from "./passport-section/passport-section.component"; import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -75,6 +76,7 @@ import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.componen IdentitySectionComponent, SshKeySectionComponent, BankAccountSectionComponent, + PassportSectionComponent, AdditionalOptionsSectionComponent, LoginDetailsSectionComponent, NewItemNudgeComponent, diff --git a/libs/vault/src/cipher-form/components/date-field-group/date-field-group.component.html b/libs/vault/src/cipher-form/components/date-field-group/date-field-group.component.html new file mode 100644 index 000000000000..d80739072f09 --- /dev/null +++ b/libs/vault/src/cipher-form/components/date-field-group/date-field-group.component.html @@ -0,0 +1,25 @@ +
+ + {{ monthLabel() }} + + @for (month of months; track month.value) { + + } + + + + + {{ dayLabel() }} + + + + + {{ yearLabel() }} + + +
diff --git a/libs/vault/src/cipher-form/components/date-field-group/date-field-group.component.spec.ts b/libs/vault/src/cipher-form/components/date-field-group/date-field-group.component.spec.ts new file mode 100644 index 000000000000..5ecf066c09d1 --- /dev/null +++ b/libs/vault/src/cipher-form/components/date-field-group/date-field-group.component.spec.ts @@ -0,0 +1,376 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { ReactiveFormsModule } from "@angular/forms"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { DateFieldGroupComponent } from "./date-field-group.component"; + +describe("DateFieldGroupComponent", () => { + let component: DateFieldGroupComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const i18nService = { + t: (key: string) => key, + }; + + await TestBed.configureTestingModule({ + imports: [DateFieldGroupComponent, ReactiveFormsModule], + providers: [{ provide: I18nService, useValue: i18nService }], + }).compileComponents(); + + fixture = TestBed.createComponent(DateFieldGroupComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe("writeValue", () => { + it("parses YYYY-MM-DD format", () => { + component.writeValue("2025-04-05"); + expect(component.internalForm.value).toEqual({ + month: "4", + day: "5", + year: "2025", + }); + }); + + it("parses old YYYY-M-D format (backward compat)", () => { + component.writeValue("2025-4-5"); + expect(component.internalForm.value).toEqual({ + month: "4", + day: "5", + year: "2025", + }); + }); + + it("handles empty string", () => { + component.writeValue(""); + expect(component.internalForm.value).toEqual({ + month: "", + day: "", + year: "", + }); + }); + + it("handles null", () => { + component.writeValue(null); + expect(component.internalForm.value).toEqual({ + month: "", + day: "", + year: "", + }); + }); + + it("handles undefined", () => { + component.writeValue(undefined); + expect(component.internalForm.value).toEqual({ + month: "", + day: "", + year: "", + }); + }); + }); + + describe("all-or-nothing validation", () => { + it("sets crossFieldRequired on day and year when only month is filled", fakeAsync(() => { + const monthCtrl = component.internalForm.get("month")!; + const dayCtrl = component.internalForm.get("day")!; + const yearCtrl = component.internalForm.get("year")!; + + monthCtrl.markAsTouched(); + monthCtrl.setValue("4"); + tick(); + + expect(dayCtrl.hasError("crossFieldRequired")).toBe(true); + expect(yearCtrl.hasError("crossFieldRequired")).toBe(true); + })); + + it("sets crossFieldRequired on month and year when only day is filled", fakeAsync(() => { + const monthCtrl = component.internalForm.get("month")!; + const dayCtrl = component.internalForm.get("day")!; + const yearCtrl = component.internalForm.get("year")!; + + dayCtrl.markAsTouched(); + dayCtrl.setValue("15"); + tick(); + + expect(monthCtrl.hasError("crossFieldRequired")).toBe(true); + expect(yearCtrl.hasError("crossFieldRequired")).toBe(true); + })); + + it("sets crossFieldRequired on month and day when only year is filled", fakeAsync(() => { + const monthCtrl = component.internalForm.get("month")!; + const dayCtrl = component.internalForm.get("day")!; + const yearCtrl = component.internalForm.get("year")!; + + yearCtrl.markAsTouched(); + yearCtrl.setValue("2025"); + tick(); + + expect(monthCtrl.hasError("crossFieldRequired")).toBe(true); + expect(dayCtrl.hasError("crossFieldRequired")).toBe(true); + })); + + it("clears crossFieldRequired errors when all fields are filled", fakeAsync(() => { + const monthCtrl = component.internalForm.get("month")!; + const dayCtrl = component.internalForm.get("day")!; + const yearCtrl = component.internalForm.get("year")!; + + monthCtrl.markAsTouched(); + monthCtrl.setValue("4"); + dayCtrl.setValue("15"); + yearCtrl.setValue("2025"); + tick(); + + expect(monthCtrl.hasError("crossFieldRequired")).toBe(false); + expect(dayCtrl.hasError("crossFieldRequired")).toBe(false); + expect(yearCtrl.hasError("crossFieldRequired")).toBe(false); + })); + + it("clears crossFieldRequired errors when all fields are empty", fakeAsync(() => { + const monthCtrl = component.internalForm.get("month")!; + const dayCtrl = component.internalForm.get("day")!; + const yearCtrl = component.internalForm.get("year")!; + + monthCtrl.setValue("4"); + monthCtrl.markAsTouched(); + dayCtrl.setValue("15"); + yearCtrl.setValue("2025"); + tick(); + + monthCtrl.setValue(""); + dayCtrl.setValue(""); + yearCtrl.setValue(""); + tick(); + + expect(monthCtrl.hasError("crossFieldRequired")).toBe(false); + expect(dayCtrl.hasError("crossFieldRequired")).toBe(false); + expect(yearCtrl.hasError("crossFieldRequired")).toBe(false); + })); + }); + + describe("day range validation", () => { + it("rejects Feb 31", fakeAsync(() => { + const dayCtrl = component.internalForm.get("day")!; + component.internalForm.patchValue({ month: "2", day: "31", year: "2025" }); + tick(); + + expect(dayCtrl.hasError("invalidDay")).toBe(true); + })); + + it("accepts Feb 29 on leap year 2024", fakeAsync(() => { + const dayCtrl = component.internalForm.get("day")!; + component.internalForm.patchValue({ month: "2", day: "29", year: "2024" }); + tick(); + + expect(dayCtrl.hasError("invalidDay")).toBe(false); + })); + + it("rejects Feb 29 on non-leap year 2023", fakeAsync(() => { + const dayCtrl = component.internalForm.get("day")!; + component.internalForm.patchValue({ month: "2", day: "29", year: "2023" }); + tick(); + + expect(dayCtrl.hasError("invalidDay")).toBe(true); + })); + + it("accepts Apr 30", fakeAsync(() => { + const dayCtrl = component.internalForm.get("day")!; + component.internalForm.patchValue({ month: "4", day: "30", year: "2025" }); + tick(); + + expect(dayCtrl.hasError("invalidDay")).toBe(false); + })); + + it("rejects Apr 31", fakeAsync(() => { + const dayCtrl = component.internalForm.get("day")!; + component.internalForm.patchValue({ month: "4", day: "31", year: "2025" }); + tick(); + + expect(dayCtrl.hasError("invalidDay")).toBe(true); + })); + + it("does not validate day when not all fields are filled", fakeAsync(() => { + const dayCtrl = component.internalForm.get("day")!; + component.internalForm.patchValue({ month: "2", day: "31", year: "" }); + tick(); + + expect(dayCtrl.hasError("invalidDay")).toBe(false); + })); + + it("rejects year with less than 4 digits", fakeAsync(() => { + const yearCtrl = component.internalForm.get("year")!; + component.internalForm.patchValue({ month: "4", day: "15", year: "25" }); + tick(); + + expect(yearCtrl.hasError("invalidYear")).toBe(true); + })); + + it("accepts year with exactly 4 digits", fakeAsync(() => { + const yearCtrl = component.internalForm.get("year")!; + component.internalForm.patchValue({ month: "4", day: "15", year: "2025" }); + tick(); + + expect(yearCtrl.hasError("invalidYear")).toBe(false); + })); + + it("accepts day 31 for Jan", fakeAsync(() => { + const dayCtrl = component.internalForm.get("day")!; + component.internalForm.patchValue({ month: "1", day: "31", year: "2025" }); + tick(); + + expect(dayCtrl.hasError("invalidDay")).toBe(false); + })); + }); + + describe("numeric filter", () => { + it("strips non-digits from day field", fakeAsync(() => { + const dayCtrl = component.internalForm.get("day")!; + dayCtrl.setValue("1a5b9"); + tick(); + + expect(dayCtrl.value).toBe("159"); + })); + + it("strips non-digits from year field", fakeAsync(() => { + const yearCtrl = component.internalForm.get("year")!; + yearCtrl.setValue("2a0b2c5"); + tick(); + + expect(yearCtrl.value).toBe("2025"); + })); + + it("allows empty values in numeric filters", fakeAsync(() => { + const dayCtrl = component.internalForm.get("day")!; + dayCtrl.setValue(""); + tick(); + + expect(dayCtrl.value).toBe(""); + })); + + it("handles day 0 as invalid", fakeAsync(() => { + const dayCtrl = component.internalForm.get("day")!; + component.internalForm.patchValue({ month: "4", day: "0", year: "2025" }); + tick(); + + expect(dayCtrl.hasError("invalidDay")).toBe(true); + })); + + it("handles large day values as invalid", fakeAsync(() => { + const dayCtrl = component.internalForm.get("day")!; + component.internalForm.patchValue({ month: "4", day: "32", year: "2025" }); + tick(); + + expect(dayCtrl.hasError("invalidDay")).toBe(true); + })); + }); + + describe("combineDate", () => { + it("produces YYYY-MM-DD zero-padded format", () => { + const onChangeSpy = jest.fn(); + component.registerOnChange(onChangeSpy); + + component.internalForm.patchValue({ month: "4", day: "5", year: "2025" }); + + expect(onChangeSpy).toHaveBeenCalledWith("2025-04-05"); + }); + + it("returns empty string when all fields are empty", () => { + const onChangeSpy = jest.fn(); + component.registerOnChange(onChangeSpy); + + component.internalForm.patchValue({ month: "", day: "", year: "" }); + + expect(onChangeSpy).toHaveBeenCalledWith(""); + }); + }); + describe("validate", () => { + it("returns errors when day is missing", fakeAsync(() => { + const monthCtrl = component.internalForm.get("month")!; + monthCtrl.markAsTouched(); + component.internalForm.patchValue({ month: "4", day: "", year: "2025" }); + tick(); + + const dayCtrl = component.internalForm.get("day")!; + expect(dayCtrl.hasError("crossFieldRequired")).toBe(true); + })); + + it("returns null when form is valid", fakeAsync(() => { + component.internalForm.patchValue({ month: "4", day: "5", year: "2025" }); + tick(); + + expect(component.internalForm.valid).toBe(true); + })); + }); + + describe("onGroupBlur", () => { + it("marks all fields as touched when focus leaves the group", () => { + const monthCtrl = component.internalForm.get("month")!; + const dayCtrl = component.internalForm.get("day")!; + const yearCtrl = component.internalForm.get("year")!; + + component.internalForm.patchValue({ month: "4", day: "", year: "2025" }); + + const blurEvent = new FocusEvent("blur", { + relatedTarget: document.body as any, + }); + + component.onGroupBlur(blurEvent); + + expect(monthCtrl.touched).toBe(true); + expect(dayCtrl.touched).toBe(true); + expect(yearCtrl.touched).toBe(true); + }); + + it("does not mark fields as touched if focus stays within the group", () => { + const monthCtrl = component.internalForm.get("month")!; + const dayCtrl = component.internalForm.get("day")!; + const yearCtrl = component.internalForm.get("year")!; + + // Create a mock element inside the date field group + const mockElement = document.createElement("div"); + const mockGroup = document.createElement("div"); + mockGroup.appendChild(mockElement); + + const blurEvent = new FocusEvent("blur", { + relatedTarget: mockElement as any, + }); + + // Mock the viewChild to return the mock group + jest.spyOn(component, "dateFieldGroup").mockReturnValue({ nativeElement: mockGroup } as any); + + component.onGroupBlur(blurEvent); + + expect(monthCtrl.touched).toBe(false); + expect(dayCtrl.touched).toBe(false); + expect(yearCtrl.touched).toBe(false); + }); + + it("marks all fields as touched when focus leaves and has partial values", fakeAsync(() => { + const monthCtrl = component.internalForm.get("month")!; + const dayCtrl = component.internalForm.get("day")!; + const yearCtrl = component.internalForm.get("year")!; + + monthCtrl.setValue("4"); + monthCtrl.markAsTouched(); + dayCtrl.setValue(""); + yearCtrl.setValue("2025"); + tick(); + + const blurEvent = new FocusEvent("blur", { + relatedTarget: document.body as any, + }); + + // Reset touched to verify onGroupBlur marks them + monthCtrl.markAsUntouched(); + dayCtrl.markAsUntouched(); + yearCtrl.markAsUntouched(); + + component.onGroupBlur(blurEvent); + + expect(monthCtrl.touched).toBe(true); + expect(dayCtrl.touched).toBe(true); + expect(yearCtrl.touched).toBe(true); + })); + }); +}); diff --git a/libs/vault/src/cipher-form/components/date-field-group/date-field-group.component.ts b/libs/vault/src/cipher-form/components/date-field-group/date-field-group.component.ts new file mode 100644 index 000000000000..de77c883f5f7 --- /dev/null +++ b/libs/vault/src/cipher-form/components/date-field-group/date-field-group.component.ts @@ -0,0 +1,313 @@ +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + forwardRef, + input, + OnInit, + viewChild, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { + AbstractControl, + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, + Validator, + ValidationErrors, +} from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FormFieldModule, SelectModule } from "@bitwarden/components"; + +interface DateParts { + month: string; + day: string; + year: string; +} + +@Component({ + selector: "vault-date-field-group", + templateUrl: "./date-field-group.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, ReactiveFormsModule, FormFieldModule, SelectModule, JslibModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DateFieldGroupComponent), + multi: true, + }, + { provide: NG_VALIDATORS, useExisting: forwardRef(() => DateFieldGroupComponent), multi: true }, + ], +}) +export class DateFieldGroupComponent implements OnInit, ControlValueAccessor, Validator { + readonly monthLabel = input(""); + readonly dayLabel = input(""); + readonly yearLabel = input(""); + + readonly dateFieldGroup = viewChild>("dateFieldGroup"); + readonly months: Array<{ name: string; value: string }>; + readonly internalForm: FormGroup; + + // These callbacks are reassigned by Angular's ControlValueAccessor interface + // eslint-disable-next-line @bitwarden/components/enforce-readonly-angular-properties + private onChange = (value: string) => {}; + // eslint-disable-next-line @bitwarden/components/enforce-readonly-angular-properties + private onTouched = () => {}; + // eslint-disable-next-line @bitwarden/components/enforce-readonly-angular-properties + private onValidatorChange = () => {}; + + constructor( + private readonly formBuilder: FormBuilder, + private readonly i18nService: I18nService, + private readonly destroyRef: DestroyRef, + ) { + this.months = [ + { name: "-- " + this.i18nService.t("select") + " --", value: "" }, + { name: "01 - " + this.i18nService.t("january"), value: "1" }, + { name: "02 - " + this.i18nService.t("february"), value: "2" }, + { name: "03 - " + this.i18nService.t("march"), value: "3" }, + { name: "04 - " + this.i18nService.t("april"), value: "4" }, + { name: "05 - " + this.i18nService.t("may"), value: "5" }, + { name: "06 - " + this.i18nService.t("june"), value: "6" }, + { name: "07 - " + this.i18nService.t("july"), value: "7" }, + { name: "08 - " + this.i18nService.t("august"), value: "8" }, + { name: "09 - " + this.i18nService.t("september"), value: "9" }, + { name: "10 - " + this.i18nService.t("october"), value: "10" }, + { name: "11 - " + this.i18nService.t("november"), value: "11" }, + { name: "12 - " + this.i18nService.t("december"), value: "12" }, + ]; + + this.internalForm = this.formBuilder.group({ + month: [""], + day: [""], + year: [""], + }); + } + + ngOnInit(): void { + this.setupNumericFilter(this.internalForm.get("day")!); + this.setupNumericFilter(this.internalForm.get("year")!); + + this.internalForm.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + this.validateAllOrNothing(); + this.validateDayRange(); + const combined = this.combineDate( + this.internalForm.get("month")!.value, + this.internalForm.get("day")!.value, + this.internalForm.get("year")!.value, + ); + this.onChange(combined); + this.onValidatorChange(); + }); + } + + writeValue(value: string | null | undefined): void { + if (!value) { + this.internalForm.patchValue({ month: "", day: "", year: "" }, { emitEvent: false }); + return; + } + const parts = this.parseDateParts(value); + this.internalForm.patchValue(parts, { emitEvent: false }); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.internalForm.disable({ emitEvent: false }); + } else { + this.internalForm.enable({ emitEvent: false }); + } + } + + validate(): ValidationErrors | null { + return this.internalForm.errors; + } + + registerOnValidatorChange(fn: () => void): void { + this.onValidatorChange = fn; + } + + onGroupBlur(event: FocusEvent): void { + if (this.dateFieldGroup()?.nativeElement.contains(event.relatedTarget as HTMLElement)) { + return; + } + + this.internalForm.get("month")!.markAsTouched(); + this.internalForm.get("day")!.markAsTouched(); + this.internalForm.get("year")!.markAsTouched(); + } + + /** + * Parses a date string in YYYY-MM-DD or YYYY-M-D format + * back into discrete month, day, year fields. + * Handles both zero-padded and non-padded formats. + */ + private parseDateParts(dateStr: string): DateParts { + if (!dateStr) { + return { month: "", day: "", year: "" }; + } + const [year = "", month = "", day = ""] = dateStr.split("-"); + return { + month: month ? String(parseInt(month, 10)) : "", + day: day ? String(parseInt(day, 10)) : "", + year, + }; + } + + /** + * Combines month, day, year into a YYYY-MM-DD string with zero-padding. + * Returns "" when all parts are empty. + */ + private combineDate( + month: string | null | undefined, + day: string | null | undefined, + year: string | null | undefined, + ): string { + if (!month && !day && !year) { + return ""; + } + if (!month || !day || !year) { + // Partial dates are allowed as input, but not returned unless all empty + return ""; + } + const monthPadded = String(month).padStart(2, "0"); + const dayPadded = String(day).padStart(2, "0"); + return `${year}-${monthPadded}-${dayPadded}`; + } + + /** + * Strips non-digit characters after each keystroke. + */ + private setupNumericFilter(ctrl: AbstractControl): void { + ctrl.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((value: string) => { + if (!value) { + return; + } + const filtered = value.replace(/\D/g, ""); + if (filtered !== value) { + ctrl.setValue(filtered, { emitEvent: false }); + } + }); + } + + /** + * All-or-nothing validation: if any field is filled, all must be filled. + * Only applies errors if user has left the date field group (touched state set). + */ + private validateAllOrNothing(): void { + const monthCtrl = this.internalForm.get("month")!; + const dayCtrl = this.internalForm.get("day")!; + const yearCtrl = this.internalForm.get("year")!; + + const monthFilled = !!monthCtrl.value; + const dayFilled = !!(dayCtrl.value as string)?.trim(); + const yearFilled = !!(yearCtrl.value as string)?.trim(); + + const anyFilled = monthFilled || dayFilled || yearFilled; + const anyTouched = monthCtrl.touched || dayCtrl.touched || yearCtrl.touched; + // Only show errors if user has touched (left) any field + if (!anyTouched || !anyFilled) { + this.clearCrossFieldError(monthCtrl); + this.clearCrossFieldError(dayCtrl); + this.clearCrossFieldError(yearCtrl); + return; + } + + this.setCrossFieldError(monthCtrl, !monthFilled, this.i18nService.t("enterMonth")); + this.setCrossFieldError(dayCtrl, !dayFilled, this.i18nService.t("enterDay")); + this.setCrossFieldError(yearCtrl, !yearFilled, this.i18nService.t("enterYear")); + } + + /** + * Validates that the day value is valid for the selected month/year. + * Only runs when all three fields are filled. + */ + private validateDayRange(): void { + const monthCtrl = this.internalForm.get("month")!; + const dayCtrl = this.internalForm.get("day")!; + const yearCtrl = this.internalForm.get("year")!; + + const month = monthCtrl.value; + const day = dayCtrl.value; + const year = yearCtrl.value; + + // Only validate when all fields are present + if (!month || !day || !year) { + dayCtrl.setErrors(this.removeError(dayCtrl.errors, "invalidDay")); + return; + } + + const dayNum = parseInt(day, 10); + const monthNum = parseInt(month, 10); + const yearNum = parseInt(year, 10); + + // Check that the year is a valid 4 digit number, anything less than 4 digits should show an error + if (isNaN(yearNum) || year.length !== 4) { + const errors = this.removeError(yearCtrl.errors, "crossFieldRequired"); + yearCtrl.setErrors({ + ...(errors ?? {}), + invalidYear: { message: this.i18nService.t("invalidYear") }, + }); + return; + } + + // Check if day is valid for the selected month/year + const daysInMonth = new Date(yearNum, monthNum, 0).getDate(); + const isValidDay = dayNum >= 1 && dayNum <= daysInMonth; + + if (!isValidDay) { + dayCtrl.setErrors({ + ...(dayCtrl.errors ?? {}), + invalidDay: { message: this.i18nService.t("invalidDay") }, + }); + } else { + dayCtrl.setErrors(this.removeError(dayCtrl.errors, "invalidDay")); + } + } + + /** + * Sets or updates the crossFieldRequired error on a control. + */ + private setCrossFieldError(ctrl: AbstractControl, shouldError: boolean, message: string): void { + if (shouldError) { + ctrl.setErrors({ ...(ctrl.errors ?? {}), crossFieldRequired: { message } }); + } else { + ctrl.setErrors(this.removeError(ctrl.errors, "crossFieldRequired")); + } + } + + /** + * Removes a specific error from a control's errors object. + * Returns null if no errors remain. + */ + private clearCrossFieldError(ctrl: AbstractControl): void { + ctrl.setErrors(this.removeError(ctrl.errors, "crossFieldRequired")); + } + + /** + * Removes an error key from the errors object. + * Returns null if no errors remain, otherwise returns the updated errors object. + */ + private removeError(errors: ValidationErrors | null, errorKey: string): ValidationErrors | null { + if (!errors || !errors[errorKey]) { + return errors; + } + const updated = { ...errors }; + delete updated[errorKey]; + return Object.keys(updated).length ? updated : null; + } +} diff --git a/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts index 1d47d572abca..26edaa72e60b 100644 --- a/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts +++ b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts @@ -27,6 +27,11 @@ export class NewItemNudgeComponent { return of(false); } const nudgeType = this.mapToNudgeType(cipherType); + + if (!nudgeType) { + return of(false); + } + return this.nudgesService.showNudgeSpotlight$(nudgeType, userId); }), ); @@ -40,7 +45,7 @@ export class NewItemNudgeComponent { private nudgesService: NudgesService, ) {} - mapToNudgeType(cipherType: CipherType): NudgeType { + mapToNudgeType(cipherType: CipherType): NudgeType | null { switch (cipherType) { case CipherType.Login: { const nudgeBodyOne = this.i18nService.t("newLoginNudgeBodyOne"); @@ -84,6 +89,9 @@ export class NewItemNudgeComponent { this.nudgeBody = this.i18nService.t("newBankAccountNudgeBody"); return NudgeType.NewBankAccountItemStatus; + case CipherType.Passport: + return null; + default: throw new Error("Unsupported cipher type"); } diff --git a/libs/vault/src/cipher-form/components/passport-section/passport-section.component.html b/libs/vault/src/cipher-form/components/passport-section/passport-section.component.html new file mode 100644 index 000000000000..a372d0512208 --- /dev/null +++ b/libs/vault/src/cipher-form/components/passport-section/passport-section.component.html @@ -0,0 +1,93 @@ +
+ +

+ {{ "typePassport" | i18n }} +

+
+ + + {{ "firstName" | i18n }} + + + + + {{ "lastName" | i18n }} + + + + + + + {{ "sex" | i18n }} + + + + + {{ "birthPlace" | i18n }} + + + + + {{ "nationality" | i18n }} + + + + + {{ "passportNumber" | i18n }} + + + + + + {{ "passportType" | i18n }} + + + + + {{ "nationalIdentificationNumber" | i18n }} + + + + + + {{ "issuingCountry" | i18n }} + + + + + {{ "issuingAuthority" | i18n }} + + + + + + + +
diff --git a/libs/vault/src/cipher-form/components/passport-section/passport-section.component.spec.ts b/libs/vault/src/cipher-form/components/passport-section/passport-section.component.spec.ts new file mode 100644 index 000000000000..7ff87be70675 --- /dev/null +++ b/libs/vault/src/cipher-form/components/passport-section/passport-section.component.spec.ts @@ -0,0 +1,121 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ReactiveFormsModule } from "@angular/forms"; +import { mock } from "jest-mock-extended"; +import { Subject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { PassportView } from "@bitwarden/common/vault/models/view/passport.view"; + +import { CipherFormContainer } from "../../cipher-form-container"; + +import { PassportSectionComponent } from "./passport-section.component"; + +describe("PassportSectionComponent", () => { + let fixture: ComponentFixture; + let component: PassportSectionComponent; + const mockI18nService = mock(); + + let formStatusChange$: Subject; + + let cipherFormContainer: { + registerChildForm: jest.Mock; + patchCipher: jest.Mock; + getInitialCipherView: jest.Mock; + formStatusChange$: Subject; + }; + + beforeEach(async () => { + formStatusChange$ = new Subject(); + + cipherFormContainer = { + registerChildForm: jest.fn(), + patchCipher: jest.fn(), + getInitialCipherView: jest.fn((): PassportView | null => null), + formStatusChange$, + }; + + await TestBed.configureTestingModule({ + imports: [PassportSectionComponent, ReactiveFormsModule], + providers: [ + { provide: CipherFormContainer, useValue: cipherFormContainer }, + { provide: I18nService, useValue: mockI18nService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PassportSectionComponent); + component = fixture.componentInstance; + }); + + describe("initialization", () => { + it("registers passportDetails form with container", () => { + expect(cipherFormContainer.registerChildForm).toHaveBeenCalledWith( + "passportDetails", + component.passportForm, + ); + }); + + it("does not set initial values when no passport data available", () => { + fixture.detectChanges(); + expect(component.passportForm.getRawValue()).toEqual({ + surname: "", + givenName: "", + dateOfBirth: "", + sex: "", + birthPlace: "", + nationality: "", + issuingCountry: "", + passportNumber: "", + passportType: "", + nationalIdentificationNumber: "", + issuingAuthority: "", + issueDate: "", + expirationDate: "", + }); + }); + + it("sets initial values when passport data available", () => { + const passportView = new PassportView(); + passportView.surname = "Doe"; + passportView.givenName = "John"; + passportView.passportNumber = "123456"; + + cipherFormContainer.getInitialCipherView.mockReturnValue(null); + + fixture.componentRef.setInput("originalCipherView", { + passport: passportView, + } as CipherView); + fixture.detectChanges(); + + expect(component.passportForm.getRawValue().surname).toBe("Doe"); + expect(component.passportForm.getRawValue().givenName).toBe("John"); + expect(component.passportForm.getRawValue().passportNumber).toBe("123456"); + }); + + it("disables form when disabled input is true", () => { + fixture.componentRef.setInput("disabled", true); + fixture.detectChanges(); + + expect(fixture.componentInstance.passportForm.disabled).toBe(true); + }); + }); + + describe("form value changes", () => { + it("patches cipher when form values change", () => { + fixture.detectChanges(); + + component.passportForm.patchValue({ + surname: "Smith", + passportNumber: "P123456", + }); + + expect(cipherFormContainer.patchCipher).toHaveBeenCalled(); + const patchFn = cipherFormContainer.patchCipher.mock.calls[0][0]; + const mockCipher = { passport: null } as any; + patchFn(mockCipher); + + expect(mockCipher.passport.surname).toBe("Smith"); + expect(mockCipher.passport.passportNumber).toBe("P123456"); + }); + }); +}); diff --git a/libs/vault/src/cipher-form/components/passport-section/passport-section.component.ts b/libs/vault/src/cipher-form/components/passport-section/passport-section.component.ts new file mode 100644 index 000000000000..b175f3f3169c --- /dev/null +++ b/libs/vault/src/cipher-form/components/passport-section/passport-section.component.ts @@ -0,0 +1,137 @@ +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + input, + OnInit, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { PassportView } from "@bitwarden/common/vault/models/view/passport.view"; +import { + CardComponent, + FormFieldModule, + IconButtonModule, + SectionHeaderComponent, + TypographyModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { CipherFormContainer } from "../../cipher-form-container"; +import { DateFieldGroupComponent } from "../date-field-group/date-field-group.component"; + +@Component({ + selector: "vault-passport-section", + templateUrl: "./passport-section.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + CardComponent, + TypographyModule, + FormFieldModule, + ReactiveFormsModule, + SectionHeaderComponent, + IconButtonModule, + JslibModule, + I18nPipe, + DateFieldGroupComponent, + ], +}) +export class PassportSectionComponent implements OnInit { + readonly originalCipherView = input(null); + readonly disabled = input(false); + + readonly passportForm: FormGroup; + private readonly destroyRef = inject(DestroyRef); + + constructor( + private readonly cipherFormContainer: CipherFormContainer, + private readonly formBuilder: FormBuilder, + ) { + this.passportForm = this.formBuilder.group({ + surname: [""], + givenName: [""], + dateOfBirth: [""], + sex: [""], + birthPlace: [""], + nationality: [""], + issuingCountry: [""], + passportNumber: [""], + passportType: [""], + nationalIdentificationNumber: [""], + issuingAuthority: [""], + issueDate: [""], + expirationDate: [""], + }); + + this.cipherFormContainer.registerChildForm("passportDetails", this.passportForm); + + this.passportForm.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe((value) => this.updateCipherFromFormValue(value)); + } + + ngOnInit() { + const prefillCipher = this.cipherFormContainer.getInitialCipherView(); + const passportView = prefillCipher?.passport ?? this.originalCipherView()?.passport; + + if (passportView) { + this.passportForm.patchValue({ + surname: passportView.surname, + givenName: passportView.givenName, + dateOfBirth: passportView.dateOfBirth, + sex: passportView.sex, + birthPlace: passportView.birthPlace, + nationality: passportView.nationality, + issuingCountry: passportView.issuingCountry, + passportNumber: passportView.passportNumber, + passportType: passportView.passportType, + nationalIdentificationNumber: passportView.nationalIdentificationNumber, + issuingAuthority: passportView.issuingAuthority, + issueDate: passportView.issueDate, + expirationDate: passportView.expirationDate, + }); + } + + if (this.disabled()) { + this.passportForm.disable(); + } + + this.cipherFormContainer.formStatusChange$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((status) => { + if (status === "disabled" || this.disabled()) { + this.passportForm.disable(); + } else { + this.passportForm.enable(); + } + }); + } + + private updateCipherFromFormValue(value: typeof this.passportForm.value): void { + const data = new PassportView(); + data.surname = value.surname; + data.givenName = value.givenName; + data.dateOfBirth = value.dateOfBirth; + data.sex = value.sex; + data.birthPlace = value.birthPlace; + data.nationality = value.nationality; + data.issuingCountry = value.issuingCountry; + data.passportNumber = value.passportNumber; + data.passportType = value.passportType; + data.nationalIdentificationNumber = value.nationalIdentificationNumber; + data.issuingAuthority = value.issuingAuthority; + data.issueDate = value.issueDate; + data.expirationDate = value.expirationDate; + + this.cipherFormContainer.patchCipher((cipher) => { + cipher.passport = data; + return cipher; + }); + } +} diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html index 675aad792bb3..61a310de4c8b 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -88,6 +88,11 @@ > } + + @if (hasPassport()) { + + } + @if (cipher()?.notes) { diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index 40df80f7f450..3d6f65cee980 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -44,6 +44,7 @@ import { CustomFieldV2Component } from "./custom-fields/custom-fields-v2.compone import { ItemDetailsV2Component } from "./item-details/item-details-v2.component"; import { ItemHistoryV2Component } from "./item-history/item-history-v2.component"; import { LoginCredentialsViewComponent } from "./login-credentials/login-credentials-view.component"; +import { PassportViewComponent } from "./passport-sections/passport-view.component"; import { SshKeyViewComponent } from "./sshkey-sections/sshkey-view.component"; import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-identity-sections.component"; @@ -65,6 +66,7 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide CardDetailsComponent, SshKeyViewComponent, BankAccountViewComponent, + PassportViewComponent, ViewIdentitySectionsComponent, LoginCredentialsViewComponent, AutofillOptionsViewComponent, @@ -246,6 +248,14 @@ export class CipherViewComponent { return Array.from(Object.values(cipher.bankAccount)).some((value) => Boolean(value)); }); + readonly hasPassport = computed(() => { + const cipher = this.cipher(); + if (!cipher) { + return false; + } + return Array.from(Object.values(cipher.passport ?? {})).some((value) => Boolean(value)); + }); + readonly hasLoginUri = computed(() => { const cipher = this.cipher(); return cipher?.login?.hasUris; diff --git a/libs/vault/src/cipher-view/passport-sections/passport-view.component.html b/libs/vault/src/cipher-view/passport-sections/passport-view.component.html new file mode 100644 index 000000000000..931ff73fd6f9 --- /dev/null +++ b/libs/vault/src/cipher-view/passport-sections/passport-view.component.html @@ -0,0 +1,213 @@ +
+ +

{{ "typePassport" | i18n }}

+
+ + @if (passport().givenName) { + + {{ "firstName" | i18n }} + + + + } + + @if (passport().surname) { + + {{ "lastName" | i18n }} + + + + } + + @if (passport().dateOfBirth) { + + {{ "dateOfBirth" | i18n }} + + + } + + @if (passport().sex) { + + {{ "sex" | i18n }} + + + } + + @if (passport().birthPlace) { + + {{ "birthPlace" | i18n }} + + + } + + @if (passport().nationality) { + + {{ "nationality" | i18n }} + + + } + + @if (passport().passportNumber) { + + {{ "passportNumber" | i18n }} + + + + + } + + @if (passport().passportType) { + + {{ "passportType" | i18n }} + + + } + + @if (passport().nationalIdentificationNumber) { + + {{ "nationalIdentificationNumber" | i18n }} + + + + } + + @if (passport().issuingCountry) { + + {{ "issuingCountry" | i18n }} + + + } + + @if (passport().issuingAuthority) { + + {{ "issuingAuthority" | i18n }} + + + } + + @if (passport().issueDate) { + + {{ "issueDate" | i18n }} + + + } + + @if (passport().expirationDate) { + + {{ "expirationDate" | i18n }} + + + } + +
diff --git a/libs/vault/src/cipher-view/passport-sections/passport-view.component.spec.ts b/libs/vault/src/cipher-view/passport-sections/passport-view.component.spec.ts new file mode 100644 index 000000000000..d073574e990a --- /dev/null +++ b/libs/vault/src/cipher-view/passport-sections/passport-view.component.spec.ts @@ -0,0 +1,105 @@ +import { DatePipe } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { EventCollectionService } from "@bitwarden/common/dirt/event-logs"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { PassportView } from "@bitwarden/common/vault/models/view/passport.view"; +import { ToastService } from "@bitwarden/components"; + +import { PasswordRepromptService } from "../../services/password-reprompt.service"; + +import { PassportViewComponent } from "./passport-view.component"; + +describe("PassportViewComponent", () => { + let fixture: ComponentFixture; + const mockI18nService = mock(); + const mockPlatformUtilsService = mock(); + const collect = jest.fn(); + + beforeEach(async () => { + collect.mockClear(); + await TestBed.configureTestingModule({ + imports: [PassportViewComponent], + providers: [ + DatePipe, + { provide: I18nService, useValue: mockI18nService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: ToastService, useValue: mock() }, + { provide: EventCollectionService, useValue: mock({ collect }) }, + { provide: PasswordRepromptService, useValue: mock() }, + { provide: TotpService, useValue: mock() }, + { + provide: BillingAccountProfileStateService, + useValue: mock(), + }, + { provide: AccountService, useValue: mock() }, + { provide: CipherService, useValue: mock() }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PassportViewComponent); + }); + + describe("display", () => { + it("does not display passport fields when they have values", () => { + const passportView = new PassportView(); + passportView.surname = "Doe"; + passportView.givenName = "John"; + passportView.passportNumber = "123456"; + passportView.nationality = "USA"; + + const cipher = new CipherView(); + cipher.type = CipherType.Passport; + + fixture.componentRef.setInput("passport", passportView); + fixture.componentRef.setInput("cipher", cipher); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const fields = compiled.querySelectorAll("bit-form-field"); + + expect(fields.length).toBeGreaterThan(0); + }); + + it("does not display empty fields", () => { + const passportView = new PassportView(); + const cipher = new CipherView(); + cipher.type = CipherType.Passport; + + fixture.componentRef.setInput("passport", passportView); + fixture.componentRef.setInput("cipher", cipher); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const inputs = compiled.querySelectorAll("input[readonly]"); + + expect(inputs.length).toBe(0); + }); + + it("does not render copy button for passport number when empty", () => { + const passportView = new PassportView(); + passportView.passportNumber = "P123456"; + + const cipher = new CipherView(); + cipher.type = CipherType.Passport; + cipher.id = "test-id"; + + fixture.componentRef.setInput("passport", passportView); + fixture.componentRef.setInput("cipher", cipher); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const copyButton = compiled.querySelector('[appCopyField="passportNumber"]'); + + expect(copyButton).toBeTruthy(); + }); + }); +}); diff --git a/libs/vault/src/cipher-view/passport-sections/passport-view.component.ts b/libs/vault/src/cipher-view/passport-sections/passport-view.component.ts new file mode 100644 index 000000000000..f7bd165e15b9 --- /dev/null +++ b/libs/vault/src/cipher-view/passport-sections/passport-view.component.ts @@ -0,0 +1,68 @@ +import { DatePipe } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, input, signal } from "@angular/core"; + +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { PassportView } from "@bitwarden/common/vault/models/view/passport.view"; +import { + SectionHeaderComponent, + TypographyModule, + FormFieldModule, + IconButtonModule, + CopyClickDirective, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { CopyCipherFieldDirective } from "../../components/copy-cipher-field.directive"; +import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only-cipher-card.component"; + +@Component({ + selector: "app-passport-view", + templateUrl: "passport-view.component.html", + imports: [ + I18nPipe, + CopyCipherFieldDirective, + SectionHeaderComponent, + ReadOnlyCipherCardComponent, + TypographyModule, + FormFieldModule, + IconButtonModule, + CopyClickDirective, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PassportViewComponent { + private readonly datePipe = inject(DatePipe); + + readonly passport = input.required(); + readonly cipher = input.required(); + readonly revealPassportNumber = signal(false); + readonly revealNationalIdentificationNumber = signal(false); + + togglePassportNumberVisible(event: boolean) { + this.revealPassportNumber.set(event); + } + + toggleNationalIdentificationNumberVisible(event: boolean) { + this.revealNationalIdentificationNumber.set(event); + } + + formatDate(dateStr: string | undefined): string { + if (!dateStr) { + return ""; + } + + const [year, month, day] = dateStr.split("-"); + + if (year && month && day) { + const date = new Date(+year, +month - 1, +day); + return this.datePipe.transform(date, "longDate") ?? dateStr; + } + + if (year && month) { + const date = new Date(+year, +month - 1, 1); + return this.datePipe.transform(date, "MMMM y") ?? dateStr; + } + + return year ?? dateStr; + } +} diff --git a/libs/vault/src/components/add-item-dialog/add-item-dialog.component.spec.ts b/libs/vault/src/components/add-item-dialog/add-item-dialog.component.spec.ts index a18317fe8a4c..e634a2740801 100644 --- a/libs/vault/src/components/add-item-dialog/add-item-dialog.component.spec.ts +++ b/libs/vault/src/components/add-item-dialog/add-item-dialog.component.spec.ts @@ -109,6 +109,10 @@ describe("AddItemDialogComponent", () => { }); it('is "default" when the grid has fewer than 6 items', () => { + restricted$.next([ + { cipherType: CipherType.Card, allowViewOrgIds: [] }, + { cipherType: CipherType.Passport, allowViewOrgIds: [] }, + ]); createComponent({ canCreateFolder: false, canCreateCollection: false, @@ -127,7 +131,10 @@ describe("AddItemDialogComponent", () => { expect(fixture.componentInstance["dialogSize"]()).toBe("large"); - restricted$.next([{ cipherType: CipherType.Card, allowViewOrgIds: [] } as any]); + restricted$.next([ + { cipherType: CipherType.Card, allowViewOrgIds: [] }, + { cipherType: CipherType.Passport, allowViewOrgIds: [] }, + ]); fixture.detectChanges(); expect(fixture.componentInstance["dialogSize"]()).toBe("default"); diff --git a/libs/vault/src/components/add-item-grid/add-item-grid.component.spec.ts b/libs/vault/src/components/add-item-grid/add-item-grid.component.spec.ts index 0a0417eeee8d..7e8f104a4f73 100644 --- a/libs/vault/src/components/add-item-grid/add-item-grid.component.spec.ts +++ b/libs/vault/src/components/add-item-grid/add-item-grid.component.spec.ts @@ -50,12 +50,13 @@ describe("AddItemGridComponent", () => { }); const items = component["items"](); - expect(items.length).toBe(6); + expect(items.length).toBe(7); expect(items.map((i) => i.labelKey)).toEqual( expect.arrayContaining([ "typeLogin", "typeCard", "typeBankAccount", + "typePassport", "typeIdentity", "typeNote", "typeSshKey", diff --git a/libs/vault/src/components/copy-cipher-field.directive.ts b/libs/vault/src/components/copy-cipher-field.directive.ts index 28f338d86657..2dd2897b247a 100644 --- a/libs/vault/src/components/copy-cipher-field.directive.ts +++ b/libs/vault/src/components/copy-cipher-field.directive.ts @@ -148,6 +148,8 @@ export class CopyCipherFieldDirective implements OnChanges { return _cipher.bankAccount?.pin; case "iban": return _cipher.bankAccount?.iban; + case "passportNumber": + return _cipher.passport?.passportNumber; default: return null; } diff --git a/libs/vault/src/models/filter-function.spec.ts b/libs/vault/src/models/filter-function.spec.ts index de544a1a0d5c..7546b815aeaf 100644 --- a/libs/vault/src/models/filter-function.spec.ts +++ b/libs/vault/src/models/filter-function.spec.ts @@ -84,6 +84,24 @@ describe("createFilter", () => { expect(result).toBe(false); }); + + it("should return true when filter matches passport cipher type", () => { + const cipher = createCipher({ type: CipherType.Passport }); + const filterFunction = createFilterFunction({ type: "passport" }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + + it("should return false when passport cipher does not match filter type", () => { + const cipher = createCipher({ type: CipherType.Passport }); + const filterFunction = createFilterFunction({ type: "identity" }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); }); describe("given a cipher with folder id", () => { diff --git a/libs/vault/src/models/filter-function.ts b/libs/vault/src/models/filter-function.ts index cfb883979c3d..5be26658de09 100644 --- a/libs/vault/src/models/filter-function.ts +++ b/libs/vault/src/models/filter-function.ts @@ -35,6 +35,9 @@ export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunc if (filter.type === "bankAccount" && type !== CipherType.BankAccount) { return false; } + if (filter.type === "passport" && type !== CipherType.Passport) { + return false; + } if (filter.type === "trash" && !isDeleted) { return false; } diff --git a/libs/vault/src/models/routed-vault-filter.model.ts b/libs/vault/src/models/routed-vault-filter.model.ts index bd3cd2d0186c..7fcf582501a3 100644 --- a/libs/vault/src/models/routed-vault-filter.model.ts +++ b/libs/vault/src/models/routed-vault-filter.model.ts @@ -16,6 +16,7 @@ const itemTypes = [ "note", "sshKey", "bankAccount", + "passport", "archive", "trash", All, diff --git a/libs/vault/src/services/copy-cipher-field.service.ts b/libs/vault/src/services/copy-cipher-field.service.ts index 90f3214744ad..e4b39f21bc16 100644 --- a/libs/vault/src/services/copy-cipher-field.service.ts +++ b/libs/vault/src/services/copy-cipher-field.service.ts @@ -36,7 +36,8 @@ export type CopyAction = | "accountNumber" | "routingNumber" | "pin" - | "iban"; + | "iban" + | "passportNumber"; /** * Copy actions that can be used with the appCopyField directive. @@ -90,6 +91,11 @@ const CopyActions: Record = { routingNumber: { typeI18nKey: "bankRoutingNumber", protected: false }, pin: { typeI18nKey: "pin", protected: true, event: EventType.Cipher_ClientCopiedBankAccountPin }, iban: { typeI18nKey: "iban", protected: true }, + passportNumber: { + typeI18nKey: "passportNumber", + protected: true, + event: EventType.Cipher_ClientCopiedPassportNumber, + }, hiddenField: { typeI18nKey: "value", protected: true, diff --git a/libs/vault/src/services/vault-filter.service.ts b/libs/vault/src/services/vault-filter.service.ts index 104844957e1c..efd32807e44e 100644 --- a/libs/vault/src/services/vault-filter.service.ts +++ b/libs/vault/src/services/vault-filter.service.ts @@ -344,6 +344,12 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { type: CipherType.Card, icon: "bwi-credit-card", }, + { + id: "passport", + name: this.i18nService.t("typePassport"), + type: CipherType.Passport, + icon: "bwi-globe", + }, { id: "identity", name: this.i18nService.t("typeIdentity"),