[PM-7896] Cipher Form - Additional Options section (#9928)

* [PM-7896] Adjust cipher form container to expose config and original cipher view for children

* [PM-7896] Add initial additional options section

* [PM-7896] Add tests

* [PM-7896] Add TODO comments for Custom Fields

* [PM-7896] Hide password reprompt checkbox when unavailable

* [PM-7896] Fix storybook
This commit is contained in:
Shane Melton 2024-07-11 15:01:24 -07:00 committed by GitHub
parent 050f8f4bdc
commit 9dda29fb9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 235 additions and 11 deletions

View File

@ -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<K extends keyof CipherForm>(
name: K,
group: Exclude<CipherForm[K], undefined>,

View File

@ -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(

View File

@ -0,0 +1,20 @@
<bit-section [formGroup]="additionalOptionsForm">
<bit-section-header>
<h2 bitTypography="h5">{{ "additionalOptions" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-form-field>
<bit-label>{{ "notes" | i18n }}</bit-label>
<textarea bitInput formControlName="notes"></textarea>
</bit-form-field>
<bit-form-control *ngIf="passwordRepromptEnabled$ | async">
<input type="checkbox" bitCheckbox formControlName="reprompt" />
<bit-label>{{ "passwordPrompt" | i18n }}</bit-label>
</bit-form-control>
<!-- TODO: Add "+ Add Field" button for Custom Fields - PM-8803 -->
</bit-card>
</bit-section>
<!-- TODO: Add Custom Fields section component - PM-8803 -->

View File

@ -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<AdditionalOptionsSectionComponent>;
let cipherFormProvider: MockProxy<CipherFormContainer>;
let passwordRepromptService: MockProxy<PasswordRepromptService>;
let passwordRepromptEnabled$: BehaviorSubject<boolean>;
beforeEach(async () => {
cipherFormProvider = mock<CipherFormContainer>();
passwordRepromptService = mock<PasswordRepromptService>();
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<I18nService>() },
],
}).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();
});
});

View File

@ -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();
}
}
}

View File

@ -18,6 +18,8 @@
[disabled]="config.mode === 'partial-edit'"
></vault-card-details-section>
<vault-additional-options-section></vault-additional-options-section>
<!-- Attachments are only available for existing ciphers -->
<ng-container *ngIf="config.mode == 'edit'">
<ng-content select="[slot=attachment-button]"></ng-content>

View File

@ -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<CipherView>();
/**
* 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<CipherForm>({});
/**
* 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.

View File

@ -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$);
}
}