diff --git a/libs/vault/src/cipher-form/cipher-form-container.ts b/libs/vault/src/cipher-form/cipher-form-container.ts index 0e4e34a6be..a002e39d3e 100644 --- a/libs/vault/src/cipher-form/cipher-form-container.ts +++ b/libs/vault/src/cipher-form/cipher-form-container.ts @@ -1,5 +1,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 { CardDetailsSectionComponent } from "./components/card-details-section/card-details-section.component"; import { IdentitySectionComponent } from "./components/identity/identity.component"; import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component"; @@ -10,19 +12,31 @@ import { ItemDetailsSectionComponent } from "./components/item-details/item-deta */ export type CipherForm = { itemDetails?: ItemDetailsSectionComponent["itemDetailsForm"]; + additionalOptions?: AdditionalOptionsSectionComponent["additionalOptionsForm"]; cardDetails?: CardDetailsSectionComponent["cardDetailsForm"]; identityDetails?: IdentitySectionComponent["identityForm"]; }; /** * A container for the {@link CipherForm} that allows for registration of child form groups and patching of the cipher - * to be updated/created. Child form components inject this container in order to register themselves with the parent form. + * to be updated/created. Child form components inject this container in order to register themselves with the parent form + * and access configuration options. * * This is an alternative to passing the form groups down through the component tree via @Inputs() and form updates via * @Outputs(). It allows child forms to define their own structure and validation rules, while still being able to * update the parent cipher. */ export abstract class CipherFormContainer { + /** + * The configuration for the cipher form. + */ + readonly config: CipherFormConfig; + + /** + * The original cipher that is being edited/cloned. Used to pre-populate the form and compare changes. + */ + readonly originalCipherView: CipherView | null; + abstract registerChildForm( name: K, group: Exclude, diff --git a/libs/vault/src/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts index 47a1e90abc..67011b5a47 100644 --- a/libs/vault/src/cipher-form/cipher-form.stories.ts +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -7,6 +7,7 @@ import { moduleMetadata, StoryObj, } from "@storybook/angular"; +import { BehaviorSubject } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -15,7 +16,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { AsyncActionsModule, ButtonModule, ToastService } from "@bitwarden/components"; -import { CipherFormConfig } from "@bitwarden/vault"; +import { CipherFormConfig, PasswordRepromptService } from "@bitwarden/vault"; import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests"; import { CipherFormService } from "./abstractions/cipher-form.service"; @@ -71,6 +72,7 @@ const defaultConfig: CipherFormConfig = { folderId: "folder2", collectionIds: ["col1"], favorite: false, + notes: "Example notes", } as unknown as Cipher, }; @@ -105,6 +107,12 @@ export default { showToast: action("showToast"), }, }, + { + provide: PasswordRepromptService, + useValue: { + enabled$: new BehaviorSubject(true), + }, + }, ], }), componentWrapperDecorator( diff --git a/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.html b/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.html new file mode 100644 index 0000000000..d9c3a00204 --- /dev/null +++ b/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.html @@ -0,0 +1,20 @@ + + +

{{ "additionalOptions" | i18n }}

+
+ + + + {{ "notes" | i18n }} + + + + + {{ "passwordPrompt" | i18n }} + + + + +
+ + diff --git a/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.spec.ts b/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.spec.ts new file mode 100644 index 0000000000..71f8c4f197 --- /dev/null +++ b/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.spec.ts @@ -0,0 +1,99 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { PasswordRepromptService } from "../../../services/password-reprompt.service"; +import { CipherFormContainer } from "../../cipher-form-container"; + +import { AdditionalOptionsSectionComponent } from "./additional-options-section.component"; + +describe("AdditionalOptionsSectionComponent", () => { + let component: AdditionalOptionsSectionComponent; + let fixture: ComponentFixture; + let cipherFormProvider: MockProxy; + let passwordRepromptService: MockProxy; + let passwordRepromptEnabled$: BehaviorSubject; + + beforeEach(async () => { + cipherFormProvider = mock(); + + passwordRepromptService = mock(); + passwordRepromptEnabled$ = new BehaviorSubject(true); + passwordRepromptService.enabled$ = passwordRepromptEnabled$; + + await TestBed.configureTestingModule({ + imports: [AdditionalOptionsSectionComponent], + providers: [ + { provide: CipherFormContainer, useValue: cipherFormProvider }, + { provide: PasswordRepromptService, useValue: passwordRepromptService }, + { provide: I18nService, useValue: mock() }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AdditionalOptionsSectionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("registers 'additionalOptionsForm' form with CipherFormContainer", () => { + expect(cipherFormProvider.registerChildForm).toHaveBeenCalledWith( + "additionalOptions", + component.additionalOptionsForm, + ); + }); + + it("patches 'additionalOptionsForm' changes to CipherFormContainer", () => { + component.additionalOptionsForm.patchValue({ + notes: "new notes", + reprompt: true, + }); + + expect(cipherFormProvider.patchCipher).toHaveBeenCalledWith({ + notes: "new notes", + reprompt: 1, + }); + }); + + it("disables 'additionalOptionsForm' when in partial-edit mode", () => { + cipherFormProvider.config.mode = "partial-edit"; + + component.ngOnInit(); + + expect(component.additionalOptionsForm.disabled).toBe(true); + }); + + it("initializes 'additionalOptionsForm' with original cipher view values", () => { + (cipherFormProvider.originalCipherView as any) = { + notes: "original notes", + reprompt: 1, + } as CipherView; + + component.ngOnInit(); + + expect(component.additionalOptionsForm.value).toEqual({ + notes: "original notes", + reprompt: true, + }); + }); + + it("hides password reprompt checkbox when disabled", () => { + passwordRepromptEnabled$.next(true); + fixture.detectChanges(); + + let checkbox = fixture.nativeElement.querySelector("input[formControlName='reprompt']"); + expect(checkbox).not.toBeNull(); + + passwordRepromptEnabled$.next(false); + fixture.detectChanges(); + + checkbox = fixture.nativeElement.querySelector("input[formControlName='reprompt']"); + expect(checkbox).toBeNull(); + }); +}); diff --git a/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.ts b/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.ts new file mode 100644 index 0000000000..9cd1c2ac5c --- /dev/null +++ b/libs/vault/src/cipher-form/components/additional-options/additional-options-section.component.ts @@ -0,0 +1,75 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { shareReplay } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums"; +import { + CardComponent, + CheckboxModule, + FormFieldModule, + SectionComponent, + SectionHeaderComponent, + TypographyModule, +} from "@bitwarden/components"; + +import { PasswordRepromptService } from "../../../services/password-reprompt.service"; +import { CipherFormContainer } from "../../cipher-form-container"; + +@Component({ + selector: "vault-additional-options-section", + templateUrl: "./additional-options-section.component.html", + standalone: true, + imports: [ + SectionComponent, + SectionHeaderComponent, + TypographyModule, + JslibModule, + CardComponent, + FormFieldModule, + ReactiveFormsModule, + CheckboxModule, + CommonModule, + ], +}) +export class AdditionalOptionsSectionComponent implements OnInit { + additionalOptionsForm = this.formBuilder.group({ + notes: [null as string], + reprompt: [false], + }); + + passwordRepromptEnabled$ = this.passwordRepromptService.enabled$.pipe( + shareReplay({ refCount: false, bufferSize: 1 }), + ); + + constructor( + private cipherFormContainer: CipherFormContainer, + private formBuilder: FormBuilder, + private passwordRepromptService: PasswordRepromptService, + ) { + this.cipherFormContainer.registerChildForm("additionalOptions", this.additionalOptionsForm); + + this.additionalOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { + this.cipherFormContainer.patchCipher({ + notes: value.notes, + reprompt: value.reprompt ? CipherRepromptType.Password : CipherRepromptType.None, + }); + }); + } + + ngOnInit() { + if (this.cipherFormContainer.originalCipherView) { + this.additionalOptionsForm.patchValue({ + notes: this.cipherFormContainer.originalCipherView.notes, + reprompt: + this.cipherFormContainer.originalCipherView.reprompt === CipherRepromptType.Password, + }); + } + + if (this.cipherFormContainer.config.mode === "partial-edit") { + this.additionalOptionsForm.disable(); + } + } +} 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 8b5d470899..669f3c8b96 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.html +++ b/libs/vault/src/cipher-form/components/cipher-form.component.html @@ -18,6 +18,8 @@ [disabled]="config.mode === 'partial-edit'" > + + 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 3307425e66..9d5e0684d2 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -35,6 +35,7 @@ import { CipherFormConfig } from "../abstractions/cipher-form-config.service"; import { CipherFormService } from "../abstractions/cipher-form.service"; import { CipherForm, CipherFormContainer } from "../cipher-form-container"; +import { AdditionalOptionsSectionComponent } from "./additional-options/additional-options-section.component"; import { CardDetailsSectionComponent } from "./card-details-section/card-details-section.component"; import { IdentitySectionComponent } from "./identity/identity.component"; import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component"; @@ -62,6 +63,7 @@ import { ItemDetailsSectionComponent } from "./item-details/item-details-section CardDetailsSectionComponent, IdentitySectionComponent, NgIf, + AdditionalOptionsSectionComponent, ], }) export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, CipherFormContainer { @@ -91,18 +93,17 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci */ @Output() cipherSaved = new EventEmitter(); + /** + * The original cipher being edited or cloned. Null for add mode. + */ + originalCipherView: CipherView | null; + /** * The form group for the cipher. Starts empty and is populated by child components via the `registerChildForm` method. * @protected */ protected cipherForm = this.formBuilder.group({}); - /** - * The original cipher being edited or cloned. Null for add mode. - * @protected - */ - protected originalCipherView: CipherView | null; - /** * The value of the updated cipher. Starts as a new cipher (or clone of originalCipher) and is updated * by child components via the `patchCipher` method. diff --git a/libs/vault/src/services/password-reprompt.service.ts b/libs/vault/src/services/password-reprompt.service.ts index 8621c436ba..6583d0787f 100644 --- a/libs/vault/src/services/password-reprompt.service.ts +++ b/libs/vault/src/services/password-reprompt.service.ts @@ -1,7 +1,8 @@ import { Injectable } from "@angular/core"; -import { lastValueFrom } from "rxjs"; +import { firstValueFrom, lastValueFrom } from "rxjs"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherRepromptType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService } from "@bitwarden/components"; @@ -19,6 +20,10 @@ export class PasswordRepromptService { private userVerificationService: UserVerificationService, ) {} + enabled$ = Utils.asyncToObservable(() => + this.userVerificationService.hasMasterPasswordAndMasterKeyHash(), + ); + protectedFields() { return ["TOTP", "Password", "H_Field", "Card Number", "Security Code"]; } @@ -45,7 +50,7 @@ export class PasswordRepromptService { return result === true; } - async enabled() { - return await this.userVerificationService.hasMasterPasswordAndMasterKeyHash(); + enabled() { + return firstValueFrom(this.enabled$); } }