chromium/chrome/browser/resources/settings/privacy_page/security_keys_credential_management_dialog.ts

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

/**
 * @fileoverview 'settings-security-keys-credential-management-dialog' is a
 * dialog for viewing and erasing credentials stored on a security key.
 */

import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/cr_input/cr_input.js';
import 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/cr_elements/cr_page_selector/cr_page_selector.js';
import 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';
import 'chrome://resources/polymer/v3_0/paper-spinner/paper-spinner-lite.js';
import '../settings_shared.css.js';
import '../site_favicon.js';
import '../i18n_setup.js';
import './security_keys_pin_field.js';

import type {CrButtonElement} from 'chrome://resources/cr_elements/cr_button/cr_button.js';
import type {CrDialogElement} from 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import type {CrIconButtonElement} from 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import type {CrInputElement} from 'chrome://resources/cr_elements/cr_input/cr_input.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {WebUiListenerMixin} from 'chrome://resources/cr_elements/web_ui_listener_mixin.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import type {IronListElement} from 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import type {Credential, SecurityKeysCredentialBrowserProxy, StartCredentialManagementResponse} from './security_keys_browser_proxy.js';
import {SecurityKeysCredentialBrowserProxyImpl} from './security_keys_browser_proxy.js';
import {getTemplate} from './security_keys_credential_management_dialog.html.js';
import type {SettingsSecurityKeysPinFieldElement} from './security_keys_pin_field.js';

export enum CredentialManagementDialogPage {
  INITIAL = 'initial',
  PIN_PROMPT = 'pinPrompt',
  PIN_ERROR = 'pinError',
  CREDENTIALS = 'credentials',
  EDIT = 'edit',
  ERROR = 'error',
  CONFIRM = 'confirm',
}

export interface SettingsSecurityKeysCredentialManagementDialogElement {
  $: {
    cancelButton: CrButtonElement,
    confirm: HTMLElement,
    confirmButton: CrButtonElement,
    credentialList: IronListElement,
    dialog: CrDialogElement,
    displayNameInput: CrInputElement,
    edit: HTMLElement,
    error: HTMLElement,
    pin: SettingsSecurityKeysPinFieldElement,
    pinError: HTMLElement,
    userNameInput: CrInputElement,
  };
}

const SettingsSecurityKeysCredentialManagementDialogElementBase =
    WebUiListenerMixin(I18nMixin(PolymerElement));

const MAX_INPUT_LENGTH: number = 62;

export class SettingsSecurityKeysCredentialManagementDialogElement extends
    SettingsSecurityKeysCredentialManagementDialogElementBase {
  static get is() {
    return 'settings-security-keys-credential-management-dialog';
  }

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

  static get properties() {
    return {
      /**
       * The ID of the element currently shown in the dialog.
       */
      dialogPage_: {
        type: String,
        value: CredentialManagementDialogPage.INITIAL,
        observer: 'dialogPageChanged_',
      },

      /**
       * The list of credentials displayed in the dialog.
       */
      credentials_: {
        type: Array,
        notify: true,
      },

      /**
       * The message displayed on the "error" dialog page.
       */
      errorMsg_: String,

      cancelButtonVisible_: Boolean,
      closeButtonVisible_: Boolean,
      confirmButtonDisabled_: Boolean,
      confirmButtonLabel_: String,
      confirmButtonVisible_: Boolean,
      confirmMsg_: String,
      credentialIdToDelete_: String,
      displayNameInputError_: String,
      editingCredential_: Object,
      editButtonVisible_: Boolean,
      minPinLength_: Number,
      newDisplayName_: String,
      newUsername_: String,
      userHandle_: String,
      userNameInputError_: String,
    };
  }

  private cancelButtonVisible_: boolean;
  private closeButtonVisible_: boolean;
  private confirmButtonDisabled_: boolean;
  private confirmButtonLabel_: string;
  private confirmButtonVisible_: boolean;
  private confirmMsg_: string;
  private credentialIdToDelete_: string;
  private credentials_: Credential[];
  private dialogPage_: CredentialManagementDialogPage;
  private dialogTitle_: string;
  private displayNameInputError_: string;
  private editingCredential_: Credential;
  private editButtonVisible_: boolean;
  private errorMsg_: string;
  private minPinLength_: number;
  private newDisplayName_: string;
  private newUsername_: string;
  private userNameInputError_: string;

  private browserProxy_: SecurityKeysCredentialBrowserProxy =
      SecurityKeysCredentialBrowserProxyImpl.getInstance();
  private showSetPINButton_: boolean = false;

  override connectedCallback() {
    super.connectedCallback();

    this.$.dialog.showModal();
    this.addWebUiListener(
        'security-keys-credential-management-finished',
        (error: string, requiresPINChange = false) =>
            this.onPinError_(error, requiresPINChange));
    this.browserProxy_.startCredentialManagement().then(
        (response: StartCredentialManagementResponse) => {
          this.minPinLength_ = response.minPinLength;
          this.editButtonVisible_ = response.supportsUpdateUserInformation;
          this.dialogPage_ = CredentialManagementDialogPage.PIN_PROMPT;
        });
  }

  private onPinError_(error: string, requiresPINChange = false) {
    this.errorMsg_ = error;
    this.showSetPINButton_ = requiresPINChange;
    this.dialogPage_ = CredentialManagementDialogPage.PIN_ERROR;
  }

  private onError_(error: string) {
    this.errorMsg_ = error;
    this.dialogPage_ = CredentialManagementDialogPage.ERROR;
  }

  private submitPin_() {
    // Disable the confirm button to prevent concurrent submissions.
    this.confirmButtonDisabled_ = true;

    this.$.pin.trySubmit(pin => this.browserProxy_.providePin(pin))
        .then(
            () => {
              // Leave confirm button disabled while enumerating credentials.
              this.browserProxy_.enumerateCredentials().then(
                  (credentials: Credential[]) =>
                      this.onCredentials_(credentials));
            },
            () => {
              // Wrong PIN.
              this.confirmButtonDisabled_ = false;
            });
  }

  private onCredentials_(credentials: Credential[]) {
    this.credentials_ = credentials;
    this.$.credentialList.fire('iron-resize');
    this.dialogPage_ = CredentialManagementDialogPage.CREDENTIALS;
  }

  private dialogPageChanged_() {
    switch (this.dialogPage_) {
      case CredentialManagementDialogPage.INITIAL:
        this.cancelButtonVisible_ = true;
        this.confirmButtonVisible_ = false;
        this.closeButtonVisible_ = false;
        this.dialogTitle_ =
            this.i18n('securityKeysCredentialManagementDialogTitle');
        break;
      case CredentialManagementDialogPage.PIN_PROMPT:
        this.cancelButtonVisible_ = false;
        this.confirmButtonLabel_ = this.i18n('continue');
        this.confirmButtonDisabled_ = false;
        this.confirmButtonVisible_ = true;
        this.closeButtonVisible_ = false;
        this.dialogTitle_ =
            this.i18n('securityKeysCredentialManagementDialogTitle');
        this.$.pin.focus();
        break;
      case CredentialManagementDialogPage.PIN_ERROR:
        this.cancelButtonVisible_ = true;
        this.confirmButtonLabel_ = this.i18n('securityKeysSetPinButton');
        this.confirmButtonVisible_ = this.showSetPINButton_;
        this.confirmButtonDisabled_ = false;
        this.closeButtonVisible_ = false;
        this.dialogTitle_ =
            this.i18n('securityKeysCredentialManagementDialogTitle');
        break;
      case CredentialManagementDialogPage.CREDENTIALS:
        this.cancelButtonVisible_ = false;
        this.confirmButtonLabel_ = this.i18n('done');
        this.confirmButtonDisabled_ = false;
        this.confirmButtonVisible_ = true;
        this.closeButtonVisible_ = false;
        this.dialogTitle_ =
            this.i18n('securityKeysCredentialManagementDialogTitle');
        break;
      case CredentialManagementDialogPage.EDIT:
        this.cancelButtonVisible_ = true;
        this.confirmButtonLabel_ = this.i18n('save');
        this.confirmButtonDisabled_ = false;
        this.confirmButtonVisible_ = true;
        this.closeButtonVisible_ = false;
        this.dialogTitle_ =
            this.i18n('securityKeysUpdateCredentialDialogTitle');
        break;
      case CredentialManagementDialogPage.ERROR:
        this.cancelButtonVisible_ = false;
        this.confirmButtonLabel_ = this.i18n('continue');
        this.confirmButtonDisabled_ = false;
        this.confirmButtonVisible_ = true;
        this.closeButtonVisible_ = false;
        this.dialogTitle_ =
            this.i18n('securityKeysCredentialManagementDialogTitle');
        break;
      case CredentialManagementDialogPage.CONFIRM:
        this.cancelButtonVisible_ = true;
        this.confirmButtonLabel_ = this.i18n('delete');
        this.confirmButtonVisible_ = true;
        this.closeButtonVisible_ = false;
        this.dialogTitle_ =
            this.i18n('securityKeysCredentialManagementConfirmDeleteTitle');
        break;
      default:
        assertNotReached();
    }
    this.dispatchEvent(new CustomEvent(
        'credential-management-dialog-ready-for-testing',
        {bubbles: true, composed: true}));
  }

  private onConfirmButtonClick_() {
    switch (this.dialogPage_) {
      case CredentialManagementDialogPage.PIN_PROMPT:
        this.submitPin_();
        break;
      case CredentialManagementDialogPage.PIN_ERROR:
        this.$.dialog.close();
        this.dispatchEvent(new CustomEvent(
            'credential-management-set-pin', {bubbles: true, composed: true}));
        break;
      case CredentialManagementDialogPage.CREDENTIALS:
        this.$.dialog.close();
        break;
      case CredentialManagementDialogPage.EDIT:
        this.updateUserInformation_();
        break;
      case CredentialManagementDialogPage.ERROR:
        this.dialogPage_ = CredentialManagementDialogPage.CREDENTIALS;
        break;
      case CredentialManagementDialogPage.CONFIRM:
        this.deleteCredential_();
        break;
      default:
        assertNotReached();
    }
  }

  private onCancelButtonClick_() {
    switch (this.dialogPage_) {
      case CredentialManagementDialogPage.INITIAL:
      case CredentialManagementDialogPage.PIN_PROMPT:
      case CredentialManagementDialogPage.PIN_ERROR:
      case CredentialManagementDialogPage.CREDENTIALS:
        this.$.dialog.close();
        break;
      case CredentialManagementDialogPage.EDIT:
      case CredentialManagementDialogPage.ERROR:
      case CredentialManagementDialogPage.CONFIRM:
        this.dialogPage_ = CredentialManagementDialogPage.CREDENTIALS;
        break;
      default:
        assertNotReached();
    }
  }

  private onDialogClosed_() {
    this.browserProxy_.close();
  }

  private close_() {
    this.$.dialog.close();
  }

  private isEmpty_(str: string|null): boolean {
    return !str || str.length === 0;
  }

  private onIronSelect_(e: Event) {
    // Prevent this event from bubbling since it is unnecessarily triggering
    // the listener within settings-animated-pages.
    e.stopPropagation();

    // Asynchronously notify the iron-list of the possible resize.
    setTimeout(() => this.$.credentialList.notifyResize(), 0);
  }

  private onDeleteButtonClick_(e: Event) {
    const target = e.target as CrIconButtonElement;
    this.credentialIdToDelete_ = target.dataset['credentialid']!;
    assert(!this.isEmpty_(this.credentialIdToDelete_));

    this.confirmMsg_ =
        this.i18n('securityKeysCredentialManagementConfirmDeleteCredential');
    this.dialogPage_ = CredentialManagementDialogPage.CONFIRM;
  }

  private deleteCredential_() {
    this.browserProxy_.deleteCredentials([this.credentialIdToDelete_])
        .then((response) => {
          if (!response.success) {
            this.onError_(response.message);
            return;
          }
          for (let i = 0; i < this.credentials_.length; i++) {
            if (this.credentials_[i].credentialId ===
                this.credentialIdToDelete_) {
              this.credentials_.splice(i, 1);
              break;
            }
          }
          this.dialogPage_ = CredentialManagementDialogPage.CREDENTIALS;
        });
  }

  private validateInput_() {
    this.displayNameInputError_ =
        this.newDisplayName_.length > MAX_INPUT_LENGTH ?
        this.i18n('securityKeysInputTooLong') :
        '';
    this.userNameInputError_ = this.newUsername_.length > MAX_INPUT_LENGTH ?
        this.i18n('securityKeysInputTooLong') :
        '';

    this.confirmButtonDisabled_ =
        !this.isEmpty_(this.displayNameInputError_ + this.userNameInputError_);
  }

  private onUpdateButtonClick_(e: Event) {
    const target = e.target as CrIconButtonElement;

    for (const credential of this.credentials_) {
      if (credential.credentialId === target.dataset['credentialid']!) {
        this.editingCredential_ = credential;
        break;
      }
    }

    this.newDisplayName_ = this.editingCredential_.userDisplayName;
    this.newUsername_ = this.editingCredential_.userName;

    this.dialogPage_ = CredentialManagementDialogPage.EDIT;
  }

  private updateUserInformation_() {
    assert(this.dialogPage_ === CredentialManagementDialogPage.EDIT);

    if (this.isEmpty_(this.newUsername_)) {
      this.newUsername_ = this.editingCredential_.userName;
    }
    if (this.isEmpty_(this.newDisplayName_)) {
      this.newDisplayName_ = this.editingCredential_.userDisplayName;
    }

    this.browserProxy_
        .updateUserInformation(
            this.editingCredential_.credentialId,
            this.editingCredential_.userHandle, this.newUsername_,
            this.newDisplayName_)
        .then((response) => {
          if (!response.success) {
            this.onError_(response.message);
            return;
          }

          for (let i = 0; i < this.credentials_.length; i++) {
            if (this.credentials_[i].credentialId ===
                this.editingCredential_.credentialId) {
              const newCred: Credential =
                  Object.assign({}, this.credentials_[i]);

              newCred.userName = this.newUsername_;
              newCred.userDisplayName = this.newDisplayName_;

              this.credentials_.splice(i, 1, newCred);
              this.$.credentialList.fire('iron-resize');
              break;
            }
          }
        });
    this.dialogPage_ = CredentialManagementDialogPage.CREDENTIALS;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-security-keys-credential-management-dialog':
        SettingsSecurityKeysCredentialManagementDialogElement;
  }
}

customElements.define(
    SettingsSecurityKeysCredentialManagementDialogElement.is,
    SettingsSecurityKeysCredentialManagementDialogElement);