chromium/chrome/browser/resources/password_manager/checkup_details_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_action_menu/cr_action_menu.js';
import 'chrome://resources/cr_elements/cr_collapse/cr_collapse.js';
import 'chrome://resources/cr_elements/cr_expand_button/cr_expand_button.js';
import 'chrome://resources/cr_elements/cr_icon/cr_icon.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/icons_lit.html.js';
import './shared_style.css.js';
import './checkup_list_item.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import type {CrActionMenuElement} from 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {PluralStringProxyImpl} from 'chrome://resources/js/plural_string_proxy.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './checkup_details_section.html.js';
import type {CheckupListItemElement} from './checkup_list_item.js';
import type {CredentialsChangedListener} from './password_manager_proxy.js';
import {PasswordCheckInteraction, PasswordManagerImpl} from './password_manager_proxy.js';
import type {Route} from './router.js';
import {CheckupSubpage, Page, RouteObserverMixin, Router} from './router.js';

export class ReusedPasswordInfo {
  constructor(credentials: chrome.passwordsPrivate.PasswordUiEntry[]) {
    this.credentials = credentials;
  }

  async init() {
    this.title = await PluralStringProxyImpl.getInstance().getPluralString(
        'numberOfPasswordReuse', this.credentials.length);
  }

  credentials: chrome.passwordsPrivate.PasswordUiEntry[];
  title: string;
}

export interface CheckupDetailsSectionElement {
  $: {
    backButton: HTMLElement,
    description: HTMLElement,
    moreActionsMenu: CrActionMenuElement,
    menuShowPassword: HTMLButtonElement,
    menuEditPassword: HTMLButtonElement,
    menuDeletePassword: HTMLButtonElement,
    subtitle: HTMLElement,
  };
}

const CheckupDetailsSectionElementBase =
    PrefsMixin(I18nMixin(RouteObserverMixin(PolymerElement)));

