chromium/chrome/browser/resources/password_manager/password_manager_app.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_page_host_style.css.js';
import 'chrome://resources/cr_elements/cr_page_selector/cr_page_selector.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import 'chrome://resources/polymer/v3_0/iron-media-query/iron-media-query.js';
import '/shared/settings/prefs/prefs.js';
import './checkup_section.js';
import './checkup_details_section.js';
import './password_details_section.js';
import './passwords_exporter.js';
import './passwords_section.js';
import './settings_section.js';
import './shared_style.css.js';
import './side_bar.js';
import './toolbar.js';

import type {CrToastElement} from '//resources/cr_elements/cr_toast/cr_toast.js';
import {focusWithoutInk} from '//resources/js/focus_without_ink.js';
import {loadTimeData} from '//resources/js/load_time_data.js';
import type {SettingsPrefsElement} from '/shared/settings/prefs/prefs.js';
import {CrContainerShadowMixin} from 'chrome://resources/cr_elements/cr_container_shadow_mixin.js';
import type {CrDrawerElement} from 'chrome://resources/cr_elements/cr_drawer/cr_drawer.js';
import type {CrPageSelectorElement} from 'chrome://resources/cr_elements/cr_page_selector/cr_page_selector.js';
import {FindShortcutMixin} from 'chrome://resources/cr_elements/find_shortcut_mixin.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {PluralStringProxyImpl} from 'chrome://resources/js/plural_string_proxy.js';
import {getDeepActiveElement, listenOnce} from 'chrome://resources/js/util.js';
import type {DomIf} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import type {CheckupSectionElement} from './checkup_section.js';
import type {PasswordRemovedEvent} from './credential_details/password_details_card.js';
import type {FocusConfig} from './focus_config.js';
import {getTemplate} from './password_manager_app.html.js';
import {PasswordManagerImpl} from './password_manager_proxy.js';
import type {PasswordsSectionElement} from './passwords_section.js';
import type {Route} from './router.js';
import {Page, RouteObserverMixin, Router} from './router.js';
import type {SettingsSectionElement} from './settings_section.js';
import type {PasswordManagerSideBarElement} from './side_bar.js';
import type {PasswordManagerToolbarElement} from './toolbar.js';

/**
 * Checks if an HTML element is an editable. An editable is either a text
 * input or a text area.
 */
function isEditable(element: Element): boolean {
  const nodeName = element.nodeName.toLowerCase();
  return element.nodeType === Node.ELEMENT_NODE &&
      (nodeName === 'textarea' ||
       (nodeName === 'input' &&
        /^(?:text|search|email|number|tel|url|password)$/i.test(
            (element as HTMLInputElement).type)));
}

export type PasswordsMovedEvent =
    CustomEvent<{accountEmail: string, numberOfPasswords: number}>;

export type ValueCopiedEvent = CustomEvent<{toastMessage: string}>;

export interface PasswordManagerAppElement {
  $: {
    checkup: CheckupSectionElement,
    content: CrPageSelectorElement,
    drawer: CrDrawerElement,
    drawerTemplate: DomIf,
    passwords: PasswordsSectionElement,
    prefs: SettingsPrefsElement,
    toast: CrToastElement,
    settings: SettingsSectionElement,
    sidebar: PasswordManagerSideBarElement,
    toolbar: PasswordManagerToolbarElement,
  };
}

const PasswordManagerAppElementBase = FindShortcutMixin(
    I18nMixin(CrContainerShadowMixin(RouteObserverMixin(PolymerElement))));

export class PasswordManagerAppElement extends PasswordManagerAppElementBase {
  static get is() {
    return 'password-manager-app';
  }

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

  static get properties() {
    return {
      /**
       * Preferences state.
       */
      prefs_: Object,

      selectedPage_: {
        type: String,
        value: Page.PASSWORDS,
      },

      narrow_: {
        type: Boolean,
        observer: 'onMaxWidthChanged_',
      },

      collapsed_: {
        type: Boolean,
        observer: 'onMaxWidthChanged_',
      },

      pageTitle_: {
        type: String,
      },

      /*
       * Mirroring the enum so that it can be used from HTML bindings.
       */
      pagesValueEnum_: {
        type: Object,
        value: Page,
      },

      toastMessage_: String,

      /**
       * Whether to show an "undo" button on the removal toast.
       */
      showUndo_: Boolean,

      /**
       * A Map specifying which element should be focused when exiting a
       * subpage. The key of the map holds a Route path, and the value holds
       * either a query selector that identifies the desired element, an element
       * or a function to be run when a neon-animation-finish event is handled.
       */
      focusConfig_: {
        type: Object,
        value() {
          const map = new Map();
          return map;
        },
      },
    };
  }

  private selectedPage_: Page;
  private narrow_: boolean;
  private collapsed_: boolean;
  private pageTitle_: string = this.i18n('passwordManagerTitle');
  private toastMessage_: string;
  private showUndo_: boolean;
  private focusConfig_: FocusConfig;

