chromium/chrome/browser/resources/password_manager/checkup_section.ts

// Copyright 2022 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/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_link_row/cr_link_row.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/cr_icons.css.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/cr_elements/icons.html.js';
import 'chrome://resources/polymer/v3_0/paper-spinner/paper-spinner-lite.js';
import './shared_style.css.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 {CrIconButtonElement} from 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import type {CrLinkRowElement} from 'chrome://resources/cr_elements/cr_link_row/cr_link_row.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {focusWithoutInk} from 'chrome://resources/js/focus_without_ink.js';
import {PluralStringProxyImpl} from 'chrome://resources/js/plural_string_proxy.js';
import type {PaperSpinnerLiteElement} from 'chrome://resources/polymer/v3_0/paper-spinner/paper-spinner-lite.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './checkup_section.html.js';
import type {FocusConfig} from './focus_config.js';
import type {CredentialsChangedListener, PasswordCheckStatusChangedListener} from './password_manager_proxy.js';
import {PasswordCheckInteraction, PasswordManagerImpl} from './password_manager_proxy.js';
import type {Route} from './router.js';
import {CheckupSubpage, Page, RouteObserverMixin, Router, UrlParam} from './router.js';

const CheckState = chrome.passwordsPrivate.PasswordCheckState;

export interface CheckupSectionElement {
  $: {
    checkupResult: HTMLElement,
    checkupStatusLabel: HTMLElement,
    checkupStatusSubLabel: HTMLElement,
    refreshButton: CrIconButtonElement,
    retryButton: CrButtonElement,
    spinner: PaperSpinnerLiteElement,
    compromisedRow: CrLinkRowElement,
    reusedRow: CrLinkRowElement,
    weakRow: CrLinkRowElement,
  };
}

const CheckupSectionElementBase = RouteObserverMixin(I18nMixin(PolymerElement));

export class CheckupSectionElement extends CheckupSectionElementBase {
  static get is() {
    return 'checkup-section';
  }

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

  static get properties() {
    return {
      focusConfig: {
        type: Object,
        observer: 'focusConfigChanged_',
      },

      /**
       * The number of checked passwords as a formatted string.
       */
      checkedPasswordsText_: String,

      /**
       * The number of compromised passwords as a formatted string.
       */
      compromisedPasswordsText_: String,

      /**
       * The number of weak passwords as a formatted string.
       */
      reusedPasswordsText_: String,

      /**
       * The number of weak passwords as a formatted string.
       */
      weakPasswordsText_: String,

      /**
       * Suggested action to take upon compromised passwords discovery.
       */
      compromisedPasswordsSuggestion_: String,

      /**
       * The status indicates progress and affects banner, title and icon.
       */
      status_: {
        type: Object,
        observer: 'onStatusChanged_',
      },

      compromisedPasswords_: {
        type: Array,
        observer: 'onCompromisedPasswordsChanged_',
      },

      reusedPasswords_: {
        type: Array,
        observer: 'onReusedPasswordsChanged_',
      },

      weakPasswords_: {
        type: Array,
        observer: 'onWeakPasswordsChanged_',
      },

      isCheckRunning_: {
        type: Boolean,
        computed: 'computeIsCheckRunning_(status_)',
      },

      isCheckSuccessful_: {
        type: Boolean,
        computed: 'computeIsCheckSuccessful_(status_)',
      },

      bannerImage_: {
        type: Array,
        value: 'checkup_result_banner_error',
        computed: 'computeBannerImage_(status_, compromisedPasswords_, ' +
            'reusedPasswords_, weakPasswords_)',
      },

      groupCount_: {
        type: Number,
        value: 0,
        observer: 'updateCheckedPasswordsText_',
      },
    };
  }

  focusConfig: FocusConfig;
  private checkedPasswordsText_: string;
  private compromisedPasswordsText_: string;
  private reusedPasswordsText_: string;
  private weakPasswordsText_: string;
  private compromisedPasswordsSuggestion_: string;
  private status_: chrome.passwordsPrivate.PasswordCheckStatus;
  private compromisedPasswords_: chrome.passwordsPrivate.PasswordUiEntry[];
  private weakPasswords_: chrome.passwordsPrivate.PasswordUiEntry[];
  private reusedPasswords_: chrome.passwordsPrivate.PasswordUiEntry[];
  private didCheckAutomatically_: boolean = false;
  private groupCount_: number;

  private statusChangedListener_: PasswordCheckStatusChangedListener|null =
      null;
  private insecureCredentialsChangedListener_: CredentialsChangedListener|null =
      null;
  private setSavedPasswordsListener_: CredentialsChangedListener|null = null;

  override connectedCallback() {
    super.connectedCallback();

    this.statusChangedListener_ = status => {
      this.status_ = status;
    };

    this.insecureCredentialsChangedListener_ = insecureCredentials => {
      this.compromisedPasswords_ = insecureCredentials.filter(cred => {
        return !cred.compromisedInfo!.isMuted &&
            cred.compromisedInfo!.compromiseTypes.some(type => {
              return (
                  type === chrome.passwordsPrivate.CompromiseType.LEAKED ||
                  type === chrome.passwordsPrivate.CompromiseType.PHISHED);
            });
      });

      this.reusedPasswords_ = insecureCredentials.filter(cred => {
        return cred.compromisedInfo!.compromiseTypes.some(type => {
          return type === chrome.passwordsPrivate.CompromiseType.REUSED;
        });
      });

      this.weakPasswords_ = insecureCredentials.filter(cred => {
        return cred.compromisedInfo!.compromiseTypes.some(type => {
          return type === chrome.passwordsPrivate.CompromiseType.WEAK;
        });
      });
    };

    this.setSavedPasswordsListener_ = _passwordList => {
      PasswordManagerImpl.getInstance().getCredentialGroups().then(
          groups => this.groupCount_ = groups.length);
    };

    PasswordManagerImpl.getInstance().getPasswordCheckStatus().then(
        this.statusChangedListener_);
    PasswordManagerImpl.getInstance().addPasswordCheckStatusListener(
        this.statusChangedListener_);

    PasswordManagerImpl.getInstance().getInsecureCredentials().then(
        this.insecureCredentialsChangedListener_);
    PasswordManagerImpl.getInstance().addInsecureCredentialsListener(
        this.insecureCredentialsChangedListener_);

    PasswordManagerImpl.getInstance().getCredentialGroups().then(
        groups => this.groupCount_ = groups.length);
    PasswordManagerImpl.getInstance().addSavedPasswordListChangedListener(
        this.setSavedPasswordsListener_);
  }

  override disconnectedCallback() {
    super.disconnectedCallback();

    assert(this.statusChangedListener_);
    PasswordManagerImpl.getInstance().removePasswordCheckStatusListener(
        this.statusChangedListener_);
    this.statusChangedListener_ = null;

    assert(this.insecureCredentialsChangedListener_);
    PasswordManagerImpl.getInstance().removeInsecureCredentialsListener(
        this.insecureCredentialsChangedListener_);
    this.insecureCredentialsChangedListener_ = null;

    assert(this.setSavedPasswordsListener_);
    PasswordManagerImpl.getInstance().removeSavedPasswordListChangedListener(
        this.setSavedPasswordsListener_);
    this.setSavedPasswordsListener_ = null;
  }

  override currentRouteChanged(route: Route): void {
    const param = route.queryParameters.get(UrlParam.START_CHECK) || '';
    if (param === 'true' && !this.didCheckAutomatically_) {
      this.didCheckAutomatically_ = true;
      PasswordManagerImpl.getInstance().startBulkPasswordCheck().catch(
          () => {});
      PasswordManagerImpl.getInstance().recordPasswordCheckInteraction(
          PasswordCheckInteraction.START_CHECK_AUTOMATICALLY);
    }
    if (route.page === Page.CHECKUP) {
      PasswordManagerImpl.getInstance()
          .dismissSafetyHubPasswordMenuNotification();
    }
  }

  private async onStatusChanged_(
      newStatus: chrome.passwordsPrivate.PasswordCheckStatus,
      oldStatus: chrome.passwordsPrivate.PasswordCheckStatus) {
    // if state is unchanged - nothing to do.
    if (oldStatus !== undefined && oldStatus.state === newStatus.state) {
      return;
    }

    await this.updateCheckedPasswordsText_();

    if (newStatus.state === CheckState.NO_PASSWORDS) {
      return;
    }

    // Announce password check result and focus retry/refresh button when
    // password check is finished.
    if (!!oldStatus && oldStatus.state === CheckState.RUNNING &&
        newStatus.state !== CheckState.RUNNING) {
      let stateText: string;
      if (this.compromisedPasswords_.length > 0) {
        stateText = this.i18n('checkupResultRed');
      } else if (this.hasAnyIssues_()) {
        stateText = this.i18n('checkupResultYellow');
      } else {
        stateText = this.i18n('checkupResultGreen');
      }
      getAnnouncerInstance().announce(
          [this.checkedPasswordsText_, stateText].join('. '));
      focusWithoutInk(
          this.showRetryButton_() ? this.$.retryButton : this.$.refreshButton);
    } else if (
        !!oldStatus && oldStatus.state !== CheckState.RUNNING &&
        newStatus.state === CheckState.RUNNING) {
      // Announce password checkup has started.
      getAnnouncerInstance().announce('Password check started');
    }
  }

  private async updateCheckedPasswordsText_() {
    if (!this.status_) {
      return;
    }

    switch (this.status_.state) {
      case CheckState.IDLE:
      case CheckState.OFFLINE:
      case CheckState.SIGNED_OUT:
      case CheckState.QUOTA_LIMIT:
      case CheckState.OTHER_ERROR:
      case CheckState.NO_PASSWORDS:
        this.checkedPasswordsText_ =
            await PluralStringProxyImpl.getInstance().getPluralString(
                'checkedPasswords', this.groupCount_);
        return;
      case CheckState.CANCELED:
        this.checkedPasswordsText_ = this.i18n('checkupCanceled');
        return;
      case CheckState.RUNNING:
        this.checkedPasswordsText_ =
            await PluralStringProxyImpl.getInstance().getPluralString(
                'checkingPasswords', this.status_.totalNumberOfPasswords || 0);
        return;
      default:
        assertNotReached(
            'Can\'t find a title for state: ' + this.status_.state);
    }
  }

  private async onCompromisedPasswordsChanged_() {
    this.compromisedPasswordsText_ =
        await PluralStringProxyImpl.getInstance().getPluralString(
            'compromisedPasswords', this.compromisedPasswords_.length);

    this.compromisedPasswordsSuggestion_ =
        await PluralStringProxyImpl.getInstance().getPluralString(
            'compromisedPasswordsTitle', this.compromisedPasswords_.length);
  }

  private async onReusedPasswordsChanged_() {
    this.reusedPasswordsText_ =
        await PluralStringProxyImpl.getInstance().getPluralString(
            'reusedPasswords', this.reusedPasswords_.length);
  }

  private async onWeakPasswordsChanged_() {
    this.weakPasswordsText_ =
        await PluralStringProxyImpl.getInstance().getPluralString(
            'weakPasswords', this.weakPasswords_.length);
  }

  /**
   * @return true iff a check is running right according to the given |status|.
   */
  private computeIsCheckRunning_(): boolean {
    return this.status_.state === CheckState.RUNNING;
  }

  private computeIsCheckSuccessful_(): boolean {
    return this.status_.state === CheckState.IDLE;
  }

  private didCompromiseCheckFail_(): boolean {
    return [
      CheckState.OFFLINE,
      CheckState.SIGNED_OUT,
      CheckState.QUOTA_LIMIT,
      CheckState.OTHER_ERROR,
    ].includes(this.status_.state);
  }

  private showRetryButton_(): boolean {
    return !this.computeIsCheckRunning_() && !this.computeIsCheckSuccessful_();
  }

  private showCheckButton_(): boolean {
    return this.status_.state !== CheckState.NO_PASSWORDS;
  }

  /**
   * Starts/Restarts bulk password check.
   */
  private onPasswordCheckButtonClick_() {
    PasswordManagerImpl.getInstance().startBulkPasswordCheck().catch(() => {});
    PasswordManagerImpl.getInstance().recordPasswordCheckInteraction(
        PasswordCheckInteraction.START_CHECK_MANUALLY);
  }

  private computeBannerImage_(): string {
    if (!this.status_) {
      return 'checkup_result_banner_error';
    }

    if (this.computeIsCheckRunning_() ||
        this.status_.state === CheckState.NO_PASSWORDS) {
      return 'checkup_result_banner_running';
    }
    if (this.computeIsCheckSuccessful_()) {
      return this.hasAnyIssues_() ? 'checkup_result_banner_compromised' :
                                    'checkup_result_banner_ok';
    }
    return 'checkup_result_banner_error';
  }

  private getIcon_(
      issues: chrome.passwordsPrivate.PasswordUiEntry[],
      checkForError: boolean): string {
    if (checkForError && this.status_ && this.didCompromiseCheckFail_()) {
      return 'cr:error';
    }
    return !!issues && issues.length ? 'cr:error' : 'cr:check-circle';
  }

  private hasAnyIssues_(): boolean {
    if (!this.compromisedPasswords_ || !this.reusedPasswords_ ||
        !this.weakPasswords_) {
      return false;
    }
    return !!this.compromisedPasswords_.length ||
        !!this.reusedPasswords_.length || !!this.weakPasswords_.length;
  }

