// FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore 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, inject, OnInit, Output, QueryList, ViewChildren, } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormArray, FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms"; import { Subject, zip } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType, FieldType, LinkedIdType } from "@bitwarden/common/vault/enums"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { CardComponent, CheckboxModule, DialogService, FormFieldModule, IconButtonModule, LinkModule, SectionComponent, SectionHeaderComponent, SelectModule, TypographyModule, } 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, private eventCollectionService: EventCollectionService, ) { 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() { const linkedFieldsOptionsForCipher = this.getLinkedFieldsOptionsForCipher(); const optionsArray = Array.from(linkedFieldsOptionsForCipher?.entries() ?? []); optionsArray.sort((a, b) => a[1].sortPosition - b[1].sortPosition); // Populate options for linked custom fields this.linkedFieldOptions = optionsArray.map(([id, linkedFieldOption]) => ({ name: this.i18nService.t(linkedFieldOption.i18nKey), value: id, })); const prefillCipher = this.cipherFormContainer.getInitialCipherView(); // When available, populate the form with the existing fields prefillCipher?.fields?.forEach((field) => { let value: string | boolean = field.value; if (field.type === FieldType.Boolean) { value = field.value === "true" ? true : false; } const customField = this.formBuilder.group({ type: field.type, name: field.name, value: value, linkedId: field.linkedId, newField: false, }); if ( field.type === FieldType.Hidden && !this.cipherFormContainer.originalCipherView?.viewPassword ) { customField.controls.value.disable(); } this.fields.push(customField); }); // 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 zip(this.focusOnNewInput$, this.customFieldRows.changes) .pipe(takeUntilDestroyed(this.destroyed$)) .subscribe(() => { const mostRecentRow = this.customFieldRows.last.nativeElement; const input = mostRecentRow.querySelector("input"); const label = mostRecentRow.querySelector("label").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), cipherType: this.cipherFormContainer.config.cipherType, 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", ); } } async logHiddenEvent(hiddenFieldVisible: boolean) { const { mode, originalCipher } = this.cipherFormContainer.config; const isEdit = ["edit", "partial-edit"].includes(mode); if (hiddenFieldVisible && isEdit) { await this.eventCollectionService.collect( EventType.Cipher_ClientToggledHiddenFieldVisible, originalCipher.id, false, originalCipher.organizationId, ); } } /** * Returns the linked field options for the current cipher type * * Note: Note ciphers do not have linked fields */ private getLinkedFieldsOptionsForCipher() { switch (this.cipherFormContainer.config.cipherType) { case CipherType.Login: return LoginView.prototype.linkedFieldOptions; case CipherType.Card: return CardView.prototype.linkedFieldOptions; case CipherType.Identity: return IdentityView.prototype.linkedFieldOptions; default: return null; } } /** 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((cipher) => { cipher.fields = newFields; return cipher; }); } }