mirror of
https://github.com/bitwarden/browser
synced 2026-01-31 00:33:33 +00:00
* add callout to vault-items for non premium users, add upgrade premium flow * add archive badge to item details only for desktop * update desktop edit item save for unarchive * updated success toast for edited archive item non premium
420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
// 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<boolean>;
|
|
|
|
/**
|
|
* 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<CipherView>();
|
|
|
|
private formReadySubject = new Subject<void>();
|
|
|
|
// 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<CipherForm>({});
|
|
|
|
/**
|
|
* 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<K extends keyof CipherForm>(
|
|
name: K,
|
|
group: Exclude<CipherForm[K], undefined>,
|
|
): 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<CipherForm>({});
|
|
|
|
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);
|
|
};
|
|
}
|