chromium/chrome/browser/resources/settings/privacy_page/security_keys_bio_enroll_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-bio-enroll-dialog' is a dialog for
 * listing, adding, renaming, and deleting biometric enrollments stored on a
 * security key.
 */

import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/cr_elements/cr_input/cr_input.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/cr_page_selector/cr_page_selector.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.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 './fingerprint_progress_arc.js';

import {getInstance as getAnnouncerInstance} from 'chrome://resources/cr_elements/cr_a11y_announcer/cr_a11y_announcer.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 {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 {FingerprintProgressArcElement} from './fingerprint_progress_arc.js';
import {getTemplate} from './security_keys_bio_enroll_dialog.html.js';
import type {Enrollment, EnrollmentResponse, SampleResponse, SecurityKeysBioEnrollProxy} from './security_keys_browser_proxy.js';
import {Ctap2Status, SampleStatus, SecurityKeysBioEnrollProxyImpl} from './security_keys_browser_proxy.js';
import type {SettingsSecurityKeysPinFieldElement} from './security_keys_pin_field.js';

export enum BioEnrollDialogPage {
  INITIAL = 'initial',
  PIN_PROMPT = 'pinPrompt',
  ENROLLMENTS = 'enrollments',
  ENROLL = 'enroll',
  CHOOSE_NAME = 'chooseName',
  ERROR = 'error',
}

export interface SettingsSecurityKeysBioEnrollDialogElement {
  $: {
    addButton: HTMLElement,
    arc: FingerprintProgressArcElement,
    cancelButton: CrButtonElement,
    confirmButton: CrButtonElement,
    dialog: CrDialogElement,
    error: HTMLElement,
    enrollmentList: IronListElement,
    enrollmentName: CrInputElement,
    pin: SettingsSecurityKeysPinFieldElement,
  };
}

const SettingsSecurityKeysBioEnrollDialogElementBase =
    WebUiListenerMixin(I18nMixin(PolymerElement));