  override ready() {
    super.ready();

    window.CrPolicyStrings = {
      controlledSettingExtension:
          loadTimeData.getString('controlledSettingExtension'),
      controlledSettingExtensionWithoutName:
          loadTimeData.getString('controlledSettingExtensionWithoutName'),
      controlledSettingPolicy:
          loadTimeData.getString('controlledSettingPolicy'),
      controlledSettingRecommendedMatches:
          loadTimeData.getString('controlledSettingRecommendedMatches'),
      controlledSettingRecommendedDiffers:
          loadTimeData.getString('controlledSettingRecommendedDiffers'),
      controlledSettingChildRestriction:
          loadTimeData.getString('controlledSettingChildRestriction'),
      controlledSettingParent:
          loadTimeData.getString('controlledSettingParent'),

      // <if expr="chromeos_ash">
      controlledSettingShared:
          loadTimeData.getString('controlledSettingShared'),
      controlledSettingWithOwner:
          loadTimeData.getString('controlledSettingWithOwner'),
      controlledSettingNoOwner:
          loadTimeData.getString('controlledSettingNoOwner'),
      // </if>
    };

    document.addEventListener('keydown', e => {
      // <if expr="is_macosx">
      if (e.metaKey && e.key === 'z') {
        this.onUndoKeyBinding_(e);
      }
      // </if>
      // <if expr="not is_macosx">
      if (e.ctrlKey && e.key === 'z') {
        this.onUndoKeyBinding_(e);
      }
      // </if>
    });

    // Lazy-create the drawer the first time it is opened or swiped into view.
    listenOnce(this.$.drawer, 'cr-drawer-opening', () => {
      this.$.drawerTemplate.if = true;
    });

    this.addEventListener('cr-toolbar-menu-click', this.onMenuButtonClick_);
    this.addEventListener('close-drawer', this.closeDrawer_);
  }

  override currentRouteChanged(route: Route): void {
    this.selectedPage_ = route.page;
    setTimeout(() => {  // Async to allow page to load.
      if (route.page === Page.CHECKUP_DETAILS) {
        this.enableScrollObservation(false);
        this.setForceDropShadows(true);
      } else {
        this.setForceDropShadows(false);
        this.enableScrollObservation(true);
      }
    }, 0);
  }

  // Override FindShortcutMixin methods.
  override handleFindShortcut(modalContextOpen: boolean): boolean {
    if (modalContextOpen) {
      return false;
    }
    // Redirect to Password Manager search on Passwords page.
    if (Router.getInstance().currentRoute.page === Page.PASSWORDS) {
      this.$.toolbar.searchField.showAndFocus();
      return true;
    }
    return false;
  }

  // Override FindShortcutMixin methods.
  override searchInputHasFocus(): boolean {
    return this.$.toolbar.searchField.isSearchFocused();
  }

  private onMaxWidthChanged_() {
    if (this.$.drawer.open && !this.narrow_) {
      this.$.drawer.close();
    }
    // Window is greater than 980px but less than 1200px.
    if (!this.narrow_ && this.collapsed_) {
      this.pageTitle_ = this.i18n('passwordManagerString');
    } else {
      this.pageTitle_ = this.i18n('passwordManagerTitle');
    }
  }

  private onMenuButtonClick_() {
    this.$.drawer.toggle();
  }

  private closeDrawer_() {
    if (this.$.drawer && this.$.drawer.open) {
      this.$.drawer.close();
    }
  }

  setNarrowForTesting(state: boolean) {
    this.narrow_ = state;
  }

  private showPage(currentPage: string, pageToShow: string): boolean {
    return currentPage === pageToShow;
  }

  /**
   * Handle the shortcut to undo a removal of passwords/exceptions. This must
   * be handled here and not at the PasswordDetailsCard level because that
   * component does not know about exception deletions.
   */
  private onUndoKeyBinding_(event: Event) {
    const activeElement = getDeepActiveElement();
    // If the focused element is editable (e.g. search box) the undo event
    // should be handled there and not here.
    if (!activeElement || !isEditable(activeElement)) {
      this.onUndoButtonClick_();
      // Preventing the default is necessary to not conflict with a possible
      // search action.
      event.preventDefault();
    }
  }

  private onPasswordRemoved_(_event: PasswordRemovedEvent) {
    // TODO(crbug.com/40234318): Show different message if account store user.
    this.showUndo_ = true;
    this.toastMessage_ = this.i18n('passwordDeleted');
    this.$.toast.show();
  }

  private onPasskeyRemoved_() {
    this.showUndo_ = false;
    this.toastMessage_ = this.i18n('passkeyDeleted');
    this.$.toast.show();
  }

  private async onPasswordsMoved_(event: PasswordsMovedEvent) {
    this.showUndo_ = false;
    this.toastMessage_ =
        await PluralStringProxyImpl.getInstance()
            .getPluralString(
                'passwordsMovedToastMessage', event.detail.numberOfPasswords)
            .then(label => label.replace('$1', event.detail.accountEmail));
    this.$.toast.show();
  }

  private async onValueCopied_(event: ValueCopiedEvent) {
    this.showUndo_ = false;
    this.toastMessage_ = event.detail.toastMessage;
    this.$.toast.show();
  }

  private async onBiometricAuthBeforeFillingEnabled_(_event: CustomEvent) {
    this.showUndo_ = false;
    this.toastMessage_ = this.i18n('screenlockReauthPromoConfirmation');
    this.$.toast.show();
  }

  private onUndoButtonClick_() {
    PasswordManagerImpl.getInstance().undoRemoveSavedPasswordOrException();
    this.$.toast.hide();
  }

  private onSearchEnterClick_() {
    this.$.passwords.focusFirstResult();
  }

  private onIronSelect_(e: Event) {
    // Ignore bubbling 'iron-select' events not originating from
    // |content| itself.
    if (e.target !== this.$.content) {
      return;
    }

    if (!this.focusConfig_ || Router.getInstance().previousRoute === null) {
      return;
    }

    const pathConfig =
        this.focusConfig_.get(Router.getInstance().previousRoute!.page);
    if (pathConfig) {
      let handler;
      if (typeof pathConfig === 'function') {
        handler = pathConfig;
      } else {
        handler = () => {
          focusWithoutInk(pathConfig as HTMLElement);
        };
      }
      handler();
    }
  }
}
declare global {
  interface HTMLElementTagNameMap {
    'password-manager-app': PasswordManagerAppElement;
  }
}

customElements.define(PasswordManagerAppElement.is, PasswordManagerAppElement);