[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:
parent
050f8f4bdc
commit
9dda29fb9c
|
@ -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>,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 -->
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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$);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue