chromium/ash/webui/common/resources/quick_unlock/setup_pin_keyboard.ts

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

/**
 * @fileoverview
 * 'setup-pin-keyboard' is the keyboard/input field for choosing a PIN.
 *
 * See usage documentation in setup_pin_keyboard.html.
 *
 */

import 'chrome://resources/ash/common/cr_elements/cr_shared_vars.css.js';
import './pin_keyboard.js';

import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {ConfigureResult, PinFactorEditor} from 'chrome://resources/mojo/chromeos/ash/services/auth_factor_config/public/mojom/auth_factor_config.mojom-webui.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {LockScreenProgress, recordLockScreenProgress} from './lock_screen_constants.js';
import {PinKeyboardElement} from './pin_keyboard.js';
import {getTemplate} from './setup_pin_keyboard.html.js';
import {fireAuthTokenInvalidEvent} from './utils.js';

/**
 * Keep in sync with the string keys provided by settings.
 */
export enum MessageType {
  TOO_SHORT = 'configurePinTooShort',
  TOO_LONG = 'configurePinTooLong',
  TOO_WEAK = 'configurePinWeakPin',
  CONTAINS_NONDIGIT = 'configurePinNondigit',
  MISMATCH = 'configurePinMismatched',
  INTERNAL_ERROR = 'internalError',
}

export enum ProblemType {
  WARNING = 'warning',
  ERROR = 'error',
}

const SetupPinKeyboardElementBase = I18nMixin(PolymerElement);

export interface SetupPinKeyboardElement {
  $: {
    pinKeyboard: PinKeyboardElement,
  };
}

export class SetupPinKeyboardElement extends SetupPinKeyboardElementBase {
  static get is() {
    return 'setup-pin-keyboard' as const;
  }

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

  static get properties() {
    return {
      /**
       * The token to be used to call into the PinFactorEditor mojo service.
       */
      authToken: String,

      /**
       * The current PIN keyboard value.
       */
      pinKeyboardValue_: {
        type: String,
        value: '',
      },

      /**
       * Stores the initial PIN value so it can be confirmed.
       */
      initialPin_: {
        type: String,
        value: '',
      },

      /**
       * The message ID of actual problem message to display.
       */
      problemMessageId_: {
        type: String,
        value: '',
      },

      /**
       * The additional parameters to format for the problem message string.
       */
      problemMessageParameters_: {
        type: String,
        value: '',
      },

      /**
       * The type of problem class to show (warning or error).
       */
      problemClass_: String,

      /**
       * Should the step-specific submit button be displayed?
       * This has upward data flow only.
       */
      enableSubmit: {
        notify: true,
        type: Boolean,
        value: false,
      },

      /**
       * writeUma is a function that handles writing uma stats.
       */
      writeUma: {
        type: Object,
        value() {
          return function() {};
        },
      },

      /**
       * The current step/subpage we are on.
       * This is has upward data flow only.
       */
      isConfirmStep: {
        notify: true,
        type: Boolean,
        value: false,
      },

      /**
       * Interface for chrome.quickUnlockPrivate calls.
       */
      quickUnlockPrivate: Object,

      /**
       * |pinHasPassedMinimumLength_| tracks whether a user has passed the
       * minimum length threshold at least once, and all subsequent PIN too
       * short messages will be displayed as errors. They will be displayed as
       * warnings prior to this.
       */
      pinHasPassedMinimumLength_: {type: Boolean, value: false},

      /**
       * Enables pin placeholder.
       */
      enablePlaceholder: {
        type: Boolean,
        value: false,
      },

      isSetPinCallPending_: {
        notify: true,
        type: Boolean,
        value: false,
      },
    };
  }

  private pinKeyboardValue_: string;
  private initialPin_: string;
  private problemMessageId_: string;
  private problemMessageParameters_: string;
  private problemClass_: string|undefined;
  private pinHasPassedMinimumLength_: boolean;
  private isSetPinCallPending_: boolean;
  authToken: string|undefined;
  enableSubmit: boolean;
  writeUma: (progress: LockScreenProgress) => void;
  isConfirmStep: boolean;
  quickUnlockPrivate: typeof chrome.quickUnlockPrivate;
  enablePlaceholder: boolean;

  override focus(): void {
    this.$.pinKeyboard.focusInput();
  }

  override connectedCallback(): void {
    this.resetState();

    // Show the pin is too short error when first displaying the PIN dialog.
    this.problemClass_ = ProblemType.WARNING;
    chrome.quickUnlockPrivate.getCredentialRequirements(
        chrome.quickUnlockPrivate.QuickUnlockMode.PIN,
        this.processPinRequirements_.bind(this, MessageType.TOO_SHORT));
  }

  /**
   * Resets the element to the initial state.
   */
  resetState(): void {
    this.initialPin_ = '';
    this.pinKeyboardValue_ = '';
    this.enableSubmit = false;
    this.isConfirmStep = false;
    this.pinHasPassedMinimumLength_ = false;
    this.hideProblem_();
    this.onPinChange_(
        new CustomEvent('pin-change', {detail: {pin: this.pinKeyboardValue_}}));
  }

  /**
   * Returns true if the PIN is ready to be changed to a new value.
   */
  private canSubmit_(): boolean {
    return this.initialPin_ === this.pinKeyboardValue_;
  }

  /**
   * Handles writing the appropriate message to |problemMessageId_| &&
   * |problemMessageParameters_|.
   * @param messageId
   * @param requirements
   *     The requirements received from getCredentialRequirements.
   */
  private processPinRequirements_(
      messageId: MessageType,
      requirements: chrome.quickUnlockPrivate.CredentialRequirements): void {
    let additionalInformation = '';
    switch (messageId) {
      case MessageType.TOO_SHORT:
        additionalInformation = requirements.minLength.toString();
        break;
      case MessageType.TOO_LONG:
        additionalInformation = (requirements.maxLength + 1).toString();
        break;
      case MessageType.TOO_WEAK:
      case MessageType.CONTAINS_NONDIGIT:
      case MessageType.MISMATCH:
      case MessageType.INTERNAL_ERROR:
        break;
      default:
        assertNotReached();
    }
    this.problemMessageId_ = messageId;
    this.problemMessageParameters_ = additionalInformation;
  }

  /**
   * Notify the user about a problem.
   */
  private showProblem_(messageId: MessageType, problemClass: ProblemType):
      void {
    this.quickUnlockPrivate.getCredentialRequirements(
        chrome.quickUnlockPrivate.QuickUnlockMode.PIN,
        this.processPinRequirements_.bind(this, messageId));
    this.problemClass_ = problemClass;
    this.enableSubmit = problemClass !== ProblemType.ERROR &&
        messageId !== MessageType.TOO_SHORT;
  }

  private hideProblem_(): void {
    this.problemMessageId_ = '';
    this.problemClass_ = '';
  }

  /**
   * Processes the message received from the quick unlock api and hides/shows
   * the problem based on the message.
   */
  private processPinProblems_(message:
                                  chrome.quickUnlockPrivate.CredentialCheck) {
    if (!message.errors.length && !message.warnings.length) {
      this.hideProblem_();
      this.enableSubmit = true;
      this.pinHasPassedMinimumLength_ = true;
      return;
    }

    if (!message.errors.length ||
        message.errors[0] !==
            chrome.quickUnlockPrivate.CredentialProblem.TOO_SHORT) {
      this.pinHasPassedMinimumLength_ = true;
    }

    if (message.warnings.length) {
      assert(
          message.warnings[0] ===
          chrome.quickUnlockPrivate.CredentialProblem.TOO_WEAK);
      this.showProblem_(MessageType.TOO_WEAK, ProblemType.WARNING);
    }

    if (message.errors.length) {
      switch (message.errors[0]) {
        case chrome.quickUnlockPrivate.CredentialProblem.TOO_SHORT:
          this.showProblem_(
              MessageType.TOO_SHORT,
              this.pinHasPassedMinimumLength_ ? ProblemType.ERROR :
                                                ProblemType.WARNING);
          break;
        case chrome.quickUnlockPrivate.CredentialProblem.TOO_LONG:
          this.showProblem_(MessageType.TOO_LONG, ProblemType.ERROR);
          break;
        case chrome.quickUnlockPrivate.CredentialProblem.TOO_WEAK:
          this.showProblem_(MessageType.TOO_WEAK, ProblemType.ERROR);
          break;
        case chrome.quickUnlockPrivate.CredentialProblem.CONTAINS_NONDIGIT:
          this.showProblem_(MessageType.CONTAINS_NONDIGIT, ProblemType.ERROR);
          break;
        default:
          assertNotReached();
      }
    }
  }

  /**
   * @param e Custom event containing the new pin.
   */
  private onPinChange_(e: CustomEvent<{pin: string}>): void {
    const newPin = e.detail.pin;
    if (!this.isConfirmStep) {
      if (newPin) {
        this.quickUnlockPrivate.checkCredential(
            chrome.quickUnlockPrivate.QuickUnlockMode.PIN, newPin,
            this.processPinProblems_.bind(this));
      } else {
        this.enableSubmit = false;
        this.showProblem_(
            MessageType.TOO_SHORT,
            this.pinHasPassedMinimumLength_ ? ProblemType.ERROR :
                                              ProblemType.WARNING);
      }
      return;
    }

    this.hideProblem_();
    this.enableSubmit = newPin.length > 0;
  }

  private onPinSubmit_(): void {
    // Notify container object.
    this.dispatchEvent(new Event('pin-submit'));
  }

  /** This is called by container object when user initiated submit. */
  async doSubmit(): Promise<void> {
    if (!this.isConfirmStep) {
      if (!this.enableSubmit) {
        return;
      }
      this.initialPin_ = this.pinKeyboardValue_;
      this.pinKeyboardValue_ = '';
      this.isConfirmStep = true;
      this.onPinChange_(new CustomEvent(
          'pin-change', {detail: {pin: this.pinKeyboardValue_}}));
      this.$.pinKeyboard.focusInput();
      recordLockScreenProgress(LockScreenProgress.ENTER_PIN);
      return;
    }
    // onPinSubmit gets called if the user hits enter on the PIN keyboard.
    // The PIN is not guaranteed to be valid in that case.
    if (!this.canSubmit_()) {
      this.showProblem_(MessageType.MISMATCH, ProblemType.ERROR);
      this.enableSubmit = false;
      // Focus the PIN keyboard and highlight the entire PIN.
      this.$.pinKeyboard.focusInput(0, this.pinKeyboardValue_.length + 1);
      return;
    }

    if (typeof this.authToken !== 'string') {
      fireAuthTokenInvalidEvent(this);
      return;
    }

    this.isSetPinCallPending_ = true;
    this.enableSubmit = false;
    const {result} = await PinFactorEditor.getRemote().setPin(
        this.authToken, this.pinKeyboardValue_);
    this.isSetPinCallPending_ = false;

    switch (result) {
      case ConfigureResult.kSuccess:
        break;
      case ConfigureResult.kInvalidTokenError:
        fireAuthTokenInvalidEvent(this);
        break;
      case ConfigureResult.kFatalError:
        console.error('Failed to update pin');
        this.showProblem_(MessageType.INTERNAL_ERROR, ProblemType.ERROR);
        // Enable submission again: We don't know why this failed, and perhaps
        // submitting again resolves the issue.
        this.enableSubmit = true;
        // Do not reset state, close the dialog or generate a set-pin-done
        // event -- this would lead the user to think that setting PIN was
        // successful when it has actually failed.
        return;
    }

    this.resetState();
    this.dispatchEvent(new Event('set-pin-done'));
    recordLockScreenProgress(LockScreenProgress.CONFIRM_PIN);
  }

  private hasError_(problemMessageId: string, problemClass: ProblemType):
      boolean {
    return !!problemMessageId && problemClass === ProblemType.ERROR;
  }

  /**
   * Format problem message
   */
  private formatProblemMessage_(
      locale: string, messageId: string|undefined,
      messageParameters: string): string {
    return messageId ? this.i18nDynamic(locale, messageId, messageParameters) :
                       '';
  }
}

customElements.define(SetupPinKeyboardElement.is, SetupPinKeyboardElement);
declare global {
  interface HTMLElementTagNameMap {
    [SetupPinKeyboardElement.is]: SetupPinKeyboardElement;
  }
}