chromium/chrome/browser/resources/password_manager/password_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_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 './shared_style.css.js';
import './site_favicon.js';
import './credential_details/password_details_card.js';
import './credential_details/passkey_details_card.js';
import './user_utils_mixin.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import type {CrIconButtonElement} from 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import {assert} from 'chrome://resources/js/assert.js';
import {afterNextRender, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './password_details_section.html.js';
import {PasswordManagerImpl, PasswordViewPageInteractions} from './password_manager_proxy.js';
import type {Route} from './router.js';
import {Page, RouteObserverMixin, Router} from './router.js';
import {UserUtilMixin} from './user_utils_mixin.js';

export interface PasswordDetailsSectionElement {
  $: {
    backButton: CrIconButtonElement,
    title: HTMLElement,
  };
}

const PasswordDetailsSectionElementBase =
    PrefsMixin(UserUtilMixin(RouteObserverMixin(PolymerElement)));

export class PasswordDetailsSectionElement extends
    PasswordDetailsSectionElementBase {
  static get is() {
    return 'password-details-section';
  }

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

  static get properties() {
    return {
      selectedGroup_: {
        type: Object,
        observer: 'maybeRegisterPasswordSharingHelpBubble_',
      },
    };
  }

  private selectedGroup_: chrome.passwordsPrivate.CredentialGroup|undefined;
  private savedPasswordsListener_: (
      (entries: chrome.passwordsPrivate.PasswordUiEntry[]) => void)|null = null;
  private passwordManagerAuthTimeoutListener_: () => void;
  private visibilityChangedListener_: () => void;

  override connectedCallback() {
    super.connectedCallback();

    this.passwordManagerAuthTimeoutListener_ = () => {
      if (Router.getInstance().currentRoute.page !== Page.PASSWORD_DETAILS) {
        return;
      }

      this.dispatchEvent(new CustomEvent('auth-timed-out', {
        bubbles: true,
        composed: true,
      }));
      this.navigateBack_();
      PasswordManagerImpl.getInstance().recordPasswordViewInteraction(
          PasswordViewPageInteractions.TIMED_OUT_IN_VIEW_PAGE);
    };
    PasswordManagerImpl.getInstance().addPasswordManagerAuthTimeoutListener(
        this.passwordManagerAuthTimeoutListener_);
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    if (this.savedPasswordsListener_) {
      PasswordManagerImpl.getInstance().removeSavedPasswordListChangedListener(
          this.savedPasswordsListener_);
      this.savedPasswordsListener_ = null;
    }
    PasswordManagerImpl.getInstance().removePasswordManagerAuthTimeoutListener(
        this.passwordManagerAuthTimeoutListener_);
  }

  override currentRouteChanged(route: Route, _: Route): void {
    if (route.page !== Page.PASSWORD_DETAILS) {
      this.selectedGroup_ = undefined;
      return;
    }

    const group = route.details as chrome.passwordsPrivate.CredentialGroup;
    if (group && group.name) {
      this.selectedGroup_ = group;
      this.startListeningForUpdates_();
      setTimeout(() => {  // Async to allow page to load.
        this.$.backButton.focus();
      });
    } else {
      // Navigation happened directly. Find group with matching name.
      PasswordManagerImpl.getInstance().recordPasswordViewInteraction(
          PasswordViewPageInteractions.CREDENTIAL_REQUESTED_BY_URL);
      this.assignMatchingGroup(route.details as string);
    }
  }

  private navigateBack_() {
    // Keep search query when navigating back.
    Router.getInstance().navigateTo(
        Page.PASSWORDS, null,
        Router.getInstance().currentRoute.queryParameters);
  }

  private async assignMatchingGroup(groupName: string) {
    const groups =
        await PasswordManagerImpl.getInstance().getCredentialGroups();
    let selectedGroup = groups.find(group => group.name === groupName);
    if (!selectedGroup) {
      // Check if any password in a group has matching domain.
      selectedGroup = groups.find(
          group => group.entries.some(
              entry => entry.affiliatedDomains?.some(
                  domain => domain.name === groupName)));
    }
    if (!selectedGroup) {
      this.navigateBack_();
      PasswordManagerImpl.getInstance().recordPasswordViewInteraction(
          PasswordViewPageInteractions.CREDENTIAL_NOT_FOUND);
      return;
    }
    assert(selectedGroup);
    this.updateShownCredentials(selectedGroup)
        .then(this.startListeningForUpdates_.bind(this))
        .catch(this.navigateBack_);
    PasswordManagerImpl.getInstance().recordPasswordViewInteraction(
        PasswordViewPageInteractions.CREDENTIAL_FOUND);
  }

  private startListeningForUpdates_() {
    if (this.savedPasswordsListener_) {
      return;
    }
    this.savedPasswordsListener_ = _passwordList => {
      PasswordManagerImpl.getInstance().getCredentialGroups().then(
          this.refreshGroupInfo_.bind(this));
    };
    PasswordManagerImpl.getInstance().addSavedPasswordListChangedListener(
        this.savedPasswordsListener_);
  }

  /*
   * Requests passwords and notes for all credentials from a group. If page
   * isn't visible the request will be postponed until tab becomes focused
   * again. This is done to prevent unnecessary authentication prompts.
   */
  private updateShownCredentials(
      group: chrome.passwordsPrivate.CredentialGroup): Promise<void> {
    if (document.visibilityState === 'visible') {
      return this.requestShownCredentials_(group);
    }
    return new Promise((resolve, reject) => {
      this.visibilityChangedListener_ = () => {
        if (document.visibilityState === 'visible') {
          document.removeEventListener(
              'visibilitychange', this.visibilityChangedListener_);
          this.requestShownCredentials_(group).then(resolve).catch(reject);
        }
      };
      document.addEventListener(
          'visibilitychange', this.visibilityChangedListener_);
    });
  }

  private requestShownCredentials_(
      group: chrome.passwordsPrivate.CredentialGroup): Promise<void> {
    const ids = group.entries.map(entry => entry.id);
    return PasswordManagerImpl.getInstance()
        .requestCredentialsDetails(ids)
        .then(entries => {
          group.entries = entries;
          this.selectedGroup_ = group;
        });
  }

  /*
   * Credentials have changed, check if shown credentials still exist:
   * if yes, navigates to its group page
   * if no, navigate back to Passwords page.
   */
  private refreshGroupInfo_(groups: chrome.passwordsPrivate.CredentialGroup[]) {
    assert(this.selectedGroup_);
    const currentIds = this.selectedGroup_.entries.map(entry => entry.id);
    let matchingGroup = groups.filter(
        group => group.entries.some(entry => currentIds.includes(entry.id)))[0];
    // If there is no group with matching id for PasswordUIEntry it means that
    // either PasswordUIEntry is deleted or updated. During update, site value
    // can't change so there should be a group with the same name.
    if (!matchingGroup) {
      matchingGroup =
          groups.filter(group => group.name === this.selectedGroup_!.name)[0];
      // If no group with matching name can be found it means that
      // PasswordUIEntry was deleted and group no longer exists.
      if (!matchingGroup) {
        this.navigateBack_();
        return;
      }
    }
    assert(matchingGroup);
    const newIds = matchingGroup.entries.map(entry => entry.id);
    const currentStores =
        this.selectedGroup_.entries.map(entry => entry.storedIn);
    const newStores = matchingGroup.entries.map(entry => entry.storedIn);
    // If ids match and stores used for entries haven't changed, don't do
    // anything.
    if (currentIds.sort().toString() === newIds.sort().toString() &&
        currentStores.sort().toString() === newStores.sort().toString()) {
      return;
    }
    this.updateShownCredentials(matchingGroup)
        .then(() => {
          // Use navigation to update page title if needed.
          Router.getInstance().navigateTo(
              Page.PASSWORD_DETAILS, this.selectedGroup_,
              Router.getInstance().currentRoute.queryParameters);
        })
        .catch(this.navigateBack_);
  }

  private maybeRegisterPasswordSharingHelpBubble_() {
    afterNextRender(this, () => {
      if (this.selectedGroup_?.entries[0]?.isPasskey) {
        return;
      }

      this.shadowRoot!.querySelector('password-details-card')
          ?.maybeRegisterSharingHelpBubble();
    });
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'password-details-section': PasswordDetailsSectionElement;
  }
}

customElements.define(
    PasswordDetailsSectionElement.is, PasswordDetailsSectionElement);