| // Copyright 2015 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * @fileoverview 'passwords-section' is the collapsible section containing |
| * the list of saved passwords as well as the list of sites that will never |
| * save any passwords. |
| */ |
| |
| /** @typedef {!{model: !{item: !chrome.passwordsPrivate.ExceptionEntry}}} */ |
| let ExceptionEntryEntryEvent; |
| |
| import {afterNextRender, html, Polymer} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js'; |
| |
| import 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.m.js'; |
| import 'chrome://resources/cr_elements/cr_button/cr_button.m.js'; |
| import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.m.js'; |
| import 'chrome://resources/cr_elements/cr_link_row/cr_link_row.js'; |
| import 'chrome://resources/cr_elements/icons.m.js'; |
| import 'chrome://resources/cr_elements/shared_style_css.m.js'; |
| import {OpenWindowProxyImpl} from '../open_window_proxy.js'; |
| import {assert} from 'chrome://resources/js/assert.m.js'; |
| import {focusWithoutInk} from 'chrome://resources/js/cr/ui/focus_without_ink.m.js'; |
| import {I18nBehavior} from 'chrome://resources/js/i18n_behavior.m.js'; |
| import {getDeepActiveElement} from 'chrome://resources/js/util.m.js'; |
| import {WebUIListenerBehavior} from 'chrome://resources/js/web_ui_listener_behavior.m.js'; |
| import {IronA11yAnnouncer} from 'chrome://resources/polymer/v3_0/iron-a11y-announcer/iron-a11y-announcer.js'; |
| import {IronA11yKeysBehavior} from 'chrome://resources/polymer/v3_0/iron-a11y-keys-behavior/iron-a11y-keys-behavior.js'; |
| import 'chrome://resources/polymer/v3_0/iron-flex-layout/iron-flex-layout-classes.js'; |
| import 'chrome://resources/polymer/v3_0/iron-list/iron-list.js'; |
| |
| import '../controls/extension_controlled_indicator.js'; |
| import '../controls/settings_toggle_button.js'; |
| import {GlobalScrollTargetBehavior} from '../global_scroll_target_behavior.js'; |
| import {loadTimeData} from '../i18n_setup.js'; |
| import {SyncBrowserProxyImpl, SyncPrefs, SyncStatus} from '../people_page/sync_browser_proxy.js'; |
| import '../prefs/prefs.js'; |
| import {PrefsBehavior} from '../prefs/prefs_behavior.js'; |
| import {routes} from '../route.js'; |
| import {MergeExceptionsStoreCopiesBehavior} from './merge_exceptions_store_copies_behavior.js'; |
| import {MergePasswordsStoreCopiesBehavior} from './merge_passwords_store_copies_behavior.js'; |
| import {MultiStorePasswordUiEntry} from './multi_store_password_ui_entry.js'; |
| import {Router} from '../router.js'; |
| import '../settings_shared_css.js'; |
| import '../site_favicon.js'; |
| import {PasswordCheckBehavior} from './password_check_behavior.js'; |
| import './password_list_item.js'; |
| import './passwords_list_handler.js'; |
| import {PasswordManagerImpl, PasswordManagerProxy} from './password_manager_proxy.js'; |
| import './passwords_export_dialog.js'; |
| import './passwords_shared_css.js'; |
| import './avatar_icon.js'; |
| // <if expr="chromeos"> |
| import '../controls/password_prompt_dialog.js'; |
| import {BlockingRequestManager} from './blocking_request_manager.js'; |
| // </if> |
| |
| /** |
| * Checks if an HTML element is an editable. An editable is either a text |
| * input or a text area. |
| * @param {!Element} element |
| * @return {boolean} |
| */ |
| function isEditable(element) { |
| 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.type))); |
| } |
| |
| Polymer({ |
| is: 'passwords-section', |
| |
| _template: html`{__html_template__}`, |
| |
| behaviors: [ |
| I18nBehavior, |
| WebUIListenerBehavior, |
| MergeExceptionsStoreCopiesBehavior, |
| MergePasswordsStoreCopiesBehavior, |
| PasswordCheckBehavior, |
| IronA11yKeysBehavior, |
| GlobalScrollTargetBehavior, |
| PrefsBehavior, |
| ], |
| |
| properties: { |
| |
| /** @type {!Map<string, (string|Function)>} */ |
| focusConfig: { |
| type: Object, |
| observer: 'focusConfigChanged_', |
| }, |
| |
| /** Preferences state. */ |
| prefs: { |
| type: Object, |
| notify: true, |
| }, |
| |
| /** @override */ |
| subpageRoute: { |
| type: Object, |
| value: routes.PASSWORDS, |
| }, |
| |
| /** The target of the key bindings defined below. */ |
| keyEventTarget: { |
| type: Object, |
| value: () => document, |
| }, |
| |
| /** Filter on the saved passwords and exceptions. */ |
| filter: { |
| type: String, |
| value: '', |
| }, |
| |
| // <if expr="not chromeos"> |
| /** @private */ |
| storedAccounts_: Array, |
| // </if> |
| |
| /** @private */ |
| signedIn_: { |
| type: Boolean, |
| value: true, |
| computed: 'computeSignedIn_(syncStatus_, storedAccounts_)', |
| }, |
| |
| /** @private */ |
| eligibleForAccountStorage_: { |
| type: Boolean, |
| value: false, |
| computed: 'computeEligibleForAccountStorage_(' + |
| 'syncStatus_, signedIn_, syncPrefs_)', |
| }, |
| |
| /** @private */ |
| hasNeverCheckedPasswords_: { |
| type: Boolean, |
| computed: 'computeHasNeverCheckedPasswords_(status)', |
| }, |
| |
| /** @private */ |
| hasSavedPasswords_: { |
| type: Boolean, |
| computed: |
| 'computeHasSavedPasswords_(savedPasswords, savedPasswords.splices)', |
| }, |
| |
| /** |
| * Used to decide the text on the button leading to 'device passwords' page. |
| * @private |
| */ |
| numberOfDevicePasswords_: { |
| type: Number, |
| computed: 'computeNumberOfDevicePasswords_(savedPasswords, ' + |
| 'savedPasswords.splices)', |
| }, |
| |
| /** @private */ |
| hasPasswordExceptions_: { |
| type: Boolean, |
| computed: 'computeHasPasswordExceptions_(passwordExceptions)', |
| }, |
| |
| /** @private */ |
| shouldShowBanner_: { |
| type: Boolean, |
| value: true, |
| computed: 'computeShouldShowBanner_(hasLeakedCredentials_,' + |
| 'signedIn_, hasNeverCheckedPasswords_, hasSavedPasswords_)', |
| }, |
| |
| /** |
| * Whether the edit dialog and removal notification should show |
| * information about which location(s) a password is stored. |
| * @private |
| */ |
| shouldShowStorageDetails_: { |
| type: Boolean, |
| value: false, |
| computed: 'computeShouldShowStorageDetails_(' + |
| 'eligibleForAccountStorage_, isOptedInForAccountStorage_)', |
| }, |
| |
| /** |
| * Whether the entry point leading to the device passwords page should be |
| * shown for a user who is already eligible for account storage. |
| * @private |
| */ |
| shouldShowDevicePasswordsLink_: { |
| type: Boolean, |
| value: false, |
| computed: 'computeShouldShowDevicePasswordsLink_(' + |
| 'isOptedInForAccountStorage_, numberOfDevicePasswords_)', |
| }, |
| |
| /** |
| * Whether the entry point leading to enroll in trusted vault encryption |
| * should be shown. |
| * @private |
| */ |
| shouldOfferTrustedVaultOptIn_: { |
| type: Boolean, |
| value: false, |
| }, |
| |
| /** @private */ |
| hasLeakedCredentials_: { |
| type: Boolean, |
| computed: 'computeHasLeakedCredentials_(leakedPasswords)', |
| }, |
| |
| /** @private */ |
| hidePasswordsLink_: { |
| type: Boolean, |
| computed: 'computeHidePasswordsLink_(syncPrefs_, syncStatus_, ' + |
| 'eligibleForAccountStorage_)', |
| }, |
| |
| /** @private */ |
| showImportPasswords_: { |
| type: Boolean, |
| value() { |
| return loadTimeData.valueExists('showImportPasswords') && |
| loadTimeData.getBoolean('showImportPasswords'); |
| } |
| }, |
| |
| /** @private */ |
| accountStorageFeatureEnabled_: { |
| type: Boolean, |
| value() { |
| return loadTimeData.getBoolean('enableAccountStorage'); |
| } |
| }, |
| |
| /** @private */ |
| profileEmail_: { |
| type: String, |
| value: '', |
| computed: 'getFirstStoredAccountEmail_(storedAccounts_)', |
| }, |
| |
| /** |
| * The currently selected profile icon as CSS image set. |
| * @private |
| */ |
| profileIcon_: String, |
| |
| /** @private */ |
| isOptedInForAccountStorage_: Boolean, |
| |
| /** @private {SyncPrefs} */ |
| syncPrefs_: Object, |
| |
| /** @private {SyncStatus} */ |
| syncStatus_: Object, |
| |
| // <if expr="chromeos"> |
| /** @private */ |
| showPasswordPromptDialog_: Boolean, |
| |
| /** @private {BlockingRequestManager} */ |
| tokenRequestManager_: Object, |
| // </if> |
| }, |
| |
| keyBindings: { |
| // <if expr="is_macosx"> |
| 'meta+z': 'onUndoKeyBinding_', |
| // </if> |
| // <if expr="not is_macosx"> |
| 'ctrl+z': 'onUndoKeyBinding_', |
| // </if> |
| }, |
| |
| /** |
| * A stack of the elements that triggered dialog to open and should therefore |
| * receive focus when that dialog is closed. The bottom of the stack is the |
| * element that triggered the earliest open dialog and top of the stack is the |
| * element that triggered the most recent (i.e. active) dialog. If no dialog |
| * is open, the stack is empty. |
| * @private {!Array<Element>} |
| */ |
| activeDialogAnchorStack_: [], |
| |
| /** @private {?PasswordManagerProxy} */ |
| passwordManager_: null, |
| |
| /** @private {?function(boolean):void} */ |
| setIsOptedInForAccountStorageListener_: null, |
| |
| /** @private {?function(!Array<PasswordManagerProxy.ExceptionEntry>):void} */ |
| setPasswordExceptionsListener_: null, |
| |
| /** @override */ |
| attached() { |
| // Create listener functions. |
| const setIsOptedInForAccountStorageListener = optedIn => { |
| this.isOptedInForAccountStorage_ = optedIn; |
| }; |
| |
| this.setIsOptedInForAccountStorageListener_ = |
| setIsOptedInForAccountStorageListener; |
| |
| // Set the manager. These can be overridden by tests. |
| this.passwordManager_ = PasswordManagerImpl.getInstance(); |
| |
| // <if expr="chromeos"> |
| // If the user's account supports the password check, an auth token will be |
| // required in order for them to view or export passwords. Otherwise there |
| // is no additional security so |tokenRequestManager_| will immediately |
| // resolve requests. |
| if (loadTimeData.getBoolean('userCannotManuallyEnterPassword')) { |
| this.tokenRequestManager_ = new BlockingRequestManager(); |
| } else { |
| this.tokenRequestManager_ = |
| new BlockingRequestManager(this.openPasswordPromptDialog_.bind(this)); |
| } |
| // </if> |
| |
| // Request initial data. |
| this.passwordManager_.isOptedInForAccountStorage().then( |
| setIsOptedInForAccountStorageListener); |
| |
| // Listen for changes. |
| this.passwordManager_.addAccountStorageOptInStateListener( |
| setIsOptedInForAccountStorageListener); |
| |
| const syncBrowserProxy = SyncBrowserProxyImpl.getInstance(); |
| |
| const syncStatusChanged = syncStatus => this.syncStatus_ = syncStatus; |
| syncBrowserProxy.getSyncStatus().then(syncStatusChanged); |
| this.addWebUIListener('sync-status-changed', syncStatusChanged); |
| |
| const syncPrefsChanged = syncPrefs => this.syncPrefs_ = syncPrefs; |
| this.addWebUIListener('sync-prefs-changed', syncPrefsChanged); |
| syncBrowserProxy.sendSyncPrefsChanged(); |
| |
| // For non-ChromeOS, also check whether accounts are available. |
| // <if expr="not chromeos"> |
| const storedAccountsChanged = accounts => this.storedAccounts_ = accounts; |
| syncBrowserProxy.getStoredAccounts().then(storedAccountsChanged); |
| this.addWebUIListener('stored-accounts-updated', storedAccountsChanged); |
| // </if> |
| |
| syncBrowserProxy.sendOfferTrustedVaultOptInChanged(); |
| this.addWebUIListener( |
| 'offer-trusted-vault-opt-in-changed', (offerOptIn) => { |
| this.shouldOfferTrustedVaultOptIn_ = offerOptIn; |
| }); |
| |
| afterNextRender(this, function() { |
| IronA11yAnnouncer.requestAvailability(); |
| }); |
| }, |
| |
| /** @override */ |
| detached() { |
| this.passwordManager_.removeAccountStorageOptInStateListener( |
| assert(this.setIsOptedInForAccountStorageListener_)); |
| }, |
| |
| /** |
| * @return {boolean} |
| * @private |
| */ |
| computeSignedIn_() { |
| return !!this.syncStatus_ && !!this.syncStatus_.signedIn ? |
| !this.syncStatus_.hasError : |
| (!!this.storedAccounts_ && this.storedAccounts_.length > 0); |
| }, |
| |
| /** |
| * @return {boolean} |
| * @private |
| */ |
| computeEligibleForAccountStorage_() { |
| // The user must have signed in but should have sync disabled |
| // (|!this.syncStatus_.signedin|). They should not be using a custom |
| // passphrase to encrypt their sync data, since there's no way for account |
| // storage users to input their passphrase and decrypt the passwords. |
| return this.accountStorageFeatureEnabled_ && |
| (!!this.syncStatus_ && !this.syncStatus_.signedIn) && this.signedIn_ && |
| (!this.syncPrefs_ || !this.syncPrefs_.encryptAllData); |
| }, |
| |
| /** |
| * @return {boolean} |
| * @private |
| */ |
| computeHasSavedPasswords_() { |
| return this.savedPasswords.length > 0; |
| }, |
| |
| /** |
| * @return {number} |
| * @private |
| */ |
| computeNumberOfDevicePasswords_() { |
| return this.savedPasswords.filter(p => p.isPresentOnDevice()).length; |
| }, |
| |
| /** |
| * @return {boolean} |
| * @private |
| */ |
| computeHasPasswordExceptions_() { |
| return this.passwordExceptions.length > 0; |
| }, |
| |
| /** |
| * @return {boolean} |
| * @private |
| */ |
| computeShouldShowBanner_() { |
| return this.signedIn_ && this.hasSavedPasswords_ && |
| this.hasNeverCheckedPasswords_ && !this.hasLeakedCredentials_; |
| }, |
| |
| /** |
| * @return {boolean} |
| * @private |
| */ |
| computeShouldShowStorageDetails_() { |
| return this.eligibleForAccountStorage_ && this.isOptedInForAccountStorage_; |
| }, |
| |
| /** |
| * @return {boolean} |
| * @private |
| */ |
| computeShouldShowDevicePasswordsLink_() { |
| return this.isOptedInForAccountStorage_ && |
| (this.numberOfDevicePasswords_ > 0); |
| }, |
| |
| /** |
| * hide the link to the user's Google Account if: |
| * a) the link is embedded in the account storage message OR |
| * b) the user is signed out (or signed-in but has encrypted passwords) |
| * @return {boolean} |
| * @private |
| */ |
| computeHidePasswordsLink_() { |
| return this.eligibleForAccountStorage_ || |
| (!!this.syncStatus_ && !!this.syncStatus_.signedIn && |
| !!this.syncPrefs_ && !!this.syncPrefs_.encryptAllData); |
| }, |
| |
| /** |
| * @private |
| * @return {boolean} |
| */ |
| computeHasLeakedCredentials_() { |
| return this.leakedPasswords.length > 0; |
| }, |
| |
| /** |
| * @private |
| * @return {boolean} |
| */ |
| computeHasNeverCheckedPasswords_() { |
| return !this.status.elapsedTimeSinceLastCheck; |
| }, |
| |
| /** |
| * Shows the check passwords sub page. |
| * @private |
| */ |
| onCheckPasswordsClick_() { |
| Router.getInstance().navigateTo( |
| routes.CHECK_PASSWORDS, new URLSearchParams('start=true')); |
| this.passwordManager_.recordPasswordCheckReferrer( |
| PasswordManagerProxy.PasswordCheckReferrer.PASSWORD_SETTINGS); |
| }, |
| |
| /** |
| * Shows the page to opt in to trusted vault encryption. |
| * @private |
| */ |
| onTrustedVaultOptInClick_() { |
| OpenWindowProxyImpl.getInstance().openURL( |
| loadTimeData.getString('trustedVaultOptInUrl')); |
| }, |
| |
| /** |
| * Shows the 'device passwords' page. |
| * @private |
| */ |
| onDevicePasswordsLinkClicked_() { |
| Router.getInstance().navigateTo(routes.DEVICE_PASSWORDS); |
| }, |
| |
| // <if expr="chromeos"> |
| /** |
| * When this event fired, it means that the password-prompt-dialog succeeded |
| * in creating a fresh token in the quickUnlockPrivate API. Because new tokens |
| * can only ever be created immediately following a GAIA password check, the |
| * passwordsPrivate API can now safely grant requests for secure data (i.e. |
| * saved passwords) for a limited time. This observer resolves the request, |
| * triggering a callback that requires a fresh auth token to succeed and that |
| * was provided to the BlockingRequestManager by another DOM element seeking |
| * secure data. |
| * |
| * @param {!CustomEvent<!chrome.quickUnlockPrivate.TokenInfo>} e - Contains |
| * newly created auth token. Note that its precise value is not relevant |
| * here, only the facts that it's created. |
| * @private |
| */ |
| onTokenObtained_(e) { |
| assert(e.detail); |
| this.tokenRequestManager_.resolve(); |
| }, |
| |
| /** @private */ |
| onPasswordPromptClosed_() { |
| this.showPasswordPromptDialog_ = false; |
| focusWithoutInk(assert(this.activeDialogAnchorStack_.pop())); |
| }, |
| |
| /** @private */ |
| openPasswordPromptDialog_() { |
| this.activeDialogAnchorStack_.push(getDeepActiveElement()); |
| this.showPasswordPromptDialog_ = true; |
| }, |
| // </if> |
| |
| /** |
| * @return {function(!MultiStorePasswordUiEntry): boolean} |
| * @private |
| */ |
| passwordFilter_() { |
| return password => [password.urls.shown, password.username].some( |
| term => term.toLowerCase().includes(this.filter.toLowerCase())); |
| }, |
| |
| /** |
| * @return {function(!chrome.passwordsPrivate.ExceptionEntry): boolean} |
| * @private |
| */ |
| passwordExceptionFilter_() { |
| return exception => exception.urls.shown.toLowerCase().includes( |
| this.filter.toLowerCase()); |
| }, |
| |
| /** |
| * Handle the shortcut to undo a removal of passwords/exceptions. This must |
| * be handled here and not at the PasswordsListHandler level because that |
| * component does not know about exception deletions. |
| * @param {!Event} event |
| * @private |
| */ |
| onUndoKeyBinding_(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.passwordManager_.undoRemoveSavedPasswordOrException(); |
| this.$.passwordsListHandler.onSavedPasswordOrExceptionRemoved(); |
| // Preventing the default is necessary to not conflict with a possible |
| // search action. |
| event.preventDefault(); |
| } |
| }, |
| |
| /** |
| * Fires an event that should delete the password exception. |
| * @param {!{model: !{item: !chrome.passwordsPrivate.ExceptionEntry}}} e |
| * The polymer event. |
| * @private |
| */ |
| onRemoveExceptionButtonTap_(e) { |
| const exception = e.model.item; |
| /** @type {!Array<number>} */ |
| const allExceptionIds = []; |
| if (exception.isPresentInAccount()) { |
| allExceptionIds.push(exception.accountId); |
| } |
| if (exception.isPresentOnDevice()) { |
| allExceptionIds.push(exception.deviceId); |
| } |
| this.passwordManager_.removeExceptions(allExceptionIds); |
| }, |
| |
| /** |
| * Opens the export/import action menu. |
| * @private |
| */ |
| onImportExportMenuTap_() { |
| const menu = /** @type {!CrActionMenuElement} */ (this.$.exportImportMenu); |
| const target = |
| /** @type {!HTMLElement} */ (this.$$('#exportImportMenuButton')); |
| |
| menu.showAt(target); |
| this.activeDialogAnchorStack_.push(target); |
| }, |
| |
| /** |
| * Fires an event that should trigger the password import process. |
| * @private |
| */ |
| onImportTap_() { |
| this.passwordManager_.importPasswords(); |
| this.$.exportImportMenu.close(); |
| }, |
| |
| /** |
| * Opens the export passwords dialog. |
| * @private |
| */ |
| onExportTap_() { |
| this.showPasswordsExportDialog_ = true; |
| this.$.exportImportMenu.close(); |
| }, |
| |
| /** @private */ |
| onPasswordsExportDialogClosed_() { |
| this.showPasswordsExportDialog_ = false; |
| focusWithoutInk(assert(this.activeDialogAnchorStack_.pop())); |
| }, |
| |
| /** @private */ |
| onOptIn_: function() { |
| this.passwordManager_.optInForAccountStorage(true); |
| }, |
| |
| /** @private */ |
| onOptOut_: function() { |
| this.passwordManager_.optInForAccountStorage(false); |
| }, |
| |
| /** |
| * @private |
| * @return {boolean} |
| */ |
| showImportOrExportPasswords_() { |
| return this.hasSavedPasswords_ || this.showImportPasswords_; |
| }, |
| |
| /** |
| * Return the first available stored account. This is useful when trying to |
| * figure out the account logged into the content area which seems to always |
| * be first even if multiple accounts are available. |
| * @return {string} The email address of the first stored account or an empty |
| * string. |
| * @private |
| */ |
| getFirstStoredAccountEmail_() { |
| return !!this.storedAccounts_ && this.storedAccounts_.length > 0 ? |
| this.storedAccounts_[0].email : |
| ''; |
| }, |
| |
| /** |
| * @param {!Map<string, string>} newConfig |
| * @param {?Map<string, string>} oldConfig |
| * @private |
| */ |
| focusConfigChanged_(newConfig, oldConfig) { |
| // focusConfig is set only once on the parent, so this observer should |
| // only fire once. |
| assert(!oldConfig); |
| |
| // Populate the |focusConfig| map of the parent <settings-autofill-page> |
| // element, with additional entries that correspond to subpage trigger |
| // elements residing in this element's Shadow DOM. |
| this.focusConfig.set(assert(routes.CHECK_PASSWORDS).path, () => { |
| focusWithoutInk(assert(this.$$('#icon'))); |
| }); |
| }, |
| }); |