chromium/chrome/browser/resources/password_manager/passwords_importer.ts

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'chrome://resources/cr_elements/cr_link_row/cr_link_row.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/md_select.css.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import 'chrome://resources/polymer/v3_0/paper-spinner/paper-spinner-lite.js';
import './site_favicon.js';
import './dialogs/password_preview_item.js';

import type {CrCheckboxElement} from 'chrome://resources/cr_elements/cr_checkbox/cr_checkbox.js';
import type {CrLinkRowElement} from 'chrome://resources/cr_elements/cr_link_row/cr_link_row.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PluralStringProxyImpl} from 'chrome://resources/js/plural_string_proxy.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import type {PasswordManagerProxy} from './password_manager_proxy.js';
import {PasswordManagerImpl} from './password_manager_proxy.js';
import {getTemplate} from './passwords_importer.html.js';
import {Page, Router} from './router.js';

export interface PasswordsImporterElement {
  $: {
    linkRow: CrLinkRowElement,
  };
}

enum DialogState {
  NO_DIALOG,
  IN_PROGRESS,
  STORE_PICKER,
  SUCCESS,
  ERROR,
  ALREADY_ACTIVE,
  CONFLICTS,
}

/**
 * Should be kept in sync with
 * |password_manager::metrics_util::PasswordsImportDesktopInteractions|.
 * These values are persisted to logs. Entries should not be renumbered and
 * numeric values should never be reused.
 */
enum PasswordsImportDesktopInteractions {
  DIALOG_OPENED_FROM_THREE_DOT_MENU = 0,
  DIALOG_OPENED_FROM_EMPTY_STATE = 1,
  CANCELED_BEFORE_FILE_SELECT = 2,
  UPM_STORE_PICKER_OPENED = 3,
  UPM_FILE_SELECT_LAUNCHED = 4,
  UPM_VIEW_PASSWORDS_CLICKED = 5,
  CONFLICTS_CANCELED = 6,
  CONFLICTS_REAUTH_FAILED = 7,
  CONFLICTS_SKIP_CLICKED = 8,
  CONFLICTS_REPLACE_CLICKED = 9,
  // Must be last.
  COUNT = 10,
}

function recordPasswordsImportInteraction(
    interaction: PasswordsImportDesktopInteractions) {
  chrome.metricsPrivate.recordEnumerationValue(
      'PasswordManager.Import.DesktopInteractions', interaction,
      PasswordsImportDesktopInteractions.COUNT);
}

const PasswordsImporterElementBase = I18nMixin(PolymerElement);

export class PasswordsImporterElement extends PasswordsImporterElementBase {
  static get is() {
    return 'passwords-importer';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      dialogState_: Number,

      dialogStateEnum_: {
        type: Object,
        value: DialogState,
        readOnly: true,
      },

      storeOptionEnum_: {
        type: Object,
        value: chrome.passwordsPrivate.PasswordStoreSet,
        readOnly: true,
      },

      selectedStoreOption_: String,

      results_: Object,
      successDescription_: String,
      failedImportsSummary_: String,
      rowsWithUnknownErrorsSummary_: String,

      conflictsDialogTitle_: String,

      conflicts_: {
        type: Array,
        value: [],
      },

      conflictsSelectedForReplace_: {
        type: Array,
        value: [],
      },

      showRowsWithUnknownErrorsSummary_: {
        type: Boolean,
        value: false,
      },

      bannerDescription_: {
        type: String,
        computed: 'computeBannerDescription_(isUserSyncingPasswords,' +
            'isAccountStoreUser, accountEmail)',
      },
    };
  }

  static get observers() {
    return [
      'updateDefaultStore_(isAccountStoreUser)',
      'updatePasswordsSavedToAccount_(isUserSyncingPasswords)',
    ];
  }

  isUserSyncingPasswords: boolean;
  isAccountStoreUser: boolean;
  accountEmail: string;

  private dialogState_: DialogState = DialogState.NO_DIALOG;
  // Refers both to syncing users with sync enabled for passwords and account
  // store users who choose to import passwords to their account.
  private passwordsSavedToAccount_: boolean;
  private selectedStoreOption_: string;
  private bannerDescription_: string;
  private results_: chrome.passwordsPrivate.ImportResults|null = null;
  private conflicts_: chrome.passwordsPrivate.ImportEntry[];
  private conflictsSelectedForReplace_: number[];
  private successDescription_: string;
  private conflictsDialogTitle_: string;
  private failedImportsSummary_: string;
  private rowsWithUnknownErrorsSummary_: string;
  private showRowsWithUnknownErrorsSummary_: boolean;
  private passwordManager_: PasswordManagerProxy =
      PasswordManagerImpl.getInstance();

  launchImport() {
    recordPasswordsImportInteraction(
        PasswordsImportDesktopInteractions.DIALOG_OPENED_FROM_EMPTY_STATE);
    this.dialogState_ = DialogState.IN_PROGRESS;
    // Timeout is needed to allow Polymer to render the Settings page before the
    // system file picker has been opened.
    setTimeout(() => {
      if (this.isAccountStoreUser) {
        this.dialogState_ = DialogState.STORE_PICKER;
      } else {
        this.selectFileHelper_();
      }
    }, 200);
  }

  private updateDefaultStore_() {
    if (this.isAccountStoreUser) {
      PasswordManagerImpl.getInstance().isAccountStoreDefault().then(
          isAccountStoreDefault => {
            this.selectedStoreOption_ = isAccountStoreDefault ?
                chrome.passwordsPrivate.PasswordStoreSet.ACCOUNT :
                chrome.passwordsPrivate.PasswordStoreSet.DEVICE;
          });
    }
  }

  private updatePasswordsSavedToAccount_() {
    if (this.isUserSyncingPasswords) {
      this.passwordsSavedToAccount_ = true;
    } else {
      this.passwordsSavedToAccount_ = false;
    }
  }

  private isState_(state: DialogState): boolean {
    return this.dialogState_ === state;
  }

  private computeBannerDescription_(): string {
    if (this.isAccountStoreUser) {
      return this.i18n('importPasswordsGenericDescription');
    }
    if (this.isUserSyncingPasswords) {
      return this.i18n(
          'importPasswordsDescriptionAccount',
          this.i18n('localPasswordManager'), this.accountEmail);
    }
    return this.i18n('importPasswordsDescriptionDevice');
  }

  private onBannerClick_() {
    if (this.isAccountStoreUser && this.isState_(DialogState.NO_DIALOG)) {
      recordPasswordsImportInteraction(
          PasswordsImportDesktopInteractions.UPM_STORE_PICKER_OPENED);
      this.dialogState_ = DialogState.STORE_PICKER;
    }
  }

  private closeDialog_() {
    this.dialogState_ = DialogState.NO_DIALOG;
    // TODO(crbug.com/40264206): Make sure that focus behaves correctly when the
    // dialog is closed.
  }

  private async resetImporter() {
    let deleteFile = false;
    if (this.isState_(DialogState.SUCCESS) &&
        !this.shouldHideDeleteFileOption_()) {
      // Trigger the file deletion if checkbox is ticked in SUCCESS (with no
      // errors) state.
      const deleteFileOption =
          this.shadowRoot!.querySelector<CrCheckboxElement>(
              '#deleteFileOption');
      assert(deleteFileOption);
      deleteFile = deleteFileOption.checked;
      chrome.metricsPrivate.recordBoolean(
          'PasswordManager.Import.FileDeletionSelected', deleteFile);
    }
    await this.passwordManager_.resetImporter(deleteFile);
  }

  private async onCloseClick_() {
    if (this.isState_(DialogState.CONFLICTS)) {
      recordPasswordsImportInteraction(
          PasswordsImportDesktopInteractions.CONFLICTS_CANCELED);
    }
    await this.resetImporter();
    this.closeDialog_();
  }

  private async onViewPasswordsClick_() {
    recordPasswordsImportInteraction(
        PasswordsImportDesktopInteractions.UPM_VIEW_PASSWORDS_CLICKED);
    await this.resetImporter();
    this.closeDialog_();
    Router.getInstance().navigateTo(Page.PASSWORDS);
  }

  private async selectFileHelper_() {
    // Clear selected rows from previous import, so it won’t affect the
    // following import.
    this.conflictsSelectedForReplace_ = [];
    this.dialogState_ = DialogState.IN_PROGRESS;
    // For "non-account-store-users" users passwords are stored in the "profile"
    // (DEVICE) store.
    let destinationStore = chrome.passwordsPrivate.PasswordStoreSet.DEVICE;
    if (this.isAccountStoreUser) {
      const storePicker =
          this.shadowRoot!.querySelector<HTMLSelectElement>('#storePicker');
      assert(storePicker);
      this.selectedStoreOption_ = storePicker.value;
      this.passwordsSavedToAccount_ = this.selectedStoreOption_ ===
          chrome.passwordsPrivate.PasswordStoreSet.ACCOUNT;
      if (this.passwordsSavedToAccount_) {
        destinationStore = chrome.passwordsPrivate.PasswordStoreSet.ACCOUNT;
      }
    }

    this.results_ =
        await this.passwordManager_.importPasswords(destinationStore);
    await this.processResults_();
  }

  private async onSelectFileClick_() {
    recordPasswordsImportInteraction(
        PasswordsImportDesktopInteractions.UPM_FILE_SELECT_LAUNCHED);
    await this.selectFileHelper_();
  }

  private async continueImportHelper_(selectedIds: number[]) {
    this.dialogState_ = DialogState.IN_PROGRESS;
    // Close the dialog while import is in progress.
    this.results_ = await this.passwordManager_.continueImport(selectedIds);
    if (this.results_.status ===
        chrome.passwordsPrivate.ImportResultsStatus.DISMISSED) {
      recordPasswordsImportInteraction(
          PasswordsImportDesktopInteractions.CONFLICTS_REAUTH_FAILED);
      // When re-auth fails, restore the conflicts dialog.
      this.dialogState_ = DialogState.CONFLICTS;
      return;
    }
    await this.processResults_();
  }

  private async onSkipClick_() {
    recordPasswordsImportInteraction(
        PasswordsImportDesktopInteractions.CONFLICTS_SKIP_CLICKED);
    await this.continueImportHelper_(/*selectedIds=*/[]);
  }

  private async onReplaceClick_() {
    recordPasswordsImportInteraction(
        PasswordsImportDesktopInteractions.CONFLICTS_REPLACE_CLICKED);
    await this.continueImportHelper_(this.conflictsSelectedForReplace_);
  }

  private isPreviewItemChecked_(id: number): boolean {
    return this.conflictsSelectedForReplace_.includes(id);
  }

  /**
   * Handler for ticking conflicting password checkbox.
   */
  private onPasswordSelectedChange_(): void {
    this.conflictsSelectedForReplace_ =
        Array.from(this.shadowRoot!.querySelectorAll('password-preview-item'))
            .filter(item => item.checked)
            .map(item => item.passwordId);
  }

  private shouldDisableReplace_(): boolean {
    return !this.conflictsSelectedForReplace_.length;
  }

  private async processResults_() {
    assert(this.results_);
    switch (this.results_.status) {
      case chrome.passwordsPrivate.ImportResultsStatus.SUCCESS:
        await this.handleSuccess_();
        return;
      case chrome.passwordsPrivate.ImportResultsStatus.MAX_FILE_SIZE:
      case chrome.passwordsPrivate.ImportResultsStatus.IO_ERROR:
      case chrome.passwordsPrivate.ImportResultsStatus.UNKNOWN_ERROR:
      case chrome.passwordsPrivate.ImportResultsStatus.NUM_PASSWORDS_EXCEEDED:
      case chrome.passwordsPrivate.ImportResultsStatus.BAD_FORMAT:
        this.dialogState_ = DialogState.ERROR;
        break;
      case chrome.passwordsPrivate.ImportResultsStatus.CONFLICTS:
        this.conflictsDialogTitle_ =
            await PluralStringProxyImpl.getInstance().getPluralString(
                'importPasswordsConflictsTitle',
                this.results_.displayedEntries.length);
        this.conflicts_ = this.results_.displayedEntries;
        this.dialogState_ = DialogState.CONFLICTS;
        break;
      case chrome.passwordsPrivate.ImportResultsStatus.IMPORT_ALREADY_ACTIVE:
        this.dialogState_ = DialogState.ALREADY_ACTIVE;
        break;
      case chrome.passwordsPrivate.ImportResultsStatus.DISMISSED:
        this.dialogState_ = DialogState.NO_DIALOG;
        break;
      default:
        assertNotReached();
    }
  }

  private getFailedImportsWithKnownErrors_():
      chrome.passwordsPrivate.ImportEntry[] {
    assert(this.results_);
    return this.results_.displayedEntries.filter(
        (entry) => entry.status !==
            chrome.passwordsPrivate.ImportEntryStatus.UNKNOWN_ERROR);
  }

  private async handleSuccess_() {
    assert(this.results_);
    if (this.results_.displayedEntries.length) {
      const rowsWithUnknownErrorCount =
          this.results_.displayedEntries
              .filter(
                  (entry) => entry.status ===
                      chrome.passwordsPrivate.ImportEntryStatus.UNKNOWN_ERROR)
              .length;
      this.failedImportsSummary_ =
          await PluralStringProxyImpl.getInstance().getPluralString(
              'importPasswordsFailuresSummary',
              this.results_.displayedEntries.length);
      if (rowsWithUnknownErrorCount) {
        this.rowsWithUnknownErrorsSummary_ =
            await PluralStringProxyImpl.getInstance().getPluralString(
                'importPasswordsBadRowsFormat', rowsWithUnknownErrorCount);
        this.showRowsWithUnknownErrorsSummary_ = true;
      }
    }
    if (this.passwordsSavedToAccount_) {
      let descriptionText =
          await PluralStringProxyImpl.getInstance().getPluralString(
              'importPasswordsSuccessSummaryAccount',
              this.results_.numberImported);
      descriptionText =
          descriptionText.replace('$1', this.i18n('localPasswordManager'));
      this.successDescription_ =
          descriptionText.replace('$2', this.accountEmail);
    } else {
      const descriptionText =
          await PluralStringProxyImpl.getInstance().getPluralString(
              'importPasswordsSuccessSummaryDevice',
              this.results_.numberImported);
      this.successDescription_ =
          descriptionText.replace('$1', this.i18n('localPasswordManager'));
    }

    this.dialogState_ = DialogState.SUCCESS;
  }

  private getSuccessDialogTitle_(): string {
    assert(this.results_);
    return this.results_.displayedEntries.length ?
        this.i18n('importPasswordsCompleteTitle') :
        this.i18n('importPasswordsSuccessTitle');
  }

  private getErrorDialogDescription_(): TrustedHTML {
    assert(this.results_);
    switch (this.results_.status) {
      case chrome.passwordsPrivate.ImportResultsStatus.MAX_FILE_SIZE:
        return this.i18nAdvanced('importPasswordsFileSizeExceeded');
      case chrome.passwordsPrivate.ImportResultsStatus.IO_ERROR:
      case chrome.passwordsPrivate.ImportResultsStatus.UNKNOWN_ERROR:
        return this.i18nAdvanced('importPasswordsUnknownError');
      case chrome.passwordsPrivate.ImportResultsStatus.NUM_PASSWORDS_EXCEEDED:
        return this.i18nAdvanced('importPasswordsLimitExceeded');
      case chrome.passwordsPrivate.ImportResultsStatus.BAD_FORMAT:
        return this.i18nAdvanced('importPasswordsBadFormatError', {
          attrs: ['class'],
          substitutions: [
            this.results_.fileName,
            loadTimeData.getString('importPasswordsHelpURL'),
          ],
        });
      default:
        assertNotReached();
    }
  }

  private getSuccessTipHtml_(): TrustedHTML {
    assert(this.results_);
    return this.i18nAdvanced(
        'importPasswordsSuccessTip',
        {attrs: ['class'], substitutions: [this.results_.fileName]});
  }

  private getCheckboxLabelHtml_(): TrustedHTML {
    assert(this.results_);
    return this.i18nAdvanced(
        'importPasswordsDeleteFileOption',
        {attrs: ['class'], substitutions: [this.results_.fileName]});
  }

  private shouldHideLinkRowIcon_(): boolean {
    return !this.isAccountStoreUser || this.isState_(DialogState.IN_PROGRESS);
  }

  private shouldShowSelectFileButton_(): boolean {
    return !this.isAccountStoreUser && !this.isState_(DialogState.IN_PROGRESS);
  }

  private shouldHideDeleteFileOption_(): boolean {
    // "Delete file" checkbox is only shown in "success" state if all passwords
    // were imported.
    assert(this.results_);
    return !!this.results_.displayedEntries.length;
  }

  private shouldHideFailuresSummary_(): boolean {
    assert(this.results_);
    return !this.results_.displayedEntries.length;
  }

  private getStoreOptionAccountText_(): string {
    assert(this.accountEmail);
    return this.i18n(
        'passwordsStoreOptionAccount', this.i18n('localPasswordManager'),
        this.accountEmail);
  }

  private getFailedEntryErrorMessage_(
      status: chrome.passwordsPrivate.ImportEntryStatus): string {
    // TODO(crbug.com/40264637): Use constants for length limits.
    switch (status) {
      case chrome.passwordsPrivate.ImportEntryStatus.MISSING_PASSWORD:
        return this.i18n('importPasswordsMissingPassword');
      case chrome.passwordsPrivate.ImportEntryStatus.MISSING_URL:
        return this.i18n('importPasswordsMissingURL');
      case chrome.passwordsPrivate.ImportEntryStatus.INVALID_URL:
        return this.i18n('importPasswordsInvalidURL');
      case chrome.passwordsPrivate.ImportEntryStatus.LONG_URL:
        return this.i18n('importPasswordsLongURL');
      case chrome.passwordsPrivate.ImportEntryStatus.LONG_PASSWORD:
        return this.i18n('importPasswordsLongPassword');
      case chrome.passwordsPrivate.ImportEntryStatus.LONG_USERNAME:
        return this.i18n('importPasswordsLongUsername');
      case chrome.passwordsPrivate.ImportEntryStatus.CONFLICT_PROFILE:
        if (this.isUserSyncingPasswords) {
          return this.i18n(
              'importPasswordsConflictAccount',
              this.i18n('localPasswordManager'), this.accountEmail);
        }
        return this.i18n('importPasswordsConflictDevice');
      case chrome.passwordsPrivate.ImportEntryStatus.CONFLICT_ACCOUNT:
        return this.i18n(
            'importPasswordsConflictAccount', this.i18n('localPasswordManager'),
            this.accountEmail);
      case chrome.passwordsPrivate.ImportEntryStatus.LONG_NOTE:
      case chrome.passwordsPrivate.ImportEntryStatus.LONG_CONCATENATED_NOTE:
        return this.i18n('importPasswordsLongNote');
      case chrome.passwordsPrivate.ImportEntryStatus.UNKNOWN_ERROR:
      case chrome.passwordsPrivate.ImportEntryStatus.NON_ASCII_URL:
      default:
        assertNotReached();
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'passwords-importer': PasswordsImporterElement;
  }
}

customElements.define(PasswordsImporterElement.is, PasswordsImporterElement);