From 0d76835cd8c05d89250aaf19899ceb8c522ee21c Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Thu, 1 Aug 2024 08:35:04 -0700 Subject: [PATCH] [PM-9190] Edit Login - Autofill Options (#10274) * [PM-8524] Update appA11yTitle to keep attributes in sync after first render * [PM-8524] Introduce UriOptionComponent * [PM-9190] Introduce AutofillOptionsComponent * [PM-9190] Add AutofillOptions to LoginDetailsSection * [PM-9190] Add autofill options component unit tests * [PM-9190] Add UriOptionComponent unit tests * [PM-9190] Add missing translations * [PM-9190] Add autofill on page load field * [PM-9190] Ensure updatedCipherView is completely separate from originalCipherView * [CL-348] Do not override items if there are no OptionComponents available * [PM-9190] Mock AutoFillOptions component in Login Details tests * [PM-9190] Cleanup storybook and missing web translations * [PM-9190] Ensure storybook decryptCipher returns a separate object --- apps/browser/src/_locales/en/messages.json | 46 +++++ apps/browser/tailwind.config.js | 1 + apps/web/src/locales/en/messages.json | 49 +++++ .../src/directives/a11y-title.directive.ts | 17 +- .../components/src/select/select.component.ts | 3 + .../src/cipher-form/cipher-form-container.ts | 2 + .../src/cipher-form/cipher-form.stories.ts | 17 +- .../autofill-options.component.html | 36 ++++ .../autofill-options.component.spec.ts | 188 +++++++++++++++++ .../autofill-options.component.ts | 189 ++++++++++++++++++ .../uri-option.component.html | 35 ++++ .../uri-option.component.spec.ts | 143 +++++++++++++ .../autofill-options/uri-option.component.ts | 164 +++++++++++++++ .../components/cipher-form.component.ts | 5 +- .../login-details-section.component.html | 2 + .../login-details-section.component.spec.ts | 20 +- .../login-details-section.component.ts | 2 + 17 files changed, 912 insertions(+), 7 deletions(-) create mode 100644 libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.html create mode 100644 libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts create mode 100644 libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts create mode 100644 libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html create mode 100644 libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts create mode 100644 libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 491c04cc41..194cc47cda 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3828,6 +3828,52 @@ "authenticatorKey": { "message": "Authenticator key" }, + "autofillOptions": { + "message": "Auto-fill options" + }, + "websiteUri": { + "message": "Website (URI)" + }, + "websiteAdded": { + "message": "Website added" + }, + "addWebsite": { + "message": "Add website" + }, + "deleteWebsite": { + "message": "Delete website" + }, + "defaultLabel": { + "message": "Default ($VALUE$)", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, + "showMatchDetection": { + "message": "Show match detection $WEBSITE$", + "placeholders": { + "website": { + "content": "$1", + "example": "https://example.com" + } + } + }, + "hideMatchDetection": { + "message": "Hide match detection $WEBSITE$", + "placeholders": { + "website": { + "content": "$1", + "example": "https://example.com" + } + } + }, + "autoFillOnPageLoad": { + "message": "Autofill on page load?" + }, "cardDetails": { "message": "Card details" }, diff --git a/apps/browser/tailwind.config.js b/apps/browser/tailwind.config.js index c0baf274a2..2e8f9c9f81 100644 --- a/apps/browser/tailwind.config.js +++ b/apps/browser/tailwind.config.js @@ -5,6 +5,7 @@ config.content = [ "./src/**/*.{html,ts}", "../../libs/components/src/**/*.{html,ts}", "../../libs/auth/src/**/*.{html,ts}", + "../../libs/vault/src/**/*.{html,ts}", "../../libs/angular/src/**/*.{html,ts}", "../../libs/vault/src/**/*.{html,ts}", ]; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 7af92a1e52..b4922df885 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -48,6 +48,52 @@ "authenticatorKey": { "message": "Authenticator key" }, + "autofillOptions": { + "message": "Auto-fill options" + }, + "websiteUri": { + "message": "Website (URI)" + }, + "websiteAdded": { + "message": "Website added" + }, + "addWebsite": { + "message": "Add website" + }, + "deleteWebsite": { + "message": "Delete website" + }, + "defaultLabel": { + "message": "Default ($VALUE$)", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, + "showMatchDetection": { + "message": "Show match detection $WEBSITE$", + "placeholders": { + "website": { + "content": "$1", + "example": "https://example.com" + } + } + }, + "hideMatchDetection": { + "message": "Hide match detection $WEBSITE$", + "placeholders": { + "website": { + "content": "$1", + "example": "https://example.com" + } + } + }, + "autoFillOnPageLoad": { + "message": "Autofill on page load?" + }, "number": { "message": "Number" }, @@ -8024,6 +8070,9 @@ } } }, + "addField": { + "message": "Add field" + }, "items": { "message": "Items" }, diff --git a/libs/angular/src/directives/a11y-title.directive.ts b/libs/angular/src/directives/a11y-title.directive.ts index a47b00f142..ebfcbe27a1 100644 --- a/libs/angular/src/directives/a11y-title.directive.ts +++ b/libs/angular/src/directives/a11y-title.directive.ts @@ -1,14 +1,17 @@ -import { Directive, ElementRef, Input, Renderer2 } from "@angular/core"; +import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core"; @Directive({ selector: "[appA11yTitle]", }) -export class A11yTitleDirective { +export class A11yTitleDirective implements OnInit { @Input() set appA11yTitle(title: string) { this.title = title; + this.setAttributes(); } private title: string; + private originalTitle: string | null; + private originalAriaLabel: string | null; constructor( private el: ElementRef, @@ -16,10 +19,16 @@ export class A11yTitleDirective { ) {} ngOnInit() { - if (!this.el.nativeElement.hasAttribute("title")) { + this.originalTitle = this.el.nativeElement.getAttribute("title"); + this.originalAriaLabel = this.el.nativeElement.getAttribute("aria-label"); + this.setAttributes(); + } + + private setAttributes() { + if (this.originalTitle === null) { this.renderer.setAttribute(this.el.nativeElement, "title", this.title); } - if (!this.el.nativeElement.hasAttribute("aria-label")) { + if (this.originalAriaLabel === null) { this.renderer.setAttribute(this.el.nativeElement, "aria-label", this.title); } } diff --git a/libs/components/src/select/select.component.ts b/libs/components/src/select/select.component.ts index 19d0a37356..2d900353a6 100644 --- a/libs/components/src/select/select.component.ts +++ b/libs/components/src/select/select.component.ts @@ -50,6 +50,9 @@ export class SelectComponent implements BitFormFieldControl, ControlValueAcce @ContentChildren(OptionComponent) protected set options(value: QueryList>) { + if (value == null || value.length == 0) { + return; + } this.items = value.toArray(); this.selectedOption = this.findSelectedOption(this.items, this.selectedValue); } diff --git a/libs/vault/src/cipher-form/cipher-form-container.ts b/libs/vault/src/cipher-form/cipher-form-container.ts index 9e93472cf5..87495df643 100644 --- a/libs/vault/src/cipher-form/cipher-form-container.ts +++ b/libs/vault/src/cipher-form/cipher-form-container.ts @@ -2,6 +2,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherFormConfig } from "@bitwarden/vault"; import { AdditionalOptionsSectionComponent } from "./components/additional-options/additional-options-section.component"; +import { AutofillOptionsComponent } from "./components/autofill-options/autofill-options.component"; import { CardDetailsSectionComponent } from "./components/card-details-section/card-details-section.component"; import { CustomFieldsComponent } from "./components/custom-fields/custom-fields.component"; import { IdentitySectionComponent } from "./components/identity/identity.component"; @@ -16,6 +17,7 @@ export type CipherForm = { itemDetails?: ItemDetailsSectionComponent["itemDetailsForm"]; additionalOptions?: AdditionalOptionsSectionComponent["additionalOptionsForm"]; loginDetails?: LoginDetailsSectionComponent["loginDetailsForm"]; + autoFillOptions?: AutofillOptionsComponent["autofillOptionsForm"]; cardDetails?: CardDetailsSectionComponent["cardDetailsForm"]; identityDetails?: IdentitySectionComponent["identityForm"]; customFields?: CustomFieldsComponent["customFieldsForm"]; diff --git a/libs/vault/src/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts index 40b1cdf610..b29bf8c781 100644 --- a/libs/vault/src/cipher-form/cipher-form.stories.ts +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -11,6 +11,9 @@ import { BehaviorSubject } from "rxjs"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -96,7 +99,7 @@ const defaultConfig: CipherFormConfig = { class TestAddEditFormService implements CipherFormService { decryptCipher(): Promise { - return Promise.resolve(defaultConfig.originalCipher as any); + return Promise.resolve({ ...defaultConfig.originalCipher } as any); } async saveCipher(cipher: CipherView): Promise { await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -151,6 +154,18 @@ export default { passwordLeaked: () => Promise.resolve(0), }, }, + { + provide: DomainSettingsService, + useValue: { + defaultUriMatchStrategy$: new BehaviorSubject(UriMatchStrategy.StartsWith), + }, + }, + { + provide: AutofillSettingsServiceAbstraction, + useValue: { + autofillOnPageLoadDefault$: new BehaviorSubject(true), + }, + }, ], }), componentWrapperDecorator( diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.html b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.html new file mode 100644 index 0000000000..c6102ca2ae --- /dev/null +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.html @@ -0,0 +1,36 @@ + + +

+ {{ "autofillOptions" | i18n }} +

+
+ + + + + + + + + + {{ "autoFillOnPageLoad" | i18n }} + + + +
diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts new file mode 100644 index 0000000000..6f73ffabef --- /dev/null +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.spec.ts @@ -0,0 +1,188 @@ +import { LiveAnnouncer } from "@angular/cdk/a11y"; +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; + +import { CipherFormContainer } from "../../cipher-form-container"; + +import { AutofillOptionsComponent } from "./autofill-options.component"; + +describe("AutofillOptionsComponent", () => { + let component: AutofillOptionsComponent; + let fixture: ComponentFixture; + + let cipherFormContainer: MockProxy; + let liveAnnouncer: MockProxy; + let domainSettingsService: MockProxy; + let autofillSettingsService: MockProxy; + + beforeEach(async () => { + cipherFormContainer = mock(); + liveAnnouncer = mock(); + domainSettingsService = mock(); + domainSettingsService.defaultUriMatchStrategy$ = new BehaviorSubject(null); + + autofillSettingsService = mock(); + autofillSettingsService.autofillOnPageLoadDefault$ = new BehaviorSubject(false); + + await TestBed.configureTestingModule({ + imports: [AutofillOptionsComponent], + providers: [ + { provide: CipherFormContainer, useValue: cipherFormContainer }, + { + provide: I18nService, + useValue: { t: (...keys: string[]) => keys.filter(Boolean).join(" ") }, + }, + { provide: LiveAnnouncer, useValue: liveAnnouncer }, + { provide: DomainSettingsService, useValue: domainSettingsService }, + { provide: AutofillSettingsServiceAbstraction, useValue: autofillSettingsService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AutofillOptionsComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("registers 'autoFillOptionsForm' form with CipherFormContainer", () => { + fixture.detectChanges(); + expect(cipherFormContainer.registerChildForm).toHaveBeenCalledWith( + "autoFillOptions", + component.autofillOptionsForm, + ); + }); + + it("patches 'autoFillOptionsForm' changes to CipherFormContainer", () => { + fixture.detectChanges(); + + component.autofillOptionsForm.patchValue({ + uris: [{ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact }], + autofillOnPageLoad: true, + }); + + expect(cipherFormContainer.patchCipher).toHaveBeenCalled(); + const patchFn = cipherFormContainer.patchCipher.mock.lastCall[0]; + + const updatedCipher = patchFn(new CipherView()); + + const expectedUri = Object.assign(new LoginUriView(), { + uri: "https://example.com", + match: UriMatchStrategy.Exact, + } as LoginUriView); + + expect(updatedCipher.login.uris).toEqual([expectedUri]); + expect(updatedCipher.login.autofillOnPageLoad).toEqual(true); + }); + + it("disables 'autoFillOptionsForm' when in partial-edit mode", () => { + cipherFormContainer.config.mode = "partial-edit"; + + fixture.detectChanges(); + + expect(component.autofillOptionsForm.disabled).toBe(true); + }); + + it("initializes 'autoFillOptionsForm' with original login view values", () => { + const existingLogin = new LoginUriView(); + existingLogin.uri = "https://example.com"; + existingLogin.match = UriMatchStrategy.Exact; + + (cipherFormContainer.originalCipherView as CipherView) = new CipherView(); + cipherFormContainer.originalCipherView.login = { + autofillOnPageLoad: true, + uris: [existingLogin], + } as LoginView; + + fixture.detectChanges(); + + expect(component.autofillOptionsForm.value.uris).toEqual([ + { uri: "https://example.com", matchDetection: UriMatchStrategy.Exact }, + ]); + expect(component.autofillOptionsForm.value.autofillOnPageLoad).toEqual(true); + }); + + it("initializes 'autoFillOptionsForm' with initialValues when creating a new cipher", () => { + cipherFormContainer.config.initialValues = { loginUri: "https://example.com" }; + + fixture.detectChanges(); + + expect(component.autofillOptionsForm.value.uris).toEqual([ + { uri: "https://example.com", matchDetection: null }, + ]); + expect(component.autofillOptionsForm.value.autofillOnPageLoad).toEqual(null); + }); + + it("initializes 'autoFillOptionsForm' with an empty URI when creating a new cipher", () => { + cipherFormContainer.config.initialValues = null; + + fixture.detectChanges(); + + expect(component.autofillOptionsForm.value.uris).toEqual([{ uri: null, matchDetection: null }]); + }); + + it("updates the default autofill on page load label", () => { + fixture.detectChanges(); + expect(component["autofillOptions"][0].label).toEqual("defaultLabel no"); + + (autofillSettingsService.autofillOnPageLoadDefault$ as BehaviorSubject).next(true); + fixture.detectChanges(); + + expect(component["autofillOptions"][0].label).toEqual("defaultLabel yes"); + }); + + it("announces the addition of a new URI input", fakeAsync(() => { + fixture.detectChanges(); + + // Mock the liveAnnouncer implementation so we can resolve it manually + let resolveAnnouncer: () => void; + jest.spyOn(liveAnnouncer, "announce").mockImplementation( + () => + new Promise((resolve) => { + resolveAnnouncer = resolve; + }), + ); + + component.addUri(undefined, true); + fixture.detectChanges(); + + expect(liveAnnouncer.announce).toHaveBeenCalledWith("websiteAdded", "polite"); + + // Spy on the last URI input's focusInput method to ensure it is called + jest.spyOn(component["uriOptions"].last, "focusInput"); + resolveAnnouncer(); // Resolve the liveAnnouncer promise so that focusOnNewInput$ pipe can continue + tick(); + + expect(component["uriOptions"].last.focusInput).toHaveBeenCalled(); + })); + + it("removes URI input when remove() is called", () => { + fixture.detectChanges(); + + // Add second Uri + component.addUri(undefined, true); + + fixture.detectChanges(); + + // Remove first Uri + component.removeUri(0); + + fixture.detectChanges(); + + expect(component.autofillOptionsForm.value.uris.length).toEqual(1); + }); +}); diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts new file mode 100644 index 0000000000..389eda4c18 --- /dev/null +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts @@ -0,0 +1,189 @@ +import { LiveAnnouncer } from "@angular/cdk/a11y"; +import { AsyncPipe, NgForOf, NgIf } from "@angular/common"; +import { Component, OnInit, QueryList, ViewChildren } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { Subject, switchMap, take } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { UriMatchStrategySetting } from "@bitwarden/common/models/domain/domain-service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; +import { + CardComponent, + FormFieldModule, + IconButtonModule, + LinkModule, + SectionComponent, + SectionHeaderComponent, + SelectModule, + TypographyModule, +} from "@bitwarden/components"; + +import { CipherFormContainer } from "../../cipher-form-container"; + +import { UriOptionComponent } from "./uri-option.component"; + +interface UriField { + uri: string; + matchDetection: UriMatchStrategySetting; +} + +@Component({ + selector: "vault-autofill-options", + templateUrl: "./autofill-options.component.html", + standalone: true, + imports: [ + SectionComponent, + SectionHeaderComponent, + TypographyModule, + JslibModule, + CardComponent, + ReactiveFormsModule, + NgForOf, + FormFieldModule, + SelectModule, + IconButtonModule, + UriOptionComponent, + LinkModule, + NgIf, + AsyncPipe, + ], +}) +export class AutofillOptionsComponent implements OnInit { + /** + * List of rendered UriOptionComponents. Used for focusing newly added Uri inputs. + */ + @ViewChildren(UriOptionComponent) + protected uriOptions: QueryList; + + autofillOptionsForm = this.formBuilder.group({ + uris: this.formBuilder.array([]), + autofillOnPageLoad: [null as boolean], + }); + + protected get uriControls() { + return this.autofillOptionsForm.controls.uris.controls; + } + + protected defaultMatchDetection$ = this.domainSettingsService.defaultUriMatchStrategy$; + + protected autofillOptions: { label: string; value: boolean | null }[] = [ + { label: this.i18nService.t("default"), value: null }, + { label: this.i18nService.t("yes"), value: true }, + { label: this.i18nService.t("no"), value: false }, + ]; + + /** + * Emits when a new URI input is added to the form and should be focused. + */ + private focusOnNewInput$ = new Subject(); + + constructor( + private cipherFormContainer: CipherFormContainer, + private formBuilder: FormBuilder, + private i18nService: I18nService, + private liveAnnouncer: LiveAnnouncer, + private domainSettingsService: DomainSettingsService, + private autofillSettingsService: AutofillSettingsServiceAbstraction, + ) { + this.cipherFormContainer.registerChildForm("autoFillOptions", this.autofillOptionsForm); + + this.autofillOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { + this.cipherFormContainer.patchCipher((cipher) => { + cipher.login.uris = value.uris.map((uri: UriField) => + Object.assign(new LoginUriView(), { + uri: uri.uri, + match: uri.matchDetection, + } as LoginUriView), + ); + cipher.login.autofillOnPageLoad = value.autofillOnPageLoad; + return cipher; + }); + }); + + this.updateDefaultAutofillLabel(); + + this.focusOnNewInput$ + .pipe( + takeUntilDestroyed(), + // Wait for the new URI input to be added to the DOM + switchMap(() => this.uriOptions.changes.pipe(take(1))), + // Announce the new URI input before focusing it + switchMap(() => this.liveAnnouncer.announce(this.i18nService.t("websiteAdded"), "polite")), + ) + .subscribe(() => { + this.uriOptions?.last?.focusInput(); + }); + } + + ngOnInit() { + if (this.cipherFormContainer.originalCipherView?.login) { + this.initFromExistingCipher(this.cipherFormContainer.originalCipherView.login); + } else { + this.initNewCipher(); + } + + if (this.cipherFormContainer.config.mode === "partial-edit") { + this.autofillOptionsForm.disable(); + } + } + + private initFromExistingCipher(existingLogin: LoginView) { + existingLogin.uris?.forEach((uri) => { + this.addUri({ + uri: uri.uri, + matchDetection: uri.match, + }); + }); + this.autofillOptionsForm.patchValue({ + autofillOnPageLoad: existingLogin.autofillOnPageLoad, + }); + } + + private initNewCipher() { + this.addUri({ + uri: this.cipherFormContainer.config.initialValues?.loginUri ?? null, + matchDetection: null, + }); + this.autofillOptionsForm.patchValue({ + autofillOnPageLoad: null, + }); + } + + private updateDefaultAutofillLabel() { + this.autofillSettingsService.autofillOnPageLoadDefault$ + .pipe(takeUntilDestroyed()) + .subscribe((value: boolean) => { + const defaultOption = this.autofillOptions.find((o) => o.value === value); + + if (!defaultOption) { + return; + } + + this.autofillOptions[0].label = this.i18nService.t("defaultLabel", defaultOption.label); + // Trigger change detection to update the label in the template + this.autofillOptions = [...this.autofillOptions]; + }); + } + + /** + * Adds a new URI input to the form. + * @param uriFieldValue The initial value for the new URI input. + * @param focusNewInput If true, the new URI input will be focused after being added. + */ + addUri(uriFieldValue: UriField = { uri: null, matchDetection: null }, focusNewInput = false) { + this.autofillOptionsForm.controls.uris.push(this.formBuilder.control(uriFieldValue)); + + if (focusNewInput) { + this.focusOnNewInput$.next(); + } + } + + removeUri(i: number) { + this.autofillOptionsForm.controls.uris.removeAt(i); + } +} diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html new file mode 100644 index 0000000000..470b2881ab --- /dev/null +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html @@ -0,0 +1,35 @@ + + + {{ "websiteUri" | i18n }} + + + + + + + {{ "matchDetection" | i18n }} + + + + + diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts new file mode 100644 index 0000000000..673f7326c1 --- /dev/null +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts @@ -0,0 +1,143 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NG_VALUE_ACCESSOR } from "@angular/forms"; + +import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { UriOptionComponent } from "./uri-option.component"; + +describe("UriOptionComponent", () => { + let component: UriOptionComponent; + let fixture: ComponentFixture; + + const getToggleMatchDetectionBtn = () => + fixture.nativeElement.querySelector( + "button[data-testid='toggle-match-detection-button']", + ) as HTMLButtonElement; + + const getMatchDetectionSelect = () => + fixture.nativeElement.querySelector( + "bit-select[formControlName='matchDetection']", + ) as HTMLSelectElement; + + const getRemoveButton = () => + fixture.nativeElement.querySelector( + "button[data-testid='remove-uri-button']", + ) as HTMLButtonElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UriOptionComponent], + providers: [ + { + provide: I18nService, + useValue: { t: (...keys: string[]) => keys.filter(Boolean).join(" ") }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(UriOptionComponent); + component = fixture.componentInstance; + + // Ensure the component provides the NG_VALUE_ACCESSOR token + fixture.debugElement.injector.get(NG_VALUE_ACCESSOR); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should update the default uri match strategy label", () => { + component.defaultMatchDetection = UriMatchStrategy.Exact; + fixture.detectChanges(); + + expect(component["uriMatchOptions"][0].label).toBe("defaultLabel exact"); + + component.defaultMatchDetection = UriMatchStrategy.StartsWith; + fixture.detectChanges(); + + expect(component["uriMatchOptions"][0].label).toBe("defaultLabel startsWith"); + }); + + it("should focus the uri input when focusInput is called", () => { + fixture.detectChanges(); + jest.spyOn(component["inputElement"].nativeElement, "focus"); + component.focusInput(); + expect(component["inputElement"].nativeElement.focus).toHaveBeenCalled(); + }); + + it("should emit change and touch events when the control value changes", () => { + const changeFn = jest.fn(); + const touchFn = jest.fn(); + component.registerOnChange(changeFn); + component.registerOnTouched(touchFn); + fixture.detectChanges(); + + expect(changeFn).not.toHaveBeenCalled(); + expect(touchFn).not.toHaveBeenCalled(); + + component["uriForm"].patchValue({ uri: "https://example.com" }); + + expect(changeFn).toHaveBeenCalled(); + expect(touchFn).toHaveBeenCalled(); + }); + + it("should disable the uri form when disabled state is set", () => { + fixture.detectChanges(); + + expect(component["uriForm"].enabled).toBe(true); + + component.setDisabledState(true); + + expect(component["uriForm"].enabled).toBe(false); + }); + + describe("match detection", () => { + it("should hide the match detection select by default", () => { + fixture.detectChanges(); + expect(getMatchDetectionSelect()).toBeNull(); + }); + + it("should show the match detection select when the toggle is clicked", () => { + fixture.detectChanges(); + getToggleMatchDetectionBtn().click(); + fixture.detectChanges(); + expect(getMatchDetectionSelect()).not.toBeNull(); + }); + + it("should update the match detection button title when the toggle is clicked", () => { + component.writeValue({ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact }); + fixture.detectChanges(); + expect(getToggleMatchDetectionBtn().title).toBe("showMatchDetection https://example.com"); + getToggleMatchDetectionBtn().click(); + fixture.detectChanges(); + expect(getToggleMatchDetectionBtn().title).toBe("hideMatchDetection https://example.com"); + }); + }); + + describe("remove button", () => { + it("should show the remove button when canRemove is true", () => { + component.canRemove = true; + fixture.detectChanges(); + expect(getRemoveButton()).toBeTruthy(); + }); + + it("should hide the remove button when canRemove is false", () => { + component.canRemove = false; + fixture.detectChanges(); + expect(getRemoveButton()).toBeFalsy(); + }); + + it("should emit remove when the remove button is clicked", () => { + jest.spyOn(component.remove, "emit"); + component.canRemove = true; + fixture.detectChanges(); + getRemoveButton().click(); + expect(component.remove.emit).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts new file mode 100644 index 0000000000..b0eaf3a4c6 --- /dev/null +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts @@ -0,0 +1,164 @@ +import { NgForOf, NgIf } from "@angular/common"; +import { + Component, + ElementRef, + EventEmitter, + forwardRef, + Input, + Output, + ViewChild, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { + ControlValueAccessor, + FormBuilder, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, +} from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + UriMatchStrategy, + UriMatchStrategySetting, +} from "@bitwarden/common/models/domain/domain-service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + FormFieldModule, + IconButtonModule, + SelectComponent, + SelectModule, +} from "@bitwarden/components"; + +@Component({ + selector: "vault-autofill-uri-option", + templateUrl: "./uri-option.component.html", + standalone: true, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => UriOptionComponent), + multi: true, + }, + ], + imports: [ + FormFieldModule, + ReactiveFormsModule, + IconButtonModule, + JslibModule, + SelectModule, + NgForOf, + NgIf, + ], +}) +export class UriOptionComponent implements ControlValueAccessor { + @ViewChild("uriInput") + private inputElement: ElementRef; + + @ViewChild("matchDetectionSelect") + private matchDetectionSelect: SelectComponent; + + protected uriForm = this.formBuilder.group({ + uri: [null as string], + matchDetection: [null as UriMatchStrategySetting], + }); + + protected uriMatchOptions: { label: string; value: UriMatchStrategySetting }[] = [ + { label: this.i18nService.t("default"), value: null }, + { label: this.i18nService.t("baseDomain"), value: UriMatchStrategy.Domain }, + { label: this.i18nService.t("host"), value: UriMatchStrategy.Host }, + { label: this.i18nService.t("startsWith"), value: UriMatchStrategy.StartsWith }, + { label: this.i18nService.t("regEx"), value: UriMatchStrategy.RegularExpression }, + { label: this.i18nService.t("exact"), value: UriMatchStrategy.Exact }, + { label: this.i18nService.t("never"), value: UriMatchStrategy.Never }, + ]; + + /** + * Whether the URI can be removed from the form. If false, the remove button will be hidden. + */ + @Input({ required: true }) + canRemove: boolean; + + /** + * The user's current default match detection strategy. Will be displayed in () after "Default" + */ + @Input({ required: true }) + set defaultMatchDetection(value: UriMatchStrategySetting) { + this.uriMatchOptions[0].label = this.i18nService.t( + "defaultLabel", + this.uriMatchOptions.find((o) => o.value === value)?.label, + ); + } + + /** + * Emits when the remove button is clicked and URI should be removed from the form. + */ + @Output() + remove = new EventEmitter(); + + protected showMatchDetection = false; + + protected toggleMatchDetection() { + this.showMatchDetection = !this.showMatchDetection; + if (this.showMatchDetection) { + setTimeout(() => this.matchDetectionSelect?.select?.focus(), 0); + } + } + + protected get toggleTitle() { + return this.showMatchDetection + ? this.i18nService.t("hideMatchDetection", this.uriForm.value.uri) + : this.i18nService.t("showMatchDetection", this.uriForm.value.uri); + } + + // NG_VALUE_ACCESSOR implementation + private onChange: any = () => {}; + private onTouched: any = () => {}; + + constructor( + private formBuilder: FormBuilder, + private i18nService: I18nService, + ) { + this.uriForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { + this.onChange(value); + }); + + this.uriForm.statusChanges.pipe(takeUntilDestroyed()).subscribe(() => { + this.onTouched(); + }); + } + + focusInput() { + if (this.inputElement?.nativeElement) { + this.inputElement.nativeElement.focus(); + } + } + + removeUri() { + this.remove.emit(); + } + + // NG_VALUE_ACCESSOR implementation + writeValue(value: any): void { + if (value) { + this.uriForm.setValue( + { + uri: value.uri ?? "", + matchDetection: value.match ?? null, + }, + { emitEvent: false }, + ); + } + } + + registerOnChange(fn: () => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState?(isDisabled: boolean): void { + isDisabled ? this.uriForm.disable() : this.uriForm.enable(); + } +} 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 534b08ad43..2a65218e82 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -190,7 +190,10 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci this.config.originalCipher, ); - this.updatedCipherView = Object.assign(this.updatedCipherView, this.originalCipherView); + // decryptCipher again to ensure we have a separate instance of CipherView + this.updatedCipherView = await this.addEditFormService.decryptCipher( + this.config.originalCipher, + ); if (this.config.mode === "clone") { this.updatedCipherView.id = null; diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html index c6218d4189..87808bd7f6 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html @@ -108,3 +108,5 @@ + + diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts index 31a0bb8ab5..a723fd7dc8 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.spec.ts @@ -1,4 +1,5 @@ import { DatePipe } from "@angular/common"; +import { Component } from "@angular/core"; import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; @@ -12,9 +13,17 @@ import { ToastService } from "@bitwarden/components"; import { CipherFormGenerationService } from "../../abstractions/cipher-form-generation.service"; import { TotpCaptureService } from "../../abstractions/totp-capture.service"; import { CipherFormContainer } from "../../cipher-form-container"; +import { AutofillOptionsComponent } from "../autofill-options/autofill-options.component"; import { LoginDetailsSectionComponent } from "./login-details-section.component"; +@Component({ + standalone: true, + selector: "vault-autofill-options", + template: "", +}) +class MockAutoFillOptionsComponent {} + describe("LoginDetailsSectionComponent", () => { let component: LoginDetailsSectionComponent; let fixture: ComponentFixture; @@ -45,7 +54,16 @@ describe("LoginDetailsSectionComponent", () => { { provide: TotpCaptureService, useValue: totpCaptureService }, { provide: I18nService, useValue: i18nService }, ], - }).compileComponents(); + }) + .overrideComponent(LoginDetailsSectionComponent, { + remove: { + imports: [AutofillOptionsComponent], + }, + add: { + imports: [MockAutoFillOptionsComponent], + }, + }) + .compileComponents(); fixture = TestBed.createComponent(LoginDetailsSectionComponent); component = fixture.componentInstance; diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts index 57d2243820..839b951210 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.ts @@ -24,6 +24,7 @@ import { import { CipherFormGenerationService } from "../../abstractions/cipher-form-generation.service"; import { TotpCaptureService } from "../../abstractions/totp-capture.service"; import { CipherFormContainer } from "../../cipher-form-container"; +import { AutofillOptionsComponent } from "../autofill-options/autofill-options.component"; @Component({ selector: "vault-login-details-section", @@ -41,6 +42,7 @@ import { CipherFormContainer } from "../../cipher-form-container"; AsyncActionsModule, NgIf, PopoverModule, + AutofillOptionsComponent, ], }) export class LoginDetailsSectionComponent implements OnInit {