  private hasIssues_(issues: chrome.passwordsPrivate.PasswordUiEntry[]):
      boolean {
    return !!issues.length;
  }

  private getCompromisedSectionLabel_(): string {
    if (this.status_ && this.didCompromiseCheckFail_()) {
      // In case of an error, don't show "No compromised passwords" title since
      // this might be a lie.
      return !this.compromisedPasswords_ || !this.compromisedPasswords_.length ?
          this.i18n('compromisedRowWithError') :
          this.compromisedPasswordsText_;
    }
    return this.compromisedPasswordsText_;
  }

  private getCompromisedSectionSublabel_(): string {
    if (!this.status_ || !this.compromisedPasswords_) {
      return '';
    }
    const brandingName = this.i18n('localPasswordManager');
    switch (this.status_.state) {
      case CheckState.IDLE:
      case CheckState.NO_PASSWORDS:
      case CheckState.RUNNING:
      case CheckState.CANCELED:
        return this.compromisedPasswords_.length ?
            this.compromisedPasswordsSuggestion_ :
            this.i18n('compromisedPasswordsEmpty');
      case CheckState.OFFLINE:
        return this.i18n('checkupErrorOffline', brandingName);
      case CheckState.SIGNED_OUT:
        return this.i18n('checkupErrorSignedOut', brandingName);
      case CheckState.QUOTA_LIMIT:
        return this.i18n('checkupErrorQuota', brandingName);
      case CheckState.OTHER_ERROR:
        return this.i18n('checkupErrorGeneric', brandingName);
      default:
        assertNotReached(
            'Can\'t find a title for state: ' + this.status_.state);
    }
  }

  private getReusedSectionSublabel_(): string {
    return this.reusedPasswords_.length ? this.i18n('reusedPasswordsTitle') :
                                          this.i18n('reusedPasswordsEmpty');
  }

  private getWeakSectionSublabel_(): string {
    return this.weakPasswords_.length ? this.i18n('weakPasswordsTitle') :
                                        this.i18n('weakPasswordsEmpty');
  }

  private onCompromisedClick_() {
    if (!this.compromisedPasswords_.length) {
      return;
    }

    Router.getInstance().navigateTo(
        Page.CHECKUP_DETAILS, CheckupSubpage.COMPROMISED);
  }

  private onReusedClick_() {
    if (!this.reusedPasswords_.length) {
      return;
    }

    Router.getInstance().navigateTo(
        Page.CHECKUP_DETAILS, CheckupSubpage.REUSED);
  }

  private onWeakClick_() {
    if (!this.weakPasswords_.length) {
      return;
    }

    Router.getInstance().navigateTo(Page.CHECKUP_DETAILS, CheckupSubpage.WEAK);
  }

  private showCheckupSublabel_(): boolean {
    return this.computeIsCheckRunning_();
  }

  private getCheckupSublabelValue_(): string {
    assert(this.status_);
    if (!this.computeIsCheckRunning_()) {
      return this.status_.state === CheckState.NO_PASSWORDS ?
          this.i18n(
              'checkupErrorNoPasswords', this.i18n('localPasswordManager')) :
          this.status_.elapsedTimeSinceLastCheck || '';
    }
    return this.i18n(
        'checkupProgress', this.status_.alreadyProcessed || 0,
        this.status_.totalNumberOfPasswords || 0);
  }

  private showCheckupResult_(): boolean {
    assert(this.status_);
    if (this.computeIsCheckRunning_()) {
      return false;
    }
    return this.status_.state !== CheckState.NO_PASSWORDS;
  }

  private focusConfigChanged_(_newConfig: FocusConfig, oldConfig: FocusConfig) {
    // focusConfig is set only once on the parent, so this observer should
    // only fire once.
    assert(!oldConfig);

    this.focusConfig.set(Page.CHECKUP_DETAILS, () => {
      const previousRoute = Router.getInstance().previousRoute;

      switch (previousRoute?.details as unknown as CheckupSubpage) {
        case CheckupSubpage.COMPROMISED:
          focusWithoutInk(this.$.compromisedRow);
          break;
        case CheckupSubpage.REUSED:
          focusWithoutInk(this.$.reusedRow);
          break;
        case CheckupSubpage.WEAK:
          focusWithoutInk(this.$.weakRow);
          break;
      }
    });
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'checkup-section': CheckupSectionElement;
  }
}

customElements.define(CheckupSectionElement.is, CheckupSectionElement);