From 90de9dd07afeedb7bb4b362c2dcfef4b52921469 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Tue, 16 Jul 2024 23:12:46 +0200 Subject: [PATCH 1/6] Create browsers SendV2 component (#10136) Co-authored-by: Daniel James Smith --- apps/browser/src/_locales/en/messages.json | 8 +++ apps/browser/src/popup/app-routing.module.ts | 6 +-- .../tools/popup/send/send-v2.component.html | 21 ++++++++ .../src/tools/popup/send/send-v2.component.ts | 52 +++++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 apps/browser/src/tools/popup/send/send-v2.component.html create mode 100644 apps/browser/src/tools/popup/send/send-v2.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 6cebe0e231..69c3a525f9 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2765,6 +2765,14 @@ "deviceTrusted": { "message": "Device trusted" }, + "sendsNoItemsTitle": { + "message": "No active Sends", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendsNoItemsMessage": { + "message": "Use Send to securely share encrypted information with anyone.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "inputRequired": { "message": "Input is required." }, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 8645cb797b..12d92249fe 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -48,6 +48,7 @@ import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/pass import { SendAddEditComponent } from "../tools/popup/send/send-add-edit.component"; import { SendGroupingsComponent } from "../tools/popup/send/send-groupings.component"; import { SendTypeComponent } from "../tools/popup/send/send-type.component"; +import { SendV2Component } from "../tools/popup/send/send-v2.component"; import { AboutPageV2Component } from "../tools/popup/settings/about-page/about-page-v2.component"; import { AboutPageComponent } from "../tools/popup/settings/about-page/about-page.component"; import { MoreFromBitwardenPageV2Component } from "../tools/popup/settings/about-page/more-from-bitwarden-page-v2.component"; @@ -450,12 +451,11 @@ const routes: Routes = [ canActivate: [AuthGuard], data: { state: "tabs_settings" }, }), - { + ...extensionRefreshSwap(SendGroupingsComponent, SendV2Component, { path: "send", - component: SendGroupingsComponent, canActivate: [AuthGuard], data: { state: "tabs_send" }, - }, + }), ], }), { diff --git a/apps/browser/src/tools/popup/send/send-v2.component.html b/apps/browser/src/tools/popup/send/send-v2.component.html new file mode 100644 index 0000000000..3499f8c32e --- /dev/null +++ b/apps/browser/src/tools/popup/send/send-v2.component.html @@ -0,0 +1,21 @@ + + + + + + + + + + +
+ + {{ "sendsNoItemsTitle" | i18n }} + {{ "sendsNoItemsMessage" | i18n }} + + +
+
diff --git a/apps/browser/src/tools/popup/send/send-v2.component.ts b/apps/browser/src/tools/popup/send/send-v2.component.ts new file mode 100644 index 0000000000..fba14b762b --- /dev/null +++ b/apps/browser/src/tools/popup/send/send-v2.component.ts @@ -0,0 +1,52 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { RouterLink } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { ButtonModule, NoItemsModule } from "@bitwarden/components"; +import { NoSendsIcon, NewSendDropdownComponent } from "@bitwarden/send-ui"; + +import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; +import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; +import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; + +enum SendsListState { + Empty, +} + +@Component({ + templateUrl: "send-v2.component.html", + standalone: true, + imports: [ + PopupPageComponent, + PopupHeaderComponent, + PopOutComponent, + CurrentAccountComponent, + NoItemsModule, + JslibModule, + CommonModule, + ButtonModule, + RouterLink, + NewSendDropdownComponent, + ], +}) +export class SendV2Component implements OnInit, OnDestroy { + sendType = SendType; + + /** Visual state of the Sends list */ + protected sendsListState: SendsListState | null = null; + + protected noItemIcon = NoSendsIcon; + + protected SendsListStateEnum = SendsListState; + + constructor() { + this.sendsListState = SendsListState.Empty; + } + + ngOnInit(): void {} + + ngOnDestroy(): void {} +} From 7dc41c0c346266e24be96cda46e5ef129f636d3e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 17 Jul 2024 14:10:30 +1000 Subject: [PATCH 2/6] [deps] AC: Update webpack to v5.93.0 (#9799) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index bae2ec44e9..ef366f224d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -183,7 +183,7 @@ "url": "0.11.3", "util": "0.12.5", "wait-on": "7.2.0", - "webpack": "5.92.0", + "webpack": "5.93.0", "webpack-cli": "5.1.4", "webpack-dev-server": "5.0.4", "webpack-node-externals": "3.0.0" @@ -39347,9 +39347,9 @@ } }, "node_modules/webpack": { - "version": "5.92.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.0.tgz", - "integrity": "sha512-Bsw2X39MYIgxouNATyVpCNVWBCuUwDgWtN78g6lSdPJRLaQ/PUVm/oXcaRAyY/sMFoKFQrsPeqvTizWtq7QPCA==", + "version": "5.93.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.93.0.tgz", + "integrity": "sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 51afc8f48b..ed8ebcef2f 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "url": "0.11.3", "util": "0.12.5", "wait-on": "7.2.0", - "webpack": "5.92.0", + "webpack": "5.93.0", "webpack-cli": "5.1.4", "webpack-dev-server": "5.0.4", "webpack-node-externals": "3.0.0" From a1c5cc6dbf25ead9ac55545eed335e33cd906fc7 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 17 Jul 2024 14:13:03 +0200 Subject: [PATCH 3/6] Fix key rotation being broken due to master key validation (#10135) --- .../app/auth/settings/change-password.component.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/auth/settings/change-password.component.ts b/apps/web/src/app/auth/settings/change-password.component.ts index aa27588691..d8cd59435f 100644 --- a/apps/web/src/app/auth/settings/change-password.component.ts +++ b/apps/web/src/app/auth/settings/change-password.component.ts @@ -16,7 +16,9 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { HashPurpose } from "@bitwarden/common/platform/enums"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; @@ -178,6 +180,13 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent { await this.kdfConfigService.getKdfConfig(), ); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))); + const newLocalKeyHash = await this.cryptoService.hashMasterKey( + this.masterPassword, + newMasterKey, + HashPurpose.LocalAuthorization, + ); + const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey); if (userKey == null) { this.platformUtilsService.showToast( @@ -199,7 +208,10 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent { try { if (this.rotateUserKey) { - this.formPromise = this.apiService.postPassword(request).then(() => { + this.formPromise = this.apiService.postPassword(request).then(async () => { + // we need to save this for local masterkey verification during rotation + await this.masterPasswordService.setMasterKeyHash(newLocalKeyHash, userId as UserId); + await this.masterPasswordService.setMasterKey(newMasterKey, userId as UserId); return this.updateKey(); }); } else { From 83d141c914006fce1c3bae55f0a2f0ab65e1b273 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 17 Jul 2024 09:11:42 -0500 Subject: [PATCH 4/6] [PM-8803] Edit Custom Fields (#10054) * initial add of custom fields * add fields for custom field * integrate custom field into cipher form service for text fields * add hidden field type * add boolean custom field * add linked option type * add testids for automated testing * add edit option for each custom field * update dialog component name to match add/edit nature * add delete button for fields * initial add of drag and drop * collect tailwind styles from vault components * add drag and drop functionality with announcement * add reorder via keyboard * update tests to match functionality * account for partial edit of custom fields * fix change detection for new fields * add label's to the edit/reorder translations * update dynamic heading to be inline * add validation/required for field label * disable toggle button on hidden fields when the user cannot view passwords * remove the need for passing `updatedCipherView` by only using a single instance of `CustomFieldsComponent` * lint fix * use bitLink styles rather than manually defining tailwind classes * use submit action, no duplicated button and allows for form submission via enter * add documentation for `newField` --- apps/browser/src/_locales/en/messages.json | 100 +++++ apps/browser/tailwind.config.js | 1 + .../src/cipher-form/cipher-form-container.ts | 2 + .../additional-options-section.component.html | 13 +- ...ditional-options-section.component.spec.ts | 20 +- .../additional-options-section.component.ts | 32 +- .../components/cipher-form.component.ts | 1 + ...dd-edit-custom-field-dialog.component.html | 48 +++ ...edit-custom-field-dialog.component.spec.ts | 72 ++++ .../add-edit-custom-field-dialog.component.ts | 120 ++++++ .../custom-fields.component.html | 111 ++++++ .../custom-fields.component.spec.ts | 373 ++++++++++++++++++ .../custom-fields/custom-fields.component.ts | 334 ++++++++++++++++ 13 files changed, 1223 insertions(+), 4 deletions(-) create mode 100644 libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html create mode 100644 libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.spec.ts create mode 100644 libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.ts create mode 100644 libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html create mode 100644 libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.spec.ts create mode 100644 libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 69c3a525f9..5b306fdb2a 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3642,5 +3642,105 @@ }, "loading": { "message": "Loading" + }, + "addField": { + "message": "Add field" + }, + "add": { + "message": "Add" + }, + "fieldType": { + "message": "Field type" + }, + "fieldLabel": { + "message": "Field label" + }, + "textHelpText": { + "message": "Use text fields for data like security questions" + }, + "hiddenHelpText": { + "message": "Use hidden fields for sensitive data like a password" + }, + "checkBoxHelpText":{ + "message": "Use checkboxes if you'd like to auto-fill a form's checkbox, like a remember email" + }, + "linkedHelpText": { + "message": "Use a linked field when you are experiencing auto-fill issues for a specific website." + }, + "linkedLabelHelpText": { + "message": "Enter the the field's html id, name, aria-label, or placeholder." + }, + "editField": { + "message": "Edit field" + }, + "editFieldLabel": { + "message": "Edit $LABEL$", + "placeholders": { + "label": { + "content": "$1", + "example": "Custom field" + } + } + }, + "deleteCustomField": { + "message": "Delete $LABEL$", + "placeholders": { + "label": { + "content": "$1", + "example": "Custom field" + } + } + }, + "fieldAdded": { + "message": "$LABEL$ added", + "placeholders": { + "label": { + "content": "$1", + "example": "Custom field" + } + } + }, + "reorderToggleButton": { + "message": "Reorder $LABEL$. Use arrow key to move item up or down.", + "placeholders": { + "label": { + "content": "$1", + "example": "Custom field" + } + } + }, + "reorderFieldUp":{ + "message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$", + "placeholders": { + "label": { + "content": "$1", + "example": "Custom field" + }, + "index": { + "content": "$2", + "example": "1" + }, + "length": { + "content": "$3", + "example": "3" + } + } + }, + "reorderFieldDown":{ + "message": "$LABEL$ moved down, position $INDEX$ of $LENGTH$", + "placeholders": { + "label": { + "content": "$1", + "example": "Custom field" + }, + "index": { + "content": "$2", + "example": "1" + }, + "length": { + "content": "$3", + "example": "3" + } + } } } diff --git a/apps/browser/tailwind.config.js b/apps/browser/tailwind.config.js index db1dd55694..c0baf274a2 100644 --- a/apps/browser/tailwind.config.js +++ b/apps/browser/tailwind.config.js @@ -6,6 +6,7 @@ config.content = [ "../../libs/components/src/**/*.{html,ts}", "../../libs/auth/src/**/*.{html,ts}", "../../libs/angular/src/**/*.{html,ts}", + "../../libs/vault/src/**/*.{html,ts}", ]; module.exports = config; diff --git a/libs/vault/src/cipher-form/cipher-form-container.ts b/libs/vault/src/cipher-form/cipher-form-container.ts index a002e39d3e..9655b70bbb 100644 --- a/libs/vault/src/cipher-form/cipher-form-container.ts +++ b/libs/vault/src/cipher-form/cipher-form-container.ts @@ -3,6 +3,7 @@ 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 { CustomFieldsComponent } from "./components/custom-fields/custom-fields.component"; import { IdentitySectionComponent } from "./components/identity/identity.component"; import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component"; @@ -15,6 +16,7 @@ export type CipherForm = { additionalOptions?: AdditionalOptionsSectionComponent["additionalOptionsForm"]; cardDetails?: CardDetailsSectionComponent["cardDetailsForm"]; identityDetails?: IdentitySectionComponent["identityForm"]; + customFields?: CustomFieldsComponent["customFieldsForm"]; }; /** 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 index d9c3a00204..9f162cb25e 100644 --- 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 @@ -13,8 +13,17 @@ {{ "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 index 71f8c4f197..d488fc9db9 100644 --- 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 @@ -1,3 +1,4 @@ +import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; @@ -7,9 +8,17 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PasswordRepromptService } from "../../../services/password-reprompt.service"; import { CipherFormContainer } from "../../cipher-form-container"; +import { CustomFieldsComponent } from "../custom-fields/custom-fields.component"; import { AdditionalOptionsSectionComponent } from "./additional-options-section.component"; +@Component({ + standalone: true, + selector: "vault-custom-fields", + template: "", +}) +class MockCustomFieldsComponent {} + describe("AdditionalOptionsSectionComponent", () => { let component: AdditionalOptionsSectionComponent; let fixture: ComponentFixture; @@ -31,7 +40,16 @@ describe("AdditionalOptionsSectionComponent", () => { { provide: PasswordRepromptService, useValue: passwordRepromptService }, { provide: I18nService, useValue: mock() }, ], - }).compileComponents(); + }) + .overrideComponent(AdditionalOptionsSectionComponent, { + remove: { + imports: [CustomFieldsComponent], + }, + add: { + imports: [MockCustomFieldsComponent], + }, + }) + .compileComponents(); fixture = TestBed.createComponent(AdditionalOptionsSectionComponent); component = fixture.componentInstance; 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 index 9cd1c2ac5c..6c061e1eea 100644 --- 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 @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; +import { ChangeDetectorRef, Component, OnInit, ViewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { shareReplay } from "rxjs"; @@ -10,6 +10,7 @@ import { CardComponent, CheckboxModule, FormFieldModule, + LinkModule, SectionComponent, SectionHeaderComponent, TypographyModule, @@ -17,12 +18,14 @@ import { import { PasswordRepromptService } from "../../../services/password-reprompt.service"; import { CipherFormContainer } from "../../cipher-form-container"; +import { CustomFieldsComponent } from "../custom-fields/custom-fields.component"; @Component({ selector: "vault-additional-options-section", templateUrl: "./additional-options-section.component.html", standalone: true, imports: [ + CommonModule, SectionComponent, SectionHeaderComponent, TypographyModule, @@ -32,9 +35,13 @@ import { CipherFormContainer } from "../../cipher-form-container"; ReactiveFormsModule, CheckboxModule, CommonModule, + CustomFieldsComponent, + LinkModule, ], }) export class AdditionalOptionsSectionComponent implements OnInit { + @ViewChild(CustomFieldsComponent) customFieldsComponent: CustomFieldsComponent; + additionalOptionsForm = this.formBuilder.group({ notes: [null as string], reprompt: [false], @@ -44,10 +51,17 @@ export class AdditionalOptionsSectionComponent implements OnInit { shareReplay({ refCount: false, bufferSize: 1 }), ); + /** When false when the add field button should be displayed in the Additional Options section */ + hasCustomFields = false; + + /** True when the form is in `partial-edit` mode */ + isPartialEdit = false; + constructor( private cipherFormContainer: CipherFormContainer, private formBuilder: FormBuilder, private passwordRepromptService: PasswordRepromptService, + private changeDetectorRef: ChangeDetectorRef, ) { this.cipherFormContainer.registerChildForm("additionalOptions", this.additionalOptionsForm); @@ -70,6 +84,22 @@ export class AdditionalOptionsSectionComponent implements OnInit { if (this.cipherFormContainer.config.mode === "partial-edit") { this.additionalOptionsForm.disable(); + this.isPartialEdit = true; } } + + /** Opens the add custom field dialog */ + addCustomField() { + this.customFieldsComponent.openAddEditCustomFieldDialog(); + } + + /** Update the local state when the number of fields changes */ + handleCustomFieldChange(numberOfCustomFields: number) { + this.hasCustomFields = numberOfCustomFields > 0; + + // The event that triggers `handleCustomFieldChange` can occur within + // the CustomFieldComponent `ngOnInit` lifecycle hook, so we need to + // manually trigger change detection to update the view. + this.changeDetectorRef.detectChanges(); + } } 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 00226b25ea..6f01f65be8 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -110,6 +110,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci * @protected */ protected updatedCipherView: CipherView | null; + protected loading: boolean = true; CipherType = CipherType; diff --git a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html new file mode 100644 index 0000000000..5f33d10b7d --- /dev/null +++ b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html @@ -0,0 +1,48 @@ +
+ + + {{ (variant === "add" ? "addField" : "editField") | i18n }} + +
+ + {{ "fieldType" | i18n }} + + + + + {{ getTypeHint() }} + + + + + {{ "fieldLabel" | i18n }} + + + {{ "linkedLabelHelpText" | i18n }} + + +
+
+ + + + +
+
+
diff --git a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.spec.ts b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.spec.ts new file mode 100644 index 0000000000..3ecb04cdc5 --- /dev/null +++ b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.spec.ts @@ -0,0 +1,72 @@ +import { DIALOG_DATA } from "@angular/cdk/dialog"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FieldType } from "@bitwarden/common/vault/enums"; + +import { + AddEditCustomFieldDialogComponent, + AddEditCustomFieldDialogData, +} from "./add-edit-custom-field-dialog.component"; + +describe("AddEditCustomFieldDialogComponent", () => { + let component: AddEditCustomFieldDialogComponent; + let fixture: ComponentFixture; + const addField = jest.fn(); + const updateLabel = jest.fn(); + const removeField = jest.fn(); + + const dialogData = { + addField, + updateLabel, + removeField, + } as AddEditCustomFieldDialogData; + + beforeEach(async () => { + addField.mockClear(); + updateLabel.mockClear(); + removeField.mockClear(); + + await TestBed.configureTestingModule({ + imports: [AddEditCustomFieldDialogComponent], + providers: [ + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: DIALOG_DATA, useValue: dialogData }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AddEditCustomFieldDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges; + }); + + it("creates", () => { + expect(component).toBeTruthy(); + }); + + it("calls `addField` from DIALOG_DATA on with the type and label", () => { + component.customFieldForm.setValue({ type: FieldType.Text, label: "Test Label" }); + + component.submit(); + + expect(addField).toHaveBeenCalledWith(FieldType.Text, "Test Label"); + }); + + it("calls `updateLabel` from DIALOG_DATA with the new label", () => { + component.variant = "edit"; + dialogData.editLabelConfig = { index: 0, label: "Test Label" }; + component.customFieldForm.setValue({ type: FieldType.Text, label: "Test Label 2" }); + + component.submit(); + + expect(updateLabel).toHaveBeenCalledWith(0, "Test Label 2"); + }); + + it("calls `removeField` from DIALOG_DATA with the respective index", () => { + dialogData.editLabelConfig = { index: 2, label: "Test Label" }; + + component.removeField(); + + expect(removeField).toHaveBeenCalledWith(2); + }); +}); diff --git a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.ts b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.ts new file mode 100644 index 0000000000..f08d0ca40e --- /dev/null +++ b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.ts @@ -0,0 +1,120 @@ +import { DIALOG_DATA } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FieldType } from "@bitwarden/common/vault/enums"; +import { + AsyncActionsModule, + ButtonModule, + DialogModule, + FormFieldModule, + IconButtonModule, + SelectModule, +} from "@bitwarden/components"; + +export type AddEditCustomFieldDialogData = { + addField: (type: FieldType, label: string) => void; + updateLabel: (index: number, label: string) => void; + removeField: (index: number) => void; + /** When provided, dialog will display edit label variants */ + editLabelConfig?: { index: number; label: string }; +}; + +@Component({ + standalone: true, + selector: "vault-add-edit-custom-field-dialog", + templateUrl: "./add-edit-custom-field-dialog.component.html", + imports: [ + CommonModule, + JslibModule, + DialogModule, + ButtonModule, + FormFieldModule, + SelectModule, + ReactiveFormsModule, + IconButtonModule, + AsyncActionsModule, + ], +}) +export class AddEditCustomFieldDialogComponent { + variant: "add" | "edit"; + + customFieldForm = this.formBuilder.group({ + type: FieldType.Text, + label: ["", Validators.required], + }); + + fieldTypeOptions = [ + { name: this.i18nService.t("cfTypeText"), value: FieldType.Text }, + { name: this.i18nService.t("cfTypeHidden"), value: FieldType.Hidden }, + { name: this.i18nService.t("cfTypeBoolean"), value: FieldType.Boolean }, + { name: this.i18nService.t("cfTypeLinked"), value: FieldType.Linked }, + ]; + + FieldType = FieldType; + + constructor( + @Inject(DIALOG_DATA) private data: AddEditCustomFieldDialogData, + private formBuilder: FormBuilder, + private i18nService: I18nService, + ) { + this.variant = data.editLabelConfig ? "edit" : "add"; + + if (this.variant === "edit") { + this.customFieldForm.controls.label.setValue(data.editLabelConfig.label); + this.customFieldForm.controls.type.disable(); + } + } + + getTypeHint(): string { + switch (this.customFieldForm.get("type")?.value) { + case FieldType.Text: + return this.i18nService.t("textHelpText"); + case FieldType.Hidden: + return this.i18nService.t("hiddenHelpText"); + case FieldType.Boolean: + return this.i18nService.t("checkBoxHelpText"); + case FieldType.Linked: + return this.i18nService.t("linkedHelpText"); + default: + return ""; + } + } + + /** Direct the form submission to the proper action */ + submit = () => { + if (this.variant === "add") { + this.addField(); + } else { + this.updateLabel(); + } + }; + + /** Invoke the `addField` callback with the custom field details */ + addField() { + if (this.customFieldForm.invalid) { + return; + } + + const { type, label } = this.customFieldForm.value; + this.data.addField(type, label); + } + + /** Invoke the `updateLabel` callback with the new label */ + updateLabel() { + if (this.customFieldForm.invalid) { + return; + } + + const { label } = this.customFieldForm.value; + this.data.updateLabel(this.data.editLabelConfig.index, label); + } + + /** Invoke the `removeField` callback */ + removeField() { + this.data.removeField(this.data.editLabelConfig.index); + } +} diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html new file mode 100644 index 0000000000..49362b9421 --- /dev/null +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html @@ -0,0 +1,111 @@ + + +

{{ "customFields" | i18n }}

+
+
+ +
+ + + {{ field.value.name }} + + + + + + {{ field.value.name }} + + + + + + + + {{ field.value.name }} + + + + + {{ field.value.name }} + + + + + + + + +
+ + +
+
+
diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.spec.ts b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.spec.ts new file mode 100644 index 0000000000..7befcd59b0 --- /dev/null +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.spec.ts @@ -0,0 +1,373 @@ +import { LiveAnnouncer } from "@angular/cdk/a11y"; +import { DialogRef } from "@angular/cdk/dialog"; +import { CdkDragDrop } from "@angular/cdk/drag-drop"; +import { DebugElement } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherType, FieldType, LoginLinkedId } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; +import { DialogService } from "@bitwarden/components"; + +import { BitPasswordInputToggleDirective } from "../../../../../components/src/form-field/password-input-toggle.directive"; +import { CipherFormContainer } from "../../cipher-form-container"; + +import { CustomField, CustomFieldsComponent } from "./custom-fields.component"; + +const mockFieldViews = [ + { type: FieldType.Text, name: "text label", value: "text value" }, + { type: FieldType.Hidden, name: "hidden label", value: "hidden value" }, + { type: FieldType.Boolean, name: "boolean label", value: "true" }, + { type: FieldType.Linked, name: "linked label", value: null, linkedId: 1 }, +] as FieldView[]; + +let originalCipherView: CipherView | null = new CipherView(); +originalCipherView.type = CipherType.Login; +originalCipherView.login = new LoginView(); + +describe("CustomFieldsComponent", () => { + let component: CustomFieldsComponent; + let fixture: ComponentFixture; + let open: jest.Mock; + let announce: jest.Mock; + let patchCipher: jest.Mock; + + beforeEach(async () => { + open = jest.fn(); + announce = jest.fn().mockResolvedValue(null); + patchCipher = jest.fn(); + originalCipherView = new CipherView(); + originalCipherView.type = CipherType.Login; + originalCipherView.login = new LoginView(); + + await TestBed.configureTestingModule({ + imports: [CustomFieldsComponent], + providers: [ + { + provide: I18nService, + useValue: { t: (...keys: string[]) => keys.filter(Boolean).join(" ") }, + }, + { + provide: CipherFormContainer, + useValue: { patchCipher, originalCipherView, registerChildForm: jest.fn(), config: {} }, + }, + { + provide: LiveAnnouncer, + useValue: { announce }, + }, + ], + }) + .overrideProvider(DialogService, { + useValue: { + open, + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(CustomFieldsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe("initializing", () => { + it("populates linkedFieldOptions", () => { + originalCipherView.login.linkedFieldOptions = new Map([ + [1, { i18nKey: "one-i18", propertyKey: "one" }], + [2, { i18nKey: "two-i18", propertyKey: "two" }], + ]); + + component.ngOnInit(); + + expect(component.linkedFieldOptions).toEqual([ + { value: 1, name: "one-i18" }, + { value: 2, name: "two-i18" }, + ]); + }); + + it("populates customFieldsForm", () => { + originalCipherView.fields = mockFieldViews; + + component.ngOnInit(); + + expect(component.fields.value).toEqual([ + { + linkedId: null, + name: "text label", + type: FieldType.Text, + value: "text value", + newField: false, + }, + { + linkedId: null, + name: "hidden label", + type: FieldType.Hidden, + value: "hidden value", + newField: false, + }, + { + linkedId: null, + name: "boolean label", + type: FieldType.Boolean, + value: true, + newField: false, + }, + { linkedId: 1, name: "linked label", type: FieldType.Linked, value: null, newField: false }, + ]); + }); + + it("forbids a user to view hidden fields when the cipher `viewPassword` is false", () => { + originalCipherView.viewPassword = false; + originalCipherView.fields = mockFieldViews; + + component.ngOnInit(); + + fixture.detectChanges(); + + const button = fixture.debugElement.query(By.directive(BitPasswordInputToggleDirective)); + + expect(button.nativeElement.disabled).toBe(true); + }); + }); + + describe("adding new field", () => { + let close: jest.Mock; + + beforeEach(() => { + close = jest.fn(); + component.dialogRef = { close } as unknown as DialogRef; + }); + + it("closes the add dialog", () => { + component.addField(FieldType.Text, "test label"); + + expect(close).toHaveBeenCalled(); + }); + + it("adds a unselected boolean field", () => { + component.addField(FieldType.Boolean, "bool label"); + + expect(component.fields.value).toEqual([ + { + linkedId: null, + name: "bool label", + type: FieldType.Boolean, + value: false, + newField: true, + }, + ]); + }); + + it("auto-selects the first linked field option", () => { + component.linkedFieldOptions = [ + { value: LoginLinkedId.Password, name: "one" }, + { value: LoginLinkedId.Username, name: "two" }, + ]; + + component.addField(FieldType.Linked, "linked label"); + + expect(component.fields.value).toEqual([ + { + linkedId: LoginLinkedId.Password, + name: "linked label", + type: FieldType.Linked, + value: null, + newField: true, + }, + ]); + }); + + it("adds text field", () => { + component.addField(FieldType.Text, "text label"); + + expect(component.fields.value).toEqual([ + { linkedId: null, name: "text label", type: FieldType.Text, value: null, newField: true }, + ]); + }); + + it("adds hidden field", () => { + component.addField(FieldType.Hidden, "hidden label"); + + expect(component.fields.value).toEqual([ + { + linkedId: null, + name: "hidden label", + type: FieldType.Hidden, + value: null, + newField: true, + }, + ]); + }); + + it("announces the new input field", () => { + component.addField(FieldType.Text, "text label 2"); + + fixture.detectChanges(); + + expect(announce).toHaveBeenCalledWith("fieldAdded text label 2", "polite"); + }); + + it("allows a user to view hidden fields when the cipher `viewPassword` is false", () => { + originalCipherView.viewPassword = false; + component.addField(FieldType.Hidden, "Hidden label"); + + fixture.detectChanges(); + + const button = fixture.debugElement.query(By.directive(BitPasswordInputToggleDirective)); + + expect(button.nativeElement.disabled).toBe(false); + }); + }); + + describe("updating a field", () => { + beforeEach(() => { + originalCipherView.fields = [mockFieldViews[0]]; + + component.ngOnInit(); + }); + + it("updates the value", () => { + component.fields.at(0).patchValue({ value: "new text value" }); + + const fieldView = new FieldView(); + fieldView.name = "text label"; + fieldView.value = "new text value"; + fieldView.type = FieldType.Text; + + expect(patchCipher).toHaveBeenCalledWith({ fields: [fieldView] }); + }); + + it("updates the label", () => { + component.updateLabel(0, "new text label"); + + const fieldView = new FieldView(); + fieldView.name = "new text label"; + fieldView.value = "text value"; + fieldView.type = FieldType.Text; + + expect(patchCipher).toHaveBeenCalledWith({ fields: [fieldView] }); + }); + }); + + describe("removing field", () => { + beforeEach(() => { + originalCipherView.fields = [mockFieldViews[0]]; + + component.ngOnInit(); + }); + + it("removes the field", () => { + component.removeField(0); + + expect(patchCipher).toHaveBeenCalledWith({ fields: [] }); + }); + }); + + describe("reordering fields", () => { + let toggleItems: DebugElement[]; + + beforeEach(() => { + originalCipherView.fields = mockFieldViews; + + component.ngOnInit(); + + fixture.detectChanges(); + + toggleItems = fixture.debugElement.queryAll( + By.css('button[data-testid="reorder-toggle-button"]'), + ); + }); + + it("reorders the fields when dropped", () => { + expect(component.fields.value.map((f: CustomField) => f.name)).toEqual([ + "text label", + "hidden label", + "boolean label", + "linked label", + ]); + + // Move second field to first + component.drop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop); + + const latestCallParams = patchCipher.mock.lastCall[0]; + + expect(latestCallParams.fields.map((f: FieldView) => f.name)).toEqual([ + "hidden label", + "text label", + "boolean label", + "linked label", + ]); + }); + + it("moves an item down in order via keyboard", () => { + // Move 3rd item (boolean label) down to 4th + toggleItems[2].triggerEventHandler("keydown", { + key: "ArrowDown", + preventDefault: jest.fn(), + }); + + const latestCallParams = patchCipher.mock.lastCall[0]; + + expect(latestCallParams.fields.map((f: FieldView) => f.name)).toEqual([ + "text label", + "hidden label", + "linked label", + "boolean label", + ]); + }); + + it("moves an item up in order via keyboard", () => { + // Move 2nd item (hidden label) up to 1st + toggleItems[1].triggerEventHandler("keydown", { key: "ArrowUp", preventDefault: jest.fn() }); + + const latestCallParams = patchCipher.mock.lastCall[0]; + + expect(latestCallParams.fields.map((f: FieldView) => f.name)).toEqual([ + "hidden label", + "text label", + "boolean label", + "linked label", + ]); + }); + + it("does not move the first item up", () => { + patchCipher.mockClear(); + + toggleItems[0].triggerEventHandler("keydown", { key: "ArrowUp", preventDefault: jest.fn() }); + + expect(patchCipher).not.toHaveBeenCalled(); + }); + + it("does not move the last item down", () => { + patchCipher.mockClear(); + + toggleItems[toggleItems.length - 1].triggerEventHandler("keydown", { + key: "ArrowDown", + preventDefault: jest.fn(), + }); + + expect(patchCipher).not.toHaveBeenCalled(); + }); + + it("announces the reorder up", () => { + // Move 2nd item up to 1st + toggleItems[1].triggerEventHandler("keydown", { key: "ArrowUp", preventDefault: jest.fn() }); + + // "reorder hidden label to position 1 of 4" + expect(announce).toHaveBeenCalledWith("reorderFieldUp hidden label 1 4", "assertive"); + }); + + it("announces the reorder down", () => { + // Move 3rd item down to 4th + toggleItems[2].triggerEventHandler("keydown", { + key: "ArrowDown", + preventDefault: jest.fn(), + }); + + // "reorder boolean label to position 4 of 4" + expect(announce).toHaveBeenCalledWith("reorderFieldDown boolean label 4 4", "assertive"); + }); + }); +}); diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts new file mode 100644 index 0000000000..0233e1c1b1 --- /dev/null +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts @@ -0,0 +1,334 @@ +import { LiveAnnouncer } from "@angular/cdk/a11y"; +import { DialogRef } from "@angular/cdk/dialog"; +import { CdkDragDrop, DragDropModule, moveItemInArray } from "@angular/cdk/drag-drop"; +import { CommonModule } from "@angular/common"; +import { + AfterViewInit, + Component, + DestroyRef, + ElementRef, + EventEmitter, + OnInit, + Output, + QueryList, + ViewChildren, + inject, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormArray, FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { Subject, switchMap, take } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FieldType, LinkedIdType } from "@bitwarden/common/vault/enums"; +import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; +import { + DialogService, + SectionComponent, + SectionHeaderComponent, + FormFieldModule, + TypographyModule, + CardComponent, + IconButtonModule, + CheckboxModule, + SelectModule, + LinkModule, +} from "@bitwarden/components"; + +import { CipherFormContainer } from "../../cipher-form-container"; + +import { + AddEditCustomFieldDialogComponent, + AddEditCustomFieldDialogData, +} from "./add-edit-custom-field-dialog/add-edit-custom-field-dialog.component"; + +/** Attributes associated with each individual FormGroup within the FormArray */ +export type CustomField = { + type: FieldType; + name: string; + value: string | boolean | null; + linkedId: LinkedIdType; + /** + * `newField` is set to true when the custom field is created. + * + * This is applicable when the user is adding a new field but + * the `viewPassword` property on the cipher is false. The + * user will still need the ability to set the value of the field + * they just created. + * + * See {@link CustomFieldsComponent.canViewPasswords} for implementation. + */ + newField: boolean; +}; + +@Component({ + standalone: true, + selector: "vault-custom-fields", + templateUrl: "./custom-fields.component.html", + imports: [ + JslibModule, + CommonModule, + FormsModule, + FormFieldModule, + ReactiveFormsModule, + SectionComponent, + SectionHeaderComponent, + TypographyModule, + CardComponent, + IconButtonModule, + CheckboxModule, + SelectModule, + DragDropModule, + LinkModule, + ], +}) +export class CustomFieldsComponent implements OnInit, AfterViewInit { + @Output() numberOfFieldsChange = new EventEmitter(); + + @ViewChildren("customFieldRow") customFieldRows: QueryList>; + + customFieldsForm = this.formBuilder.group({ + fields: new FormArray([]), + }); + + /** Reference to the add field dialog */ + dialogRef: DialogRef; + + /** Options for Linked Fields */ + linkedFieldOptions: { name: string; value: LinkedIdType }[] = []; + + /** True when edit/reorder toggles should be hidden based on partial-edit */ + isPartialEdit: boolean; + + /** True when there are custom fields available */ + hasCustomFields = false; + + /** Emits when a new custom field should be focused */ + private focusOnNewInput$ = new Subject(); + + destroyed$: DestroyRef; + FieldType = FieldType; + + constructor( + private dialogService: DialogService, + private cipherFormContainer: CipherFormContainer, + private formBuilder: FormBuilder, + private i18nService: I18nService, + private liveAnnouncer: LiveAnnouncer, + ) { + this.destroyed$ = inject(DestroyRef); + this.cipherFormContainer.registerChildForm("customFields", this.customFieldsForm); + + this.customFieldsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((values) => { + this.updateCipher(values.fields); + }); + } + + /** Fields form array, referenced via a getter to avoid type-casting in multiple places */ + get fields(): FormArray { + return this.customFieldsForm.controls.fields as FormArray; + } + + ngOnInit() { + // Populate options for linked custom fields + this.linkedFieldOptions = Array.from( + this.cipherFormContainer.originalCipherView?.linkedFieldOptions?.entries() ?? [], + ) + .map(([id, linkedFieldOption]) => ({ + name: this.i18nService.t(linkedFieldOption.i18nKey), + value: id, + })) + .sort(Utils.getSortFunction(this.i18nService, "name")); + + // Populate the form with the existing fields + this.cipherFormContainer.originalCipherView?.fields?.forEach((field) => { + let value: string | boolean = field.value; + + if (field.type === FieldType.Boolean) { + value = field.value === "true" ? true : false; + } + + this.fields.push( + this.formBuilder.group({ + type: field.type, + name: field.name, + value: value, + linkedId: field.linkedId, + newField: false, + }), + ); + }); + + // Disable the form if in partial-edit mode + // Must happen after the initial fields are populated + if (this.cipherFormContainer.config.mode === "partial-edit") { + this.isPartialEdit = true; + this.customFieldsForm.disable(); + } + } + + ngAfterViewInit(): void { + // Focus on the new input field when it is added + // This is done after the view is initialized to ensure the input is rendered + this.focusOnNewInput$ + .pipe( + takeUntilDestroyed(this.destroyed$), + // QueryList changes are emitted after the view is updated + switchMap(() => this.customFieldRows.changes.pipe(take(1))), + ) + .subscribe(() => { + const input = + this.customFieldRows.last.nativeElement.querySelector("input"); + const label = document.querySelector(`label[for="${input.id}"]`).textContent.trim(); + + // Focus the input after the announcement element is added to the DOM, + // this should stop the announcement from being cut off by the "focus" event. + void this.liveAnnouncer + .announce(this.i18nService.t("fieldAdded", label), "polite") + .then(() => { + input.focus(); + }); + }); + } + + /** Opens the add/edit custom field dialog */ + openAddEditCustomFieldDialog(editLabelConfig?: AddEditCustomFieldDialogData["editLabelConfig"]) { + this.dialogRef = this.dialogService.open( + AddEditCustomFieldDialogComponent, + { + data: { + addField: this.addField.bind(this), + updateLabel: this.updateLabel.bind(this), + removeField: this.removeField.bind(this), + editLabelConfig, + }, + }, + ); + } + + /** Returns true when the user has permission to view passwords for the individual cipher */ + canViewPasswords(index: number) { + if (this.cipherFormContainer.originalCipherView === null) { + return true; + } + + return ( + this.cipherFormContainer.originalCipherView.viewPassword || + this.fields.at(index).value.newField + ); + } + + /** Updates label for an individual field */ + updateLabel(index: number, label: string) { + this.fields.at(index).patchValue({ name: label }); + this.dialogRef?.close(); + } + + /** Removes an individual field at a specific index */ + removeField(index: number) { + this.fields.removeAt(index); + this.dialogRef?.close(); + } + + /** Adds a new field to the form */ + addField(type: FieldType, label: string) { + this.dialogRef?.close(); + + let value = null; + let linkedId = null; + + if (type === FieldType.Boolean) { + // Default to false for boolean fields + value = false; + } + + if (type === FieldType.Linked && this.linkedFieldOptions.length > 0) { + // Default to the first linked field option + linkedId = this.linkedFieldOptions[0].value; + } + + this.fields.push( + this.formBuilder.group({ + type, + name: label, + value, + linkedId, + newField: true, + }), + ); + + // Trigger focus on the new input field + this.focusOnNewInput$.next(); + } + + /** Reorder the controls to match the new order after a "drop" event */ + drop(event: CdkDragDrop) { + // Alter the order of the fields array in place + moveItemInArray(this.fields.controls, event.previousIndex, event.currentIndex); + + this.updateCipher(this.fields.controls.map((control) => control.value)); + } + + /** Move a custom field up or down in the list order */ + async handleKeyDown(event: KeyboardEvent, label: string, index: number) { + if (event.key === "ArrowUp" && index !== 0) { + event.preventDefault(); + + const currentIndex = index - 1; + this.drop({ previousIndex: index, currentIndex } as CdkDragDrop); + await this.liveAnnouncer.announce( + this.i18nService.t("reorderFieldUp", label, currentIndex + 1, this.fields.length), + "assertive", + ); + + // Refocus the button after the reorder + // Angular re-renders the list when moving an item up which causes the focus to be lost + // Wait for the next tick to ensure the button is rendered before focusing + setTimeout(() => { + (event.target as HTMLButtonElement).focus(); + }); + } + + if (event.key === "ArrowDown" && index !== this.fields.length - 1) { + event.preventDefault(); + + const currentIndex = index + 1; + this.drop({ previousIndex: index, currentIndex } as CdkDragDrop); + await this.liveAnnouncer.announce( + this.i18nService.t("reorderFieldDown", label, currentIndex + 1, this.fields.length), + "assertive", + ); + } + } + + /** Create `FieldView` from the form objects and update the cipher */ + private updateCipher(fields: CustomField[]) { + const newFields = fields.map((field: CustomField) => { + let value: string; + + if (typeof field.value === "number") { + value = `${field.value}`; + } else if (typeof field.value === "boolean") { + value = field.value ? "true" : "false"; + } else { + value = field.value; + } + + const fieldView = new FieldView(); + fieldView.type = field.type; + fieldView.name = field.name; + fieldView.value = value; + fieldView.linkedId = field.linkedId; + return fieldView; + }); + + this.hasCustomFields = newFields.length > 0; + + this.numberOfFieldsChange.emit(newFields.length); + + this.cipherFormContainer.patchCipher({ + fields: newFields, + }); + } +} From e27d698d4b4564944fda9865f5be9a62ba8f2f26 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 17 Jul 2024 10:12:40 -0400 Subject: [PATCH 5/6] [AC-2860] Revise unassigned and purchased seat warning for CB (#10077) * Rework create-client-dialog seat warning * Rework manage-client-subscription-dialog seat warning * Fix create client purchased seats label * Fix manage client subscription purchased seats label logic * Another manage subscription purchased seats fix --- apps/web/src/locales/en/messages.json | 3 ++ .../create-client-dialog.component.html | 9 ++++-- .../clients/create-client-dialog.component.ts | 19 +++++++----- ...-client-subscription-dialog.component.html | 14 ++++++--- ...ge-client-subscription-dialog.component.ts | 31 +++++++++++++++++-- 5 files changed, 59 insertions(+), 17 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 8b8c265653..73396c39c1 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -8564,5 +8564,8 @@ "example": "Organization name" } } + }, + "purchasedSeatsRemoved": { + "message": "purchased seats removed" } } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html index 662cd8a69f..66ac422441 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.html @@ -58,13 +58,16 @@ {{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }} - {{ additionalSeatsPurchased }} {{ "purchaseSeatDescription" | i18n | lowercase }} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts index 987b7cc698..c0ee21d2ab 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts @@ -162,18 +162,16 @@ export class CreateClientDialogComponent implements OnInit { this.dialogRef.close(this.ResultType.Submitted); }; - protected get openSeats(): number { + protected get unassignedSeats(): number { const selectedProviderPlan = this.getSelectedProviderPlan(); if (selectedProviderPlan === null) { return 0; } - return selectedProviderPlan.seatMinimum - selectedProviderPlan.assignedSeats; - } + const openSeats = selectedProviderPlan.seatMinimum - selectedProviderPlan.assignedSeats; - protected get unassignedSeats(): number { - const unassignedSeats = this.openSeats - this.formGroup.value.seats; + const unassignedSeats = openSeats - this.formGroup.value.seats; return unassignedSeats > 0 ? unassignedSeats : 0; } @@ -185,11 +183,16 @@ export class CreateClientDialogComponent implements OnInit { return 0; } - const selectedSeats = this.formGroup.value.seats ?? 0; + if (selectedProviderPlan.purchasedSeats > 0) { + return this.formGroup.value.seats; + } - const purchased = selectedSeats - this.openSeats; + const additionalSeatsPurchased = + this.formGroup.value.seats + + selectedProviderPlan.assignedSeats - + selectedProviderPlan.seatMinimum; - return purchased > 0 ? purchased : 0; + return additionalSeatsPurchased > 0 ? additionalSeatsPurchased : 0; } private getSelectedProviderPlan(): ProviderPlanResponse { diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.html index 0d2d22eadd..2c911b2cb1 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.html @@ -16,21 +16,27 @@ formControlName="assignedSeats" [min]="dialogParams.organization.occupiedSeats" /> - +
{{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }} - {{ additionalSeatsPurchased }} {{ "purchaseSeatDescription" | i18n | lowercase }} + {{ purchasedSeatsRemoved }} {{ "purchasedSeatsRemoved" | i18n | lowercase }}
+ diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.ts index e97e4ea959..f92223d1b5 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-subscription-dialog.component.ts @@ -36,7 +36,10 @@ export const openManageClientSubscriptionDialog = ( export class ManageClientSubscriptionDialogComponent implements OnInit { protected loading = true; protected providerPlan: ProviderPlanResponse; + protected assignedSeats: number; protected openSeats: number; + protected purchasedSeats: number; + protected seatMinimum: number; protected readonly ResultType = ManageClientSubscriptionDialogResultType; protected formGroup = new FormGroup({ @@ -63,7 +66,10 @@ export class ManageClientSubscriptionDialogComponent implements OnInit { (plan) => plan.planName === this.dialogParams.organization.plan, ); + this.assignedSeats = this.providerPlan.assignedSeats; this.openSeats = this.providerPlan.seatMinimum - this.providerPlan.assignedSeats; + this.purchasedSeats = this.providerPlan.purchasedSeats; + this.seatMinimum = this.providerPlan.seatMinimum; this.formGroup.controls.assignedSeats.addValidators( this.isServiceUserWithPurchasedSeats @@ -165,9 +171,22 @@ export class ManageClientSubscriptionDialogComponent implements OnInit { const seatDifference = this.formGroup.value.assignedSeats - this.dialogParams.organization.seats; - const purchasedSeats = seatDifference - this.openSeats; + if (this.purchasedSeats > 0) { + return seatDifference; + } - return purchasedSeats > 0 ? purchasedSeats : 0; + return seatDifference - this.openSeats; + } + + get purchasedSeatsRemoved(): number { + const seatDifference = + this.dialogParams.organization.seats - this.formGroup.value.assignedSeats; + + if (this.purchasedSeats >= seatDifference) { + return seatDifference; + } + + return this.purchasedSeats; } get isProviderAdmin(): boolean { @@ -177,4 +196,12 @@ export class ManageClientSubscriptionDialogComponent implements OnInit { get isServiceUserWithPurchasedSeats(): boolean { return !this.isProviderAdmin && this.providerPlan && this.providerPlan.purchasedSeats > 0; } + + get purchasingSeats(): boolean { + return this.additionalSeatsPurchased > 0; + } + + get sellingSeats(): boolean { + return this.purchasedSeats > 0 && this.additionalSeatsPurchased < 0; + } } From fd93c76c0dffc433a4d8ebae85ba786599c87e93 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 17 Jul 2024 16:31:35 +0200 Subject: [PATCH 6/6] Fix key rotation being broken due to org ciphers being included (#10140) --- libs/common/src/vault/services/cipher.service.spec.ts | 2 ++ libs/common/src/vault/services/cipher.service.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index ba85f51c38..c84fd066b2 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -352,8 +352,10 @@ describe("Cipher Service", () => { const cipher1 = new CipherView(cipherObj); cipher1.id = "Cipher 1"; + cipher1.organizationId = null; const cipher2 = new CipherView(cipherObj); cipher2.id = "Cipher 2"; + cipher2.organizationId = null; decryptedCiphers = new BehaviorSubject({ Cipher1: cipher1, diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index d2d28c2d81..1d06ae1dd0 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1184,11 +1184,16 @@ export class CipherService implements CipherServiceAbstraction { let encryptedCiphers: CipherWithIdRequest[] = []; const ciphers = await this.getAllDecrypted(); - if (!ciphers || ciphers.length === 0) { + if (!ciphers) { + return encryptedCiphers; + } + + const userCiphers = ciphers.filter((c) => c.organizationId == null); + if (userCiphers.length === 0) { return encryptedCiphers; } encryptedCiphers = await Promise.all( - ciphers.map(async (cipher) => { + userCiphers.map(async (cipher) => { const encryptedCipher = await this.encrypt(cipher, newUserKey, originalUserKey); return new CipherWithIdRequest(encryptedCipher); }),