chromium/ash/webui/common/resources/auth_setup/set_local_password_input.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/ash/common/cr_elements/cr_shared_style.css.js';
import '//resources/ash/common/cr_elements/cros_color_overrides.css.js';
import './auth_setup_icons.html.js';

import {CrInputElement} from 'chrome://resources/ash/common/cr_elements/cr_input/cr_input.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {assertInstanceof, assertNotReached} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PasswordComplexity, PasswordFactorEditor} 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 {getTemplate} from './set_local_password_input.html.js';

const LocalPasswordInputElementBase = I18nMixin(PolymerElement);

export interface SetLocalPasswordInputElement {
  $: {
    firstInput: CrInputElement,
    confirmInput: CrInputElement,
  };
}

enum FirstInputValidity {
  OK,
  TOO_SHORT,
}

enum ConfirmInputValidity {
  OK,
  NO_MATCH,
}

/**
 * @fileoverview 'set-local-password-input' is a component that consists of two
 * input fields: The first input field to ask for a new password, and a second
 * input field for confirmation.
 *
 * The element does not affect a password change itself. Instead, it makes the
 * password value that the user has entered available to parent elements via
 * the read-only |value| property. |value| is non-null only after validation
 * was successful and the user hasn't changed the first or confirm input
 * since the last validation.
 *
 * Validation triggers automatically when one of the input fields loses focus
 * or the user presses <Enter> in one of those fields, and parent elements can
 * explicitly request validation by calling |validate|. The element calls into
 * the PasswordFactorEditor mojo service for validation, so it can only be used
 * in WebUIs in which this mojo service is available.
 *
 * When the user presses <Enter> in the confirmation input field and validation
 * passes, then the 'set-local-password-input' element dispatches a "submit"
 * event.
 *
 * TODO(b/309430756): Reuse ShowPasswordMixin here.
 */
export class SetLocalPasswordInputElement extends
    LocalPasswordInputElementBase {
  static get is() {
    return 'set-local-password-input' as const;
  }

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

  static get properties(): object {
    return {
      /**
       * The password value that the user has entered. Non-null only if the
       * |validate| method has been called and validation has passed.
       */
      value: {
        notify: true,
        type: String,
        computed: 'computeValue(firstInputValidity_, confirmInputValidity_)',
      },

      /**
       * This is here to make this element usable in OOBE, where the locale
       * can change dynamically. This attribute replicates the functionality of
       * OobeI18nMixin.
       */
      locale: {
        type: String,
        value: '',
      },

      /**
       * Aria label to apply to the first input.
       */
      firstInputAriaLabel: {
        type: String,
        value: null,
      },

      firstInputValidity_: {
        type: String,
        value: null,
      },

      confirmInputValidity_: {
        type: String,
        value: null,
      },

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

      isConfirmPasswordVisible_: {
        type: Boolean,
        value: false,
      },
    };
  }

  value: string|null;

  private firstInputValidity_: null|FirstInputValidity;
  private confirmInputValidity_: null|ConfirmInputValidity;
  private isFirstPasswordVisible_: boolean;
  private isConfirmPasswordVisible_: boolean;

  locale: string;

  constructor() {
    super();

    // See comment at the |locale| attribute for why this is here.
    this.classList.add('i18n-dynamic');
  }

  override ready(): void {
    super.ready();

    // Dynamic checks to make sure that our static type declaration about named
    // elements in the shadow DOM are actually true.
    assertInstanceof(this.$.firstInput, CrInputElement);
    assertInstanceof(this.$.confirmInput, CrInputElement);
  }

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

  // See comment at the |locale| attribute for why this is here.
  i18nUpdateLocale(): void {
    this.locale = loadTimeData.getString('app_locale');
  }

  // Computes the value of firstInputValidity_ based on the value of the first
  // input field. Note that, since this function is async, it can happen that a
  // user changes the value of the input field in-between the moment when this
  // method is called and when it returns. In that case, firstInputValidity_
  // will still be null.
  private async validateFirstInput(): Promise<void> {
    if (this.firstInputValidity_ !== null) {
      return;
    }

    const value = this.$.firstInput.value;
    const {complexity} =
        await PasswordFactorEditor.getRemote().checkLocalPasswordComplexity(
            value);

    // Abort validation if the user has changed the input value while we were
    // waiting for the async function call above to return.
    if (value !== this.$.firstInput.value) {
      return;
    }

    switch (complexity) {
      case PasswordComplexity.kOk:
        this.firstInputValidity_ = FirstInputValidity.OK;
        break;
      case PasswordComplexity.kTooShort:
        this.firstInputValidity_ = FirstInputValidity.TOO_SHORT;
        break;
      default:
        assertNotReached();
    }
  }

  private validateConfirmInput(): void {
    // Confirm input validation is only applicable if the first input field is
    // known to be valid.
    if (this.firstInputValidity_ !== FirstInputValidity.OK) {
      this.confirmInputValidity_ = null;
      return;
    }

    if (this.$.firstInput.value !== this.$.confirmInput.value) {
      this.confirmInputValidity_ = ConfirmInputValidity.NO_MATCH;
      return;
    }

    this.confirmInputValidity_ = ConfirmInputValidity.OK;
  }

  async validate(): Promise<void> {
    await this.validateFirstInput();
    if (this.showFirstInputError()) {
      this.$.firstInput.focus();
      return;
    }

    this.validateConfirmInput();
    if (this.showConfirmInputError()) {
      this.$.confirmInput.focus();
      return;
    }
  }

  reset(): void {
    this.$.firstInput.value = '';
    this.$.confirmInput.value = '';
    this.firstInputValidity_ = null;
    this.confirmInputValidity_ = null;
  }

  private computeValue(): string|null {
    if (this.firstInputValidity_ !== FirstInputValidity.OK) {
      return null;
    }
    if (this.confirmInputValidity_ !== ConfirmInputValidity.OK) {
      return null;
    }

    return this.$.firstInput.value;
  }

  private async onInput(ev: Event): Promise<void> {
    if (ev.target === this.$.firstInput) {
      this.firstInputValidity_ = null;
      this.confirmInputValidity_ = null;
      return;
    }

    if (ev.target === this.$.confirmInput) {
      this.confirmInputValidity_ = null;

      // Catch the moment when both passwords are valid, this is
      // to allow us to update the state of the Submit Button for
      // whatever element is hosting.
      await this.validateFirstInput();
      this.validateConfirmInput();

      return;
    }


    assertNotReached();
  }

  private async onKeyup(ev: KeyboardEvent): Promise<void> {
    if (ev.key !== 'Enter') {
      return;
    }

    if (ev.target === this.$.firstInput) {
      ev.stopPropagation();
      await this.validateFirstInput();
      if (!this.showFirstInputError()) {
        this.$.confirmInput.focus();
      }
      return;
    }

    if (ev.target === this.$.confirmInput) {
      ev.stopPropagation();
      await this.validateFirstInput();
      this.validateConfirmInput();
      if (typeof this.value === 'string') {
        this.dispatchEvent(new CustomEvent('submit', {bubbles: true}));
      }
      return;
    }

    assertNotReached();
  }

  private async onBlur(ev: Event): Promise<void> {
    if (ev.target === this.$.firstInput) {
      ev.stopPropagation();
      await this.validateFirstInput();
      return;
    }

    if (ev.target === this.$.confirmInput) {
      ev.stopPropagation();
      await this.validateFirstInput();
      this.validateConfirmInput();
      return;
    }

    assertNotReached();
  }

  private showFirstInputError(): boolean {
    switch (this.firstInputValidity_) {
      case FirstInputValidity.TOO_SHORT:
        return true;
      case null:
      case FirstInputValidity.OK:
        return false;
    }
  }

  private showConfirmInputError(): boolean {
    switch (this.confirmInputValidity_) {
      case ConfirmInputValidity.NO_MATCH:
        return true;
      case null:
      case ConfirmInputValidity.OK:
        return false;
    }
  }

  private getPasswordInputType(isVisible: boolean): string {
    return isVisible ? 'text' : 'password';
  }

  private getShowHideButtonLabel(isVisible: boolean): string {
    return isVisible ? loadTimeData.getString('hidePassword') :
                       loadTimeData.getString('showPassword');
  }

  private getShowHideButtonIcon(isVisible: boolean): string {
    return isVisible ? 'auth-setup:visibility-off' : 'auth-setup:visibility';
  }

  /**
   * Handlers for showing/hiding the passwords. These methods should be
   * attached to on-click event of show/hide password button.
   */
  private onFirstShowHidePasswordButtonClick() {
    this.isFirstPasswordVisible_ = !this.isFirstPasswordVisible_;
  }
  private onConfirmShowHidePasswordButtonClick() {
    this.isConfirmPasswordVisible_ = !this.isConfirmPasswordVisible_;
  }
}

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