export class SettingsSecurityKeysBioEnrollDialogElement extends
    SettingsSecurityKeysBioEnrollDialogElementBase {
  static get is() {
    return 'settings-security-keys-bio-enroll-dialog';
  }

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

  static get properties() {
    return {
      cancelButtonDisabled_: Boolean,
      cancelButtonVisible_: Boolean,
      confirmButtonDisabled_: Boolean,
      confirmButtonVisible_: Boolean,
      confirmButtonLabel_: String,
      deleteInProgress_: Boolean,

      /**
       * The ID of the element currently shown in the dialog.
       */
      dialogPage_: {
        type: String,
        value: BioEnrollDialogPage.INITIAL,
        observer: 'dialogPageChanged_',
      },

      doneButtonVisible_: Boolean,

      /**
       * The list of enrollments displayed.
       */
      enrollments_: Array,

      minPinLength_: Number,
      progressArcLabel_: String,
      recentEnrollmentName_: String,
      enrollmentNameError_: String,
      enrollmentNameMaxUtf8Length_: Number,
      errorMsg_: String,
    };
  }

  private cancelButtonDisabled_: boolean;
  private cancelButtonVisible_: boolean;
  private confirmButtonDisabled_: boolean;
  private confirmButtonVisible_: boolean;
  private confirmButtonLabel_: string;
  private deleteInProgress_: boolean;
  private dialogPage_: BioEnrollDialogPage;
  private doneButtonVisible_: boolean;
  private enrollments_: Enrollment[];
  private minPinLength_: number;
  private progressArcLabel_: string;
  private recentEnrollmentName_: string;
  private enrollmentNameError_: string|null;
  private enrollmentNameMaxUtf8Length_: number;
  private errorMsg_: string;

  private browserProxy_: SecurityKeysBioEnrollProxy =
      SecurityKeysBioEnrollProxyImpl.getInstance();
  private maxSamples_: number = -1;
  private recentEnrollmentId_: string = '';
  private showSetPINButton_: boolean = false;

  override connectedCallback() {
    super.connectedCallback();
    this.$.dialog.showModal();
    this.addWebUiListener(
        'security-keys-bio-enroll-error',
        (error: string, requiresPINChange = false) =>
            this.onError_(error, requiresPINChange));
    this.addWebUiListener(
        'security-keys-bio-enroll-status',
        (response: SampleResponse) => this.onEnrollmentSample_(response));
    this.browserProxy_.startBioEnroll().then(([minPinLength]) => {
      this.minPinLength_ = minPinLength;
      this.dialogPage_ = BioEnrollDialogPage.PIN_PROMPT;
    });
  }

  setDialogPageForTesting(page: BioEnrollDialogPage) {
    this.dialogPage_ = page;
  }

  private fire_(eventName: string, detail?: any) {
    this.dispatchEvent(
        new CustomEvent(eventName, {bubbles: true, composed: true, detail}));
  }

  private onError_(error: string, requiresPINChange = false) {
    this.errorMsg_ = error;
    this.showSetPINButton_ = requiresPINChange;
    this.dialogPage_ = BioEnrollDialogPage.ERROR;
  }

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

    this.$.pin.trySubmit(pin => this.browserProxy_.providePin(pin))
        .then(
            () => {
              this.browserProxy_.getSensorInfo().then(sensorInfo => {
                this.enrollmentNameMaxUtf8Length_ =
                    sensorInfo.maxTemplateFriendlyName;
                // Leave confirm button disabled while enumerating fingerprints.
                // It will be re-enabled by dialogPageChanged_() where
                // appropriate.
                this.showEnrollmentsPage_();
              });
            },
            () => {
              // Wrong PIN.
              this.confirmButtonDisabled_ = false;
            });
  }

  private onEnrollments_(enrollments: Enrollment[]) {
    this.enrollments_ =
        enrollments.slice().sort((a, b) => a.name.localeCompare(b.name));
    this.$.enrollmentList.fire('iron-resize');
    this.dialogPage_ = BioEnrollDialogPage.ENROLLMENTS;
  }

  setCancelButtonDisabledForTesting(disabled: boolean) {
    this.cancelButtonDisabled_ = disabled;
  }

  private dialogPageChanged_() {
    switch (this.dialogPage_) {
      case BioEnrollDialogPage.INITIAL:
        this.cancelButtonVisible_ = true;
        this.cancelButtonDisabled_ = false;
        this.confirmButtonVisible_ = false;
        this.doneButtonVisible_ = false;
        break;
      case BioEnrollDialogPage.PIN_PROMPT:
        this.cancelButtonVisible_ = true;
        this.cancelButtonDisabled_ = false;
        this.confirmButtonVisible_ = true;
        this.confirmButtonLabel_ = this.i18n('continue');
        this.confirmButtonDisabled_ = false;
        this.doneButtonVisible_ = false;
        this.$.pin.focus();
        break;
      case BioEnrollDialogPage.ENROLLMENTS:
        this.cancelButtonVisible_ = false;
        this.confirmButtonVisible_ = false;
        this.doneButtonVisible_ = true;
        break;
      case BioEnrollDialogPage.ENROLL:
        this.cancelButtonVisible_ = true;
        this.cancelButtonDisabled_ = false;
        this.confirmButtonVisible_ = false;
        this.doneButtonVisible_ = false;
        break;
      case BioEnrollDialogPage.CHOOSE_NAME:
        this.cancelButtonVisible_ = false;
        this.confirmButtonVisible_ = true;
        this.confirmButtonLabel_ = this.i18n('continue');
        this.confirmButtonDisabled_ = !this.recentEnrollmentName_.length;
        this.doneButtonVisible_ = false;
        this.$.enrollmentName.focus();
        break;
      case BioEnrollDialogPage.ERROR:
        this.cancelButtonVisible_ = true;
        this.confirmButtonVisible_ = this.showSetPINButton_;
        this.confirmButtonLabel_ = this.i18n('securityKeysSetPinButton');
        this.doneButtonVisible_ = false;
        break;
      default:
        assertNotReached();
    }
    this.fire_('bio-enroll-dialog-ready-for-testing');
  }

  private addButtonClick_() {
    assert(this.dialogPage_ === BioEnrollDialogPage.ENROLLMENTS);

    this.maxSamples_ = -1;  // Reset maxSamples_ before enrolling starts.
    this.$.arc.reset();
    this.progressArcLabel_ =
        this.i18n('securityKeysBioEnrollmentEnrollingLabel');

    this.recentEnrollmentId_ = '';
    this.recentEnrollmentName_ = '';

    this.dialogPage_ = BioEnrollDialogPage.ENROLL;

    this.browserProxy_.startEnrolling().then(response => {
      this.onEnrollmentComplete_(response);
    });
  }

  private onEnrollmentSample_(response: SampleResponse) {
    if (response.status !== SampleStatus.OK) {
      this.progressArcLabel_ =
          this.i18n('securityKeysBioEnrollmentTryAgainLabel');
      getAnnouncerInstance().announce(this.progressArcLabel_);
      return;
    }

    this.progressArcLabel_ =
        this.i18n('securityKeysBioEnrollmentEnrollingLabel');

    assert(response.remaining >= 0);

    if (this.maxSamples_ === -1) {
      this.maxSamples_ = response.remaining + 1;
    }

    this.$.arc.setProgress(
        100 * (this.maxSamples_ - response.remaining - 1) / this.maxSamples_,
        100 * (this.maxSamples_ - response.remaining) / this.maxSamples_,
        false);
  }

  private onEnrollmentComplete_(response: EnrollmentResponse) {
    switch (response.code) {
      case Ctap2Status.OK:
        break;
      case Ctap2Status.ERR_KEEPALIVE_CANCEL:
        this.showEnrollmentsPage_();
        return;
      case Ctap2Status.ERR_FP_DATABASE_FULL:
        this.onError_(this.i18n('securityKeysBioEnrollmentStorageFullLabel'));
        return;
      default:
        this.onError_(
            this.i18n('securityKeysBioEnrollmentEnrollingFailedLabel'));
        return;
    }

    this.maxSamples_ = Math.max(this.maxSamples_, 1);
    this.$.arc.setProgress(
        100 * (this.maxSamples_ - 1) / this.maxSamples_, 100, true);

    assert(response.enrollment);
    this.recentEnrollmentId_ = response.enrollment!.id;
    this.recentEnrollmentName_ = response.enrollment!.name;
    this.cancelButtonVisible_ = false;
    this.confirmButtonVisible_ = true;
    this.confirmButtonDisabled_ = false;
    this.progressArcLabel_ =
        this.i18n('securityKeysBioEnrollmentEnrollingCompleteLabel');
    this.$.confirmButton.focus();
    // Make screen-readers announce enrollment completion.
    this.fire_('iron-announce', {text: this.progressArcLabel_});

    this.fire_('bio-enroll-dialog-ready-for-testing');
  }

  private confirmButtonClick_() {
    switch (this.dialogPage_) {
      case BioEnrollDialogPage.PIN_PROMPT:
        this.submitPin_();
        break;
      case BioEnrollDialogPage.ENROLL:
        assert(!!this.recentEnrollmentId_.length);
        this.dialogPage_ = BioEnrollDialogPage.CHOOSE_NAME;
        break;
      case BioEnrollDialogPage.CHOOSE_NAME:
        this.renameNewEnrollment_();
        break;
      case BioEnrollDialogPage.ERROR:
        this.$.dialog.close();
        this.fire_('bio-enroll-set-pin');
        break;
      default:
        assertNotReached();
    }
  }

  private renameNewEnrollment_() {
    assert(this.dialogPage_ === BioEnrollDialogPage.CHOOSE_NAME);

    // Check that the user-provided name doesn't exceed the maximum permissible
    // length reported by the security key when encoded as UTF-8. (Note that
    // JavaScript String length counts code units, but string length maximums in
    // CTAP 2.1 are generally on UTF-8 bytes.)
    if (new TextEncoder().encode(this.recentEnrollmentName_).length >
        this.enrollmentNameMaxUtf8Length_) {
      this.enrollmentNameError_ =
          this.i18n('securityKeysBioEnrollmentNameLabelTooLong');
      return;
    }
    this.enrollmentNameError_ = null;

    // Disable the confirm button to prevent concurrent submissions. It will
    // be re-enabled by dialogPageChanged_() where appropriate.
    this.confirmButtonDisabled_ = true;
    this.browserProxy_
        .renameEnrollment(this.recentEnrollmentId_, this.recentEnrollmentName_)
        .then(enrollments => {
          this.onEnrollments_(enrollments);
        });
  }

  private showEnrollmentsPage_() {
    this.browserProxy_.enumerateEnrollments().then(enrollments => {
      this.onEnrollments_(enrollments);
    });
  }

  private cancel_() {
    if (this.dialogPage_ === BioEnrollDialogPage.ENROLL) {
      // Cancel an ongoing enrollment.  Will cause the pending
      // enumerateEnrollments() promise to be resolved and proceed to the
      // enrollments page.
      this.cancelButtonDisabled_ = true;
      this.browserProxy_.cancelEnrollment();
    } else {
      // On any other screen, simply close the dialog.
      this.done_();
    }
  }

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

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

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

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

  private deleteEnrollment_(event: {model: {index: number}}) {
    if (this.deleteInProgress_) {
      return;
    }
    this.deleteInProgress_ = true;
    const enrollment = this.enrollments_[event.model.index];
    this.browserProxy_.deleteEnrollment(enrollment.id).then(enrollments => {
      this.deleteInProgress_ = false;
      this.onEnrollments_(enrollments);
    });
  }

  private onEnrollmentNameInput_() {
    this.confirmButtonDisabled_ = !this.recentEnrollmentName_.length;
  }

  /**
   * @return The title string for the current dialog page.
   */
  private dialogTitle_(dialogPage: BioEnrollDialogPage): string {
    if (dialogPage === BioEnrollDialogPage.ENROLL ||
        dialogPage === BioEnrollDialogPage.CHOOSE_NAME) {
      return this.i18n('securityKeysBioEnrollmentAddTitle');
    }
    return this.i18n('securityKeysBioEnrollmentDialogTitle');
  }

  /**
   * @return The header label for the enrollments page.
   */
  private enrollmentsHeader_(enrollments: Enrollment[]|null): string {
    return this.i18n(
        enrollments && enrollments.length ?
            'securityKeysBioEnrollmentEnrollmentsLabel' :
            'securityKeysBioEnrollmentNoEnrollmentsLabel');
  }

  private isNullOrEmpty_(s: string): boolean {
    return s === '' || !s;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-security-keys-bio-enroll-dialog':
        SettingsSecurityKeysBioEnrollDialogElement;
  }
}

customElements.define(
    SettingsSecurityKeysBioEnrollDialogElement.is,
    SettingsSecurityKeysBioEnrollDialogElement);