export class CheckupDetailsSectionElement extends
    CheckupDetailsSectionElementBase {
  static get is() {
    return 'checkup-details-section';
  }

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

  static get properties() {
    return {
      pageTitle_: String,

      pageSubtitle_: String,

      insecurityType_: {
        type: String,
        observer: 'updateShownCredentials_',
      },

      allInsecureCredentials_: {
        type: Array,
        observer: 'updateShownCredentials_',
      },

      shownInsecureCredentials_: {
        type: Array,
        observer: 'onCredentialsChanged_',
      },

      credentialsWithReusedPassword_: {
        type: Array,
      },

      /**
       * The ids of insecure credentials for which user clicked "Change
       * Password" button
       */
      clickedChangePasswordIds_: {
        type: Object,
        value: new Set(),
      },

      isMutingDisabled_: {
        type: Boolean,
        computed: 'computeIsMutingDisabled_(' +
            'prefs.profile.password_dismiss_compromised_alert.value)',
      },
    };
  }

  private pageTitle_: string;
  private pageSubtitle_: string;
  private insecurityType_: CheckupSubpage|undefined;
  private groups_: chrome.passwordsPrivate.CredentialGroup[] = [];
  private allInsecureCredentials_: chrome.passwordsPrivate.PasswordUiEntry[];
  private shownInsecureCredentials_: chrome.passwordsPrivate.PasswordUiEntry[];
  private credentialsWithReusedPassword_: ReusedPasswordInfo[];
  private mutedCompromisedCredentials_:
      chrome.passwordsPrivate.PasswordUiEntry[];
  private activeListItem_: CheckupListItemElement|null;
  private clickedChangePasswordIds_: Set<number>;
  private isMutingDisabled_: boolean;
  private activeCredential_: chrome.passwordsPrivate.PasswordUiEntry|undefined;
  private insecureCredentialsChangedListener_: CredentialsChangedListener|null =
      null;

  override connectedCallback() {
    super.connectedCallback();

    const updateGroups = () => {
      PasswordManagerImpl.getInstance().getCredentialGroups().then(
          groups => this.groups_ = groups);
    };

    this.insecureCredentialsChangedListener_ = insecureCredentials => {
      this.allInsecureCredentials_ = insecureCredentials;
      updateGroups();
    };

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

  override currentRouteChanged(route: Route, oldRoute: Route): void {
    if (route.page !== Page.CHECKUP_DETAILS) {
      return;
    }
    this.insecurityType_ = route.details as unknown as CheckupSubpage;
    // Focus back button when it's not direct navigation.
    if (oldRoute !== undefined) {
      setTimeout(() => {  // Async to allow page to load.
        this.$.backButton.focus();
      });
    }
  }

  private navigateBack_() {
    Router.getInstance().navigateTo(Page.CHECKUP);
  }

  private async updateShownCredentials_() {
    if (!this.insecurityType_ || !this.allInsecureCredentials_) {
      return;
    }
    const insecureCredentialsForThisType = this.allInsecureCredentials_.filter(
        cred => cred.compromisedInfo!.compromiseTypes.some(type => {
          return this.getInsecurityType_().includes(type);
        }));
    const insuecureCredentialsSorter =
        (lhs: chrome.passwordsPrivate.PasswordUiEntry,
         rhs: chrome.passwordsPrivate.PasswordUiEntry) => {
          if ((this.getCurrentGroup_(lhs.id)?.name || '') >
              (this.getCurrentGroup_(rhs.id)?.name || '')) {
            return 1;
          }
          return -1;
        };

    if (this.isCompromisedType()) {
      // Compromised credentials can be muted. Show muted credentials
      // separately.
      this.mutedCompromisedCredentials_ = insecureCredentialsForThisType.filter(
          cred => cred.compromisedInfo!.isMuted);
      this.shownInsecureCredentials_ = insecureCredentialsForThisType.filter(
          cred => !cred.compromisedInfo!.isMuted);
    } else {
      insecureCredentialsForThisType.sort(insuecureCredentialsSorter);
      this.shownInsecureCredentials_ = insecureCredentialsForThisType;
    }

    if (this.isReusedType()) {
      const allReusedCredentials = await PasswordManagerImpl.getInstance()
                                       .getCredentialsWithReusedPassword();
      this.credentialsWithReusedPassword_ =
          await Promise.all(allReusedCredentials.map(
              async(credentials): Promise<ReusedPasswordInfo> => {
                const reuseInfo = new ReusedPasswordInfo(
                    credentials.entries.sort(insuecureCredentialsSorter));
                await reuseInfo.init();
                return reuseInfo;
              }));
      this.credentialsWithReusedPassword_.sort(
          (lhs, rhs) =>
              (lhs.credentials.length > rhs.credentials.length ? -1 : 1));
    }
  }

  private async onCredentialsChanged_() {
    assert(this.insecurityType_);
    this.pageTitle_ = await PluralStringProxyImpl.getInstance().getPluralString(
        this.insecurityType_.concat('Passwords'),
        this.shownInsecureCredentials_.length);
    if (this.insecurityType_ === CheckupSubpage.COMPROMISED) {
      this.pageSubtitle_ =
          await PluralStringProxyImpl.getInstance().getPluralString(
              `${this.insecurityType_}PasswordsTitle`,
              this.shownInsecureCredentials_.length);
    } else {
      this.pageSubtitle_ = this.i18n(`${this.insecurityType_}PasswordsTitle`);
    }
  }

  private getInsecurityType_(): chrome.passwordsPrivate.CompromiseType[] {
    assert(this.insecurityType_);
    switch (this.insecurityType_) {
      case CheckupSubpage.COMPROMISED:
        return [
          chrome.passwordsPrivate.CompromiseType.LEAKED,
          chrome.passwordsPrivate.CompromiseType.PHISHED,
        ];
      case CheckupSubpage.REUSED:
        return [chrome.passwordsPrivate.CompromiseType.REUSED];
      case CheckupSubpage.WEAK:
        return [chrome.passwordsPrivate.CompromiseType.WEAK];
    }
  }

  private getDescription_() {
    assert(this.insecurityType_);
    return this.i18n(`${this.insecurityType_}PasswordsDescription`);
  }

  private isCompromisedType(): boolean {
    return this.insecurityType_ === CheckupSubpage.COMPROMISED;
  }

  private isReusedType(): boolean {
    return this.insecurityType_ === CheckupSubpage.REUSED;
  }

  private onMoreActionsClick_(event: CustomEvent) {
    const target = event.detail.target;
    this.$.moreActionsMenu.showAt(target);
    this.activeListItem_ =
        event.detail.listItem as unknown as CheckupListItemElement;
  }

  private onMenuShowPasswordClick_() {
    this.activeListItem_?.showHidePassword();
    this.$.moreActionsMenu.close();
    this.activeListItem_ = null;
    PasswordManagerImpl.getInstance().recordPasswordCheckInteraction(
        PasswordCheckInteraction.SHOW_PASSWORD);
  }

  private async onMenuEditPasswordClick_() {
    this.activeListItem_?.showEditDialog();
    this.$.moreActionsMenu.close();
    this.activeListItem_ = null;
    PasswordManagerImpl.getInstance().recordPasswordCheckInteraction(
        PasswordCheckInteraction.EDIT_PASSWORD);
  }

  private async onMenuDeletePasswordClick_() {
    this.activeListItem_?.showDeleteDialog();
    this.$.moreActionsMenu.close();
    this.activeListItem_ = null;
  }

  private getShowHideTitle_(): string {
    return this.activeListItem_?.getShowHideButtonLabel() || '';
  }

  private computeIsMutingDisabled_(): boolean {
    return !this.getPref('profile.password_dismiss_compromised_alert').value;
  }

  private getMuteUnmuteLabel_(): string {
    return this.activeListItem_?.item.compromisedInfo?.isMuted === true ?
        this.i18n('unmuteCompromisedPassword') :
        this.i18n('muteCompromisedPassword');
  }

  private onMenuMuteUnmuteClick_() {
    assert(this.activeListItem_);
    if (this.activeListItem_.item.compromisedInfo?.isMuted === true) {
      PasswordManagerImpl.getInstance().recordPasswordCheckInteraction(
          PasswordCheckInteraction.UNMUTE_PASSWORD);
      PasswordManagerImpl.getInstance().unmuteInsecureCredential(
          this.activeListItem_.item);
    } else {
      PasswordManagerImpl.getInstance().recordPasswordCheckInteraction(
          PasswordCheckInteraction.MUTE_PASSWORD);
      PasswordManagerImpl.getInstance().muteInsecureCredential(
          this.activeListItem_.item);
    }
    this.$.moreActionsMenu.close();
  }

  private getCurrentGroup_(id: number): chrome.passwordsPrivate.CredentialGroup
      |undefined {
    return this.groups_.find(
        group => group.entries.some(entry => entry.id === id));
  }

  private onChangePasswordClick_(event: CustomEvent<number>) {
    this.clickedChangePasswordIds_.add(event.detail);
    this.notifyPath('clickedChangePasswordIds_.size');
    PasswordManagerImpl.getInstance().recordPasswordCheckInteraction(
        PasswordCheckInteraction.CHANGE_PASSWORD);
  }

  private clickedChangePassword_(item: chrome.passwordsPrivate.PasswordUiEntry):
      boolean {
    return this.clickedChangePasswordIds_.has(item.id);
  }
}

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

customElements.define(
    CheckupDetailsSectionElement.is, CheckupDetailsSectionElement);