mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 13:53:34 +00:00
[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:
@@ -1,5 +1,7 @@
|
|||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
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 { CardDetailsSectionComponent } from "./components/card-details-section/card-details-section.component";
|
||||||
import { IdentitySectionComponent } from "./components/identity/identity.component";
|
import { IdentitySectionComponent } from "./components/identity/identity.component";
|
||||||
import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.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 = {
|
export type CipherForm = {
|
||||||
itemDetails?: ItemDetailsSectionComponent["itemDetailsForm"];
|
itemDetails?: ItemDetailsSectionComponent["itemDetailsForm"];
|
||||||
|
additionalOptions?: AdditionalOptionsSectionComponent["additionalOptionsForm"];
|
||||||
cardDetails?: CardDetailsSectionComponent["cardDetailsForm"];
|
cardDetails?: CardDetailsSectionComponent["cardDetailsForm"];
|
||||||
identityDetails?: IdentitySectionComponent["identityForm"];
|
identityDetails?: IdentitySectionComponent["identityForm"];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A container for the {@link CipherForm} that allows for registration of child form groups and patching of the cipher
|
* 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
|
* 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
|
* @Outputs(). It allows child forms to define their own structure and validation rules, while still being able to
|
||||||
* update the parent cipher.
|
* update the parent cipher.
|
||||||
*/
|
*/
|
||||||
export abstract class CipherFormContainer {
|
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>(
|
abstract registerChildForm<K extends keyof CipherForm>(
|
||||||
name: K,
|
name: K,
|
||||||
group: Exclude<CipherForm[K], undefined>,
|
group: Exclude<CipherForm[K], undefined>,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
moduleMetadata,
|
moduleMetadata,
|
||||||
StoryObj,
|
StoryObj,
|
||||||
} from "@storybook/angular";
|
} from "@storybook/angular";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
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 { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||||
import { AsyncActionsModule, ButtonModule, ToastService } from "@bitwarden/components";
|
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 { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests";
|
||||||
|
|
||||||
import { CipherFormService } from "./abstractions/cipher-form.service";
|
import { CipherFormService } from "./abstractions/cipher-form.service";
|
||||||
@@ -71,6 +72,7 @@ const defaultConfig: CipherFormConfig = {
|
|||||||
folderId: "folder2",
|
folderId: "folder2",
|
||||||
collectionIds: ["col1"],
|
collectionIds: ["col1"],
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
notes: "Example notes",
|
||||||
} as unknown as Cipher,
|
} as unknown as Cipher,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,6 +107,12 @@ export default {
|
|||||||
showToast: action("showToast"),
|
showToast: action("showToast"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: PasswordRepromptService,
|
||||||
|
useValue: {
|
||||||
|
enabled$: new BehaviorSubject(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
componentWrapperDecorator(
|
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'"
|
[disabled]="config.mode === 'partial-edit'"
|
||||||
></vault-card-details-section>
|
></vault-card-details-section>
|
||||||
|
|
||||||
|
<vault-additional-options-section></vault-additional-options-section>
|
||||||
|
|
||||||
<!-- Attachments are only available for existing ciphers -->
|
<!-- Attachments are only available for existing ciphers -->
|
||||||
<ng-container *ngIf="config.mode == 'edit'">
|
<ng-container *ngIf="config.mode == 'edit'">
|
||||||
<ng-content select="[slot=attachment-button]"></ng-content>
|
<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 { CipherFormService } from "../abstractions/cipher-form.service";
|
||||||
import { CipherForm, CipherFormContainer } from "../cipher-form-container";
|
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 { CardDetailsSectionComponent } from "./card-details-section/card-details-section.component";
|
||||||
import { IdentitySectionComponent } from "./identity/identity.component";
|
import { IdentitySectionComponent } from "./identity/identity.component";
|
||||||
import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component";
|
import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component";
|
||||||
@@ -62,6 +63,7 @@ import { ItemDetailsSectionComponent } from "./item-details/item-details-section
|
|||||||
CardDetailsSectionComponent,
|
CardDetailsSectionComponent,
|
||||||
IdentitySectionComponent,
|
IdentitySectionComponent,
|
||||||
NgIf,
|
NgIf,
|
||||||
|
AdditionalOptionsSectionComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, CipherFormContainer {
|
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>();
|
@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.
|
* The form group for the cipher. Starts empty and is populated by child components via the `registerChildForm` method.
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected cipherForm = this.formBuilder.group<CipherForm>({});
|
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
|
* 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.
|
* by child components via the `patchCipher` method.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable } from "@angular/core";
|
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 { 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 { CipherRepromptType } from "@bitwarden/common/vault/enums";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService } from "@bitwarden/components";
|
||||||
@@ -19,6 +20,10 @@ export class PasswordRepromptService {
|
|||||||
private userVerificationService: UserVerificationService,
|
private userVerificationService: UserVerificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
enabled$ = Utils.asyncToObservable(() =>
|
||||||
|
this.userVerificationService.hasMasterPasswordAndMasterKeyHash(),
|
||||||
|
);
|
||||||
|
|
||||||
protectedFields() {
|
protectedFields() {
|
||||||
return ["TOTP", "Password", "H_Field", "Card Number", "Security Code"];
|
return ["TOTP", "Password", "H_Field", "Card Number", "Security Code"];
|
||||||
}
|
}
|
||||||
@@ -45,7 +50,7 @@ export class PasswordRepromptService {
|
|||||||
return result === true;
|
return result === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async enabled() {
|
enabled() {
|
||||||
return await this.userVerificationService.hasMasterPasswordAndMasterKeyHash();
|
return firstValueFrom(this.enabled$);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user