= {
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 @@
+
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 @@
+
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"),