mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +00:00
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
// 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<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,
|
|
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<CustomField>({
|
|
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<HTMLInputElement>("input");
|
|
const label = mostRecentRow.querySelector<HTMLLabelElement>("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<unknown, AddEditCustomFieldDialogData>(
|
|
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<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",
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
}
|