chromium/chrome/browser/resources/ash/settings/os_apps_page/app_parental_controls/app_setup_pin_keyboard.ts

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

/**
 * @fileoverview
 * 'app-setup-pin-keyboard' is the keyboard/input field for
 * entering a PIN to access App Parental Controls. Used by the PIN setup
 * dialog.
 */

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

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {PinKeyboardElement} from 'chrome://resources/ash/common/quick_unlock/pin_keyboard.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {AppParentalControlsHandlerInterface, PinValidationResult} from '../../mojom-webui/app_parental_controls_handler.mojom-webui.js';

import {getTemplate} from './app_setup_pin_keyboard.html.js';
import {ParentalControlsPinDialogError, recordPinDialogError} from './metrics_utils.js';
import {getAppParentalControlsProvider} from './mojo_interface_provider.js';

/**
 * Error type mapped to the corresponding i18n string.
 */
enum MessageType {
  WRONG_LENGTH = 'appParentalControlsPinWrongLengthErrorText',
  MISMATCH = 'appParentalControlsPinMismatchErrorText',
  NUMBERS_ONLY = 'appParentalControlsPinNumericErrorText',
}

export const PIN_LENGTH = 6;

const AppSetupPinKeyboardElementBase = (PrefsMixin(I18nMixin(PolymerElement)));

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

export class AppSetupPinKeyboardElement extends AppSetupPinKeyboardElementBase {
  static get is() {
    return 'app-setup-pin-keyboard' as const;
  }

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

  static get properties() {
    return {
      /**
       * Whether clicking the submit button takes you to the next step.
       */
      enableSubmit: {
        notify: true,
        type: Boolean,
        value: false,
      },

      /**
       * Whether the user is at the PIN confirmation step.
       */
      isConfirmStep: {
        notify: true,
        type: Boolean,
        value: false,
      },

      ariaLabel: {
        type: String,
      },

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

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

      /**
       * Whether the PIN is currently being set as a pref.
       * If true, the PIN keyboard input should be disabled.
       */
      isSetPinCallPending_: {
        notify: true,
        type: Boolean,
        value: false,
      },
    };
  }

  enableSubmit: boolean;
  isConfirmStep: boolean;

  private initialPin_: string;
  private isSetPinCallPending_: boolean;
  private mojoInterfaceProvider: AppParentalControlsHandlerInterface;
  private pinKeyboardValue_: string;
  private problemMessage_: string;

  constructor() {
    super();
    this.mojoInterfaceProvider = getAppParentalControlsProvider();
  }

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

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

  resetState(): void {
    this.initialPin_ = '';
    this.isConfirmStep = false;
    this.resetPinKeyboard_();
  }

  private resetPinKeyboard_(): void {
    this.pinKeyboardValue_ = '';
    this.problemMessage_ = '';
    this.enableSubmit = false;
  }

  /**
   * Returns true if the user has re-entered the same PIN at the confirmation
   * step.
   */
  private isPinConfirmed_(): boolean {
    return this.isConfirmStep && (this.initialPin_ === this.pinKeyboardValue_);
  }

  private async canGoToConfirmStep_(newPin: string): Promise<boolean> {
    this.problemMessage_ = '';

    if (newPin.length === 0) {
      return false;
    }

    if (this.isConfirmStep) {
      return true;
    }

    // Check if a valid PIN is entered before going to confirm step.
    const validationResult =
        await this.mojoInterfaceProvider.validatePin(newPin);
    switch (validationResult.result) {
      case (PinValidationResult.kPinValidationSuccess):
        return true;
      case (PinValidationResult.kPinLengthError):
        this.problemMessage_ = this.i18n(MessageType.WRONG_LENGTH);
        return false;
      case (PinValidationResult.kPinNumericError):
        this.problemMessage_ = this.i18n(MessageType.NUMBERS_ONLY);
        return false;
    }
  }

  private async onPinChange_(e: CustomEvent<{pin: string}>): Promise<void> {
    const newPin = e.detail.pin;
    this.enableSubmit = await this.canGoToConfirmStep_(newPin);
  }

  /**
   * Called when the user presses enter/return during PIN entry.
   */
  private onPinSubmit_(): void {
    this.doSubmit();
  }

  /**
   * Intended to be called by the containing dialog when the user attempts
   * to submit the PIN.
   */
  doSubmit(): void {
    if (!this.isConfirmStep) {
      if (!this.enableSubmit) {
        recordPinDialogError(
            ParentalControlsPinDialogError.INVALID_PIN_ON_SETUP);
        return;
      }
      this.initialPin_ = this.pinKeyboardValue_;
      this.isConfirmStep = true;
      this.resetPinKeyboard_();
      this.$.pinKeyboard.focusInput();
      return;
    }

    if (!this.isPinConfirmed_()) {
      // Focus the PIN keyboard and highlight the entire PIN so the user can
      // replace it.
      this.$.pinKeyboard.focusInput(0, this.pinKeyboardValue_.length + 1);
      this.problemMessage_ = this.i18n(MessageType.MISMATCH);
      return;
    }

    this.isSetPinCallPending_ = true;
    this.mojoInterfaceProvider.setUpPin(this.pinKeyboardValue_)
        .then((result) => {
          this.isSetPinCallPending_ = false;
          if (!result.isSuccess) {
            // An error is not expected because the PIN is validated before
            // proceeding to the confirmation step where the PIN is stored.
            console.error('app-controls: Failed to set PIN');
            return;
          }
          this.dispatchEvent(new Event('set-app-pin-done', {composed: true}));
        });
  }

  private hasError_(): boolean {
    return this.problemMessage_ === this.i18n(MessageType.MISMATCH);
  }

  private getErrorClass_(): string {
    return this.hasError_() ? 'error' : '';
  }
}

declare global {
  interface HTMLElementTagNameMap {
    [AppSetupPinKeyboardElement.is]: AppSetupPinKeyboardElement;
  }
}

customElements.define(
    AppSetupPinKeyboardElement.is, AppSetupPinKeyboardElement);