// FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { NgIf } from "@angular/common"; import { AfterViewInit, ChangeDetectorRef, Component, DestroyRef, EventEmitter, forwardRef, inject, Input, OnChanges, OnInit, Output, ViewChild, } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { BehaviorSubject, firstValueFrom, Subject, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { AsyncActionsModule, BitSubmitDirective, ButtonComponent, FormFieldModule, ItemModule, SelectModule, ToastService, TypographyModule, } from "@bitwarden/components"; import { CipherFormConfig } from "../abstractions/cipher-form-config.service"; import { CipherFormService } from "../abstractions/cipher-form.service"; import { CipherForm, CipherFormContainer } from "../cipher-form-container"; import { CipherFormCacheService } from "../services/default-cipher-form-cache.service"; import { AdditionalOptionsSectionComponent } from "./additional-options/additional-options-section.component"; import { CardDetailsSectionComponent } from "./card-details-section/card-details-section.component"; import { IdentitySectionComponent } from "./identity/identity.component"; import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component"; import { LoginDetailsSectionComponent } from "./login-details-section/login-details-section.component"; import { NewItemNudgeComponent } from "./new-item-nudge/new-item-nudge.component"; import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "vault-cipher-form", templateUrl: "./cipher-form.component.html", providers: [ { provide: CipherFormContainer, useExisting: forwardRef(() => CipherFormComponent), }, { provide: CipherFormCacheService, }, ], imports: [ AsyncActionsModule, TypographyModule, ItemModule, FormFieldModule, ReactiveFormsModule, SelectModule, ItemDetailsSectionComponent, CardDetailsSectionComponent, IdentitySectionComponent, SshKeySectionComponent, NgIf, AdditionalOptionsSectionComponent, LoginDetailsSectionComponent, NewItemNudgeComponent, ], }) export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, CipherFormContainer { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(BitSubmitDirective) private bitSubmit: BitSubmitDirective; private destroyRef = inject(DestroyRef); private _firstInitialized = false; /** * The form ID to use for the form. Used to connect it to a submit button. */ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) formId: string; /** * The configuration for the add/edit form. Used to determine which controls are shown and what values are available. */ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) config: CipherFormConfig; /** * Optional submit button that will be disabled or marked as loading when the form is submitting. */ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() submitBtn?: ButtonComponent; /** * Optional function to call before submitting the form. If the function returns false, the form will not be submitted. */ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() beforeSubmit: () => Promise; /** * Event emitted when the cipher is saved successfully. */ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() cipherSaved = new EventEmitter(); private formReadySubject = new Subject(); // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() formReady = this.formReadySubject.asObservable(); /** * Emitted when the form is enabled */ private formStatusChangeSubject = new BehaviorSubject<"enabled" | "disabled" | null>(null); // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() formStatusChange$ = this.formStatusChangeSubject.asObservable(); /** * The original cipher being edited or cloned. Null for add mode. */ originalCipherView: CipherView | null; /** * The form group for the cipher. Starts empty and is populated by child components via the `registerChildForm` method. * @protected */ protected cipherForm = this.formBuilder.group({}); /** * The value of the updated cipher. Starts as a new cipher (or clone of originalCipher) and is updated * by child components via the `patchCipher` method. * @protected */ protected updatedCipherView: CipherView | null; get website(): string | null { return this.updatedCipherView?.login?.uris?.[0]?.uri ?? null; } protected loading: boolean = true; CipherType = CipherType; ngAfterViewInit(): void { if (this.submitBtn) { this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => { this.submitBtn.loading.set(loading); }); this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((disabled) => { this.submitBtn.disabled.set(disabled); }); } } disableFormFields(): void { this.cipherForm.disable({ emitEvent: false }); this.formStatusChangeSubject.next("disabled"); } /** * Enables the form, only when it is currently disabled. * Child forms could have disabled some of their controls based on * other factors. Enabling the form from this level should only occur * when the form was disabled at this level. */ enableFormFields(): void { if (this.formStatusChangeSubject.getValue() === "disabled") { this.cipherForm.enable({ emitEvent: false }); this.formStatusChangeSubject.next("enabled"); } } /** * Registers a child form group with the parent form group. Used by child components to add their form groups to * the parent form for validation. * @param name - The name of the form group. * @param group - The form group to add. */ registerChildForm( name: K, group: Exclude, ): void { this.cipherForm.setControl(name, group); } /** * Method to update the cipherView with the new values. This method should be called by the child form components * @param updateFn - A function that takes the current cipherView and returns the updated cipherView */ patchCipher(updateFn: (current: CipherView) => CipherView): void { this.updatedCipherView = updateFn(this.updatedCipherView); // Cache the updated cipher this.cipherFormCacheService.cacheCipherView(this.updatedCipherView); } /** * Return initial values for given keys of a cipher */ getInitialCipherView(): CipherView { const cachedCipherView = this.cipherFormCacheService.getCachedCipherView(); if (cachedCipherView && this.initializedWithCachedCipher()) { return cachedCipherView; } return this.originalCipherView; } /** */ initializedWithCachedCipher(): boolean { return this.cipherFormCacheService.initializedWithValue; } /** * We need to re-initialize the form when the config is updated. */ async ngOnChanges() { // Avoid re-initializing the form on the first change detection cycle. if (this._firstInitialized) { await this.init(); } } async ngOnInit() { await this.init(); this._firstInitialized = true; } async init() { this.loading = true; // Force change detection so that all child components are destroyed and re-created this.changeDetectorRef.detectChanges(); this.updatedCipherView = new CipherView(); this.originalCipherView = null; this.cipherForm = this.formBuilder.group({}); if (this.config == null) { return; } if (this.config.mode !== "add") { if (this.config.originalCipher == null) { throw new Error("Original cipher is required for edit or clone mode"); } this.originalCipherView = await this.addEditFormService.decryptCipher( this.config.originalCipher, ); // decryptCipher again to ensure we have a separate instance of CipherView this.updatedCipherView = await this.addEditFormService.decryptCipher( this.config.originalCipher, ); if (this.config.mode === "clone") { this.updatedCipherView.id = null; if (this.updatedCipherView.login) { this.updatedCipherView.login.fido2Credentials = null; } } } else { this.updatedCipherView.type = this.config.cipherType; if (this.config.cipherType === CipherType.SecureNote) { this.updatedCipherView.secureNote.type = SecureNoteType.Generic; } } this.setInitialCipherFromCache(); this.loading = false; this.formReadySubject.next(); } /** * Updates `updatedCipherView` based on the value from the cache. */ setInitialCipherFromCache() { // If we are coming from the overlay/popup flow clear the cache to avoid old cached data const hasOverlayData = this.config.initialValues && (this.config.initialValues.username !== undefined || this.config.initialValues.password !== undefined); if (hasOverlayData) { this.cipherFormCacheService.clearCache(); return; } const cachedCipher = this.cipherFormCacheService.getCachedCipherView(); if (cachedCipher === null) { return; } const isEditingExistingCipher = this.updatedCipherView.id && this.updatedCipherView.id === cachedCipher.id; const isCreatingNewCipher = !this.updatedCipherView.id && !cachedCipher.id && this.updatedCipherView.type === cachedCipher.type; if (isEditingExistingCipher || isCreatingNewCipher) { this.updatedCipherView = cachedCipher; } } constructor( private formBuilder: FormBuilder, private addEditFormService: CipherFormService, private toastService: ToastService, private i18nService: I18nService, private changeDetectorRef: ChangeDetectorRef, private cipherFormCacheService: CipherFormCacheService, private cipherArchiveService: CipherArchiveService, private accountService: AccountService, ) {} /** * Counts the number of invalid fields in a form group. * @param formGroup - The form group to count the invalid fields in. * @returns The number of invalid fields in the form group. */ private countInvalidFields(formGroup: FormGroup): number { return Object.values(formGroup.controls).reduce((count, control) => { if (control instanceof FormGroup) { return count + this.countInvalidFields(control); } return count + (control.invalid ? 1 : 0); }, 0); } submit = async () => { let successToast: string = "editedItem"; if (this.cipherForm.invalid) { this.cipherForm.markAllAsTouched(); const invalidFieldsCount = this.countInvalidFields(this.cipherForm); if (invalidFieldsCount > 0) { this.toastService.showToast({ variant: "error", title: null, message: invalidFieldsCount === 1 ? this.i18nService.t("singleFieldNeedsAttention") : this.i18nService.t("multipleFieldsNeedAttention", invalidFieldsCount), }); } return; } if (this.beforeSubmit) { const shouldSubmit = await this.beforeSubmit(); if (!shouldSubmit) { return; } } const userCanArchive = await firstValueFrom( this.accountService.activeAccount$.pipe( getUserId, switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)), ), ); // If the item is archived but user has lost archive permissions, unarchive the item. if (!userCanArchive && this.updatedCipherView.archivedDate) { this.updatedCipherView.archivedDate = null; successToast = "itemRestored"; } const savedCipher = await this.addEditFormService.saveCipher( this.updatedCipherView, this.config, ); // Clear the cache after successful save this.cipherFormCacheService.clearCache(); this.toastService.showToast({ variant: "success", title: null, message: this.i18nService.t( this.config.mode === "edit" || this.config.mode === "partial-edit" ? successToast : "addedItem", ), }); this.cipherSaved.emit(savedCipher); }; }