Merge branch 'main' into PM-9852

This commit is contained in:
jaasen-livefront 2024-07-17 10:57:27 -07:00
commit 63f95600e8
No known key found for this signature in database
26 changed files with 1393 additions and 32 deletions

View File

@ -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."
},
@ -3634,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"
}
}
}
}

View File

@ -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" },
},
}),
],
}),
{

View File

@ -0,0 +1,21 @@
<popup-page>
<popup-header slot="header" [pageTitle]="'send' | i18n">
<ng-container slot="end">
<tools-new-send-dropdown></tools-new-send-dropdown>
<app-pop-out></app-pop-out>
<app-current-account></app-current-account>
</ng-container>
</popup-header>
<div
*ngIf="sendsListState === SendsListStateEnum.Empty"
class="tw-flex tw-flex-col tw-h-full tw-justify-center"
>
<bit-no-items [icon]="noItemIcon" class="tw-text-main">
<ng-container slot="title">{{ "sendsNoItemsTitle" | i18n }}</ng-container>
<ng-container slot="description">{{ "sendsNoItemsMessage" | i18n }}</ng-container>
<tools-new-send-dropdown slot="button"></tools-new-send-dropdown>
</bit-no-items>
</div>
</popup-page>

View File

@ -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 {}
}

View File

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

View File

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

View File

@ -8564,5 +8564,8 @@
"example": "Organization name"
}
}
},
"purchasedSeatsRemoved": {
"message": "purchased seats removed"
}
}

View File

@ -58,13 +58,16 @@
</bit-label>
<input type="number" bitInput formControlName="seats" min="1" />
<bit-hint
class="tw-text-muted tw-grid tw-grid-flow-col tw-gap-1 tw-grid-cols-1 tw-grid-rows-2"
*ngIf="openSeats > 0"
class="tw-text-muted tw-grid tw-grid-flow-col tw-gap-1 tw-grid-cols-1"
[ngClass]="{
'tw-grid-rows-1': additionalSeatsPurchased <= 0,
'tw-grid-rows-2': additionalSeatsPurchased > 0
}"
>
<span class="tw-col-span-1"
>{{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }}</span
>
<span class="tw-col-span-1"
<span class="tw-col-span-1" *ngIf="additionalSeatsPurchased > 0"
>{{ additionalSeatsPurchased }}
{{ "purchaseSeatDescription" | i18n | lowercase }}</span
>

View File

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

View File

@ -16,21 +16,27 @@
formControlName="assignedSeats"
[min]="dialogParams.organization.occupiedSeats"
/>
<bit-hint class="tw-text-muted" *ngIf="openSeats > 0 || isServiceUserWithPurchasedSeats">
<bit-hint class="tw-text-muted" *ngIf="!isServiceUserWithPurchasedSeats">
<div
*ngIf="!this.isServiceUserWithPurchasedSeats"
class="tw-grid tw-grid-flow-col tw-gap-1 tw-grid-cols-1"
[ngClass]="{ 'tw-grid-rows-2': this.isProviderAdmin }"
[ngClass]="{
'tw-grid-rows-1': additionalSeatsPurchased === 0,
'tw-grid-rows-2': purchasingSeats || sellingSeats
}"
>
<span class="tw-col-span-1">
{{ unassignedSeats }} {{ "unassignedSeatsDescription" | i18n | lowercase }}
</span>
<span *ngIf="this.isProviderAdmin" class="tw-col-span-1"
<span *ngIf="purchasingSeats" class="tw-col-span-1"
>{{ additionalSeatsPurchased }}
{{ "purchaseSeatDescription" | i18n | lowercase }}</span
>
<span *ngIf="sellingSeats" class="tw-col-span-1"
>{{ purchasedSeatsRemoved }} {{ "purchasedSeatsRemoved" | i18n | lowercase }}</span
>
</div>
</bit-hint>
<bit-hint *ngIf="isServiceUserWithPurchasedSeats"></bit-hint>
</bit-form-field>
</div>
<ng-container bitDialogFooter>

View File

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

View File

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

View File

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

View File

@ -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"];
};
/**

View File

@ -13,8 +13,17 @@
<bit-label>{{ "passwordPrompt" | i18n }}</bit-label>
</bit-form-control>
<!-- TODO: Add "+ Add Field" button for Custom Fields - PM-8803 -->
<button
bitLink
type="button"
linkType="primary"
*ngIf="!hasCustomFields && !isPartialEdit"
(click)="addCustomField()"
>
<i class="bwi bwi-plus tw-font-bold" aria-hidden="true"></i>
{{ "addField" | i18n }}
</button>
</bit-card>
</bit-section>
<!-- TODO: Add Custom Fields section component - PM-8803 -->
<vault-custom-fields (numberOfFieldsChange)="handleCustomFieldChange($event)"></vault-custom-fields>

View File

@ -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<AdditionalOptionsSectionComponent>;
@ -31,7 +40,16 @@ describe("AdditionalOptionsSectionComponent", () => {
{ provide: PasswordRepromptService, useValue: passwordRepromptService },
{ provide: I18nService, useValue: mock<I18nService>() },
],
}).compileComponents();
})
.overrideComponent(AdditionalOptionsSectionComponent, {
remove: {
imports: [CustomFieldsComponent],
},
add: {
imports: [MockCustomFieldsComponent],
},
})
.compileComponents();
fixture = TestBed.createComponent(AdditionalOptionsSectionComponent);
component = fixture.componentInstance;

View File

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

View File

@ -110,6 +110,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
* @protected
*/
protected updatedCipherView: CipherView | null;
protected loading: boolean = true;
CipherType = CipherType;

View File

@ -0,0 +1,48 @@
<form [formGroup]="customFieldForm" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle>
{{ (variant === "add" ? "addField" : "editField") | i18n }}
</span>
<div bitDialogContent>
<bit-form-field *ngIf="variant === 'add'">
<bit-label>{{ "fieldType" | i18n }}</bit-label>
<bit-select id="fieldType" formControlName="type">
<bit-option
*ngFor="let type of fieldTypeOptions"
[value]="type.value"
[label]="type.name"
></bit-option>
</bit-select>
<bit-hint>
{{ getTypeHint() }}
</bit-hint>
</bit-form-field>
<bit-form-field disableMargin>
<bit-label>{{ "fieldLabel" | i18n }}</bit-label>
<input bitInput id="fieldLabel" formControlName="label" type="text" />
<bit-hint *ngIf="customFieldForm.value.type === FieldType.Linked">
{{ "linkedLabelHelpText" | i18n }}
</bit-hint>
</bit-form-field>
</div>
<div bitDialogFooter class="tw-flex tw-gap-2 tw-w-full">
<button bitButton buttonType="primary" type="submit" [disabled]="customFieldForm.invalid">
{{ (variant === "add" ? "add" : "edit") | i18n }}
</button>
<button bitButton bitDialogClose buttonType="secondary" type="button">
{{ "cancel" | i18n }}
</button>
<button
*ngIf="variant === 'edit'"
type="button"
buttonType="danger"
class="tw-border-0 tw-ml-auto"
bitIconButton="bwi-trash"
[appA11yTitle]="'deleteCustomField' | i18n: customFieldForm.value.label"
(click)="removeField()"
></button>
</div>
</bit-dialog>
</form>

View File

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

View File

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

View File

@ -0,0 +1,111 @@
<bit-section *ngIf="hasCustomFields">
<bit-section-header>
<h2 bitTypography="h5">{{ "customFields" | i18n }}</h2>
</bit-section-header>
<form [formGroup]="customFieldsForm">
<bit-card formArrayName="fields" cdkDropList (cdkDropListDropped)="drop($event)">
<div
*ngFor="let field of fields.controls; let i = index"
[formGroupName]="i"
class="tw-flex tw-p-3 -tw-mx-3 tw-gap-4 tw-bg-background tw-rounded-lg first:-tw-mt-3 last-of-type:tw-mb-3"
[ngClass]="{
'tw-items-center': field.value.type === FieldType.Boolean
}"
cdkDrag
#customFieldRow
>
<!-- Text Field -->
<bit-form-field *ngIf="field.value.type === FieldType.Text" class="tw-flex-1" disableMargin>
<bit-label>{{ field.value.name }}</bit-label>
<input bitInput formControlName="value" data-testid="custom-text-field" />
</bit-form-field>
<!-- Hidden Field -->
<bit-form-field
*ngIf="field.value.type === FieldType.Hidden"
class="tw-flex-1"
disableMargin
>
<bit-label>{{ field.value.name }}</bit-label>
<input
bitInput
formControlName="value"
type="password"
data-testid="custom-hidden-field"
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
data-testid="visibility-for-custom-hidden-field"
[disabled]="!canViewPasswords(i)"
></button>
</bit-form-field>
<!-- Boolean Field -->
<bit-form-control
*ngIf="field.value.type === FieldType.Boolean"
class="tw-flex-1"
disableMargin
>
<input
bitCheckbox
formControlName="value"
type="checkbox"
data-testid="custom-boolean-field"
/>
<bit-label>{{ field.value.name }}</bit-label>
</bit-form-control>
<!-- Linked Field -->
<bit-form-field
*ngIf="field.value.type === FieldType.Linked"
class="tw-flex-1"
disableMargin
>
<bit-label>{{ field.value.name }}</bit-label>
<bit-select formControlName="linkedId" data-testid="custom-linked-field">
<bit-option
*ngFor="let option of linkedFieldOptions"
[value]="option.value"
[label]="option.name"
></bit-option>
</bit-select>
</bit-form-field>
<button
type="button"
(click)="openAddEditCustomFieldDialog({ index: i, label: field.value.name })"
[appA11yTitle]="'editFieldLabel' | i18n: field.value.name"
bitIconButton="bwi-pencil-square"
class="tw-self-end"
data-testid="edit-custom-field-button"
*ngIf="!isPartialEdit"
></button>
<button
type="button"
bitIconButton="bwi-hamburger"
class="tw-self-end"
cdkDragHandle
[appA11yTitle]="'reorderToggleButton' | i18n: field.value.name"
(keydown)="handleKeyDown($event, field.value.name, i)"
data-testid="reorder-toggle-button"
*ngIf="!isPartialEdit"
></button>
</div>
<button
type="button"
bitLink
linkType="primary"
(click)="openAddEditCustomFieldDialog()"
*ngIf="!isPartialEdit"
>
<i class="bwi bwi-plus tw-font-bold" aria-hidden="true"></i>
{{ "addField" | i18n }}
</button>
</bit-card>
</form>
</bit-section>

View File

@ -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<CustomFieldsComponent>;
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<HTMLDivElement>);
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");
});
});
});

View File

@ -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<number>();
@ViewChildren("customFieldRow") customFieldRows: QueryList<ElementRef<HTMLDivElement>>;
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<void>();
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<CustomField>({
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<HTMLInputElement>("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<unknown, AddEditCustomFieldDialogData>(
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<CustomField>({
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<HTMLDivElement>) {
// 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<HTMLDivElement>);
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<HTMLDivElement>);
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,
});
}
}

8
package-lock.json generated
View File

@ -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": {

View File

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