Bug 433238 - Password manager contextual menu password field manual fill. r=MattN

--HG--
extra : commitid : C2Thcw28VRq
extra : rebase_source : 6b6b628738bf715109161961bdced4489685058c
This commit is contained in:
Bernardo P. Rittmeyer 2015-08-06 15:28:07 -07:00
parent 68d9768596
commit 7ec5131e30
10 changed files with 315 additions and 14 deletions

View File

@ -417,6 +417,23 @@
label="&bidiSwitchPageDirectionItem.label;"
accesskey="&bidiSwitchPageDirectionItem.accesskey;"
oncommand="gContextMenu.switchPageDirection();"/>
<menuseparator id="fill-login-separator" hidden="true"/>
<menu id="fill-login"
label="&fillPasswordMenu.label;"
class="menu-iconic"
accesskey="&fillPasswordMenu.accesskey;"
hidden="true">
<menupopup id="fill-login-popup">
<menuitem id="fill-login-no-logins"
label="&noLoginSuggestions.label;"
disabled="true"
hidden="true"/>
<menuseparator id="saved-logins-separator"/>
<menuitem id="fill-login-saved-passwords"
label="&viewSavedLogins.label;"
oncommand="gContextMenu.openPasswordManager();"/>
</menupopup>
</menu>
<menuseparator id="inspect-separator" hidden="true"/>
<menuitem id="context-inspect"
hidden="true"

View File

@ -5,6 +5,7 @@
Components.utils.import("resource://gre/modules/PrivateBrowsingUtils.jsm");
Components.utils.import("resource://gre/modules/InlineSpellChecker.jsm");
Components.utils.import("resource://gre/modules/LoginManagerContextMenu.jsm");
Components.utils.import("resource://gre/modules/BrowserUtils.jsm");
Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
@ -12,6 +13,8 @@ XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
"resource:///modules/CustomizableUI.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Pocket",
"resource:///modules/Pocket.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
"resource://gre/modules/LoginHelper.jsm");
var gContextMenuContentData = null;
@ -67,6 +70,7 @@ nsContextMenu.prototype = {
InlineSpellCheckerUI.clearSuggestionsFromMenu();
InlineSpellCheckerUI.clearDictionaryListFromMenu();
InlineSpellCheckerUI.uninit();
LoginManagerContextMenu.clearLoginsFromMenu(document);
// This handler self-deletes, only run it if it is still there:
if (this._onPopupHiding) {
@ -86,6 +90,7 @@ nsContextMenu.prototype = {
this.initMediaPlayerItems();
this.initLeaveDOMFullScreenItems();
this.initClickToPlayItems();
this.initPasswordManagerItems();
},
initPageMenuSeparator: function CM_initPageMenuSeparator() {
@ -500,6 +505,33 @@ nsContextMenu.prototype = {
this.showItem("context-sep-ctp", this.onCTPPlugin);
},
initPasswordManagerItems: function() {
let showFillPassword = this.onPassword;
let disableFillPassword = !Services.logins.isLoggedIn || this.target.disabled || this.target.readOnly;
this.showItem("fill-login-separator", showFillPassword);
this.showItem("fill-login", showFillPassword);
this.setItemAttr("fill-login", "disabled", disableFillPassword);
if (!showFillPassword || disableFillPassword) {
return;
}
let documentURI = gContextMenuContentData.documentURIObject;
let fragment = LoginManagerContextMenu.addLoginsToMenu(this.target, this.browser, documentURI);
this.showItem("fill-login-no-logins", !fragment);
if (!fragment) {
return;
}
let popup = document.getElementById("fill-login-popup");
let insertBeforeElement = document.getElementById("fill-login-no-logins");
popup.insertBefore(fragment, insertBeforeElement);
},
openPasswordManager: function() {
LoginHelper.openPasswordManager(window, gContextMenuContentData.documentURIObject.host);
},
inspectNode: function CM_inspectNode() {
let {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
let gBrowser = this.browser.ownerDocument.defaultView.gBrowser;
@ -571,6 +603,7 @@ nsContextMenu.prototype = {
this.isDesignMode = false;
this.onCTPPlugin = false;
this.canSpellCheck = false;
this.onPassword = false;
if (this.isRemote) {
this.selectionInfo = gContextMenuContentData.selectionInfo;
@ -668,6 +701,7 @@ nsContextMenu.prototype = {
this.onTextInput = (editFlags & SpellCheckHelper.TEXTINPUT) !== 0;
this.onNumeric = (editFlags & SpellCheckHelper.NUMERIC) !== 0;
this.onEditableArea = (editFlags & SpellCheckHelper.EDITABLE) !== 0;
this.onPassword = (editFlags & SpellCheckHelper.PASSWORD) !== 0;
if (this.onEditableArea) {
if (this.isRemote) {
InlineSpellCheckerUI.initFromRemote(gContextMenuContentData.spellInfo);

View File

@ -95,3 +95,13 @@
#context-media-eme-learnmore {
list-style-image: url("chrome://browser/skin/drm-icon.svg#chains");
}
#fill-login {
list-style-image: url("chrome://mozapps/skin/passwordmgr/key-16.png");
}
@media (min-resolution: 1.1dppx) {
#fill-login {
list-style-image: url("chrome://mozapps/skin/passwordmgr/key-16@2x.png");
}
}

View File

@ -188,6 +188,7 @@ var LoginManagerContent = {
loginFormOrigin: msg.data.loginFormOrigin,
loginsFound: jsLoginsToXPCOM(msg.data.logins),
recipes: msg.data.recipes,
inputElement: msg.objects.inputElement,
});
return;
}
@ -445,25 +446,39 @@ var LoginManagerContent = {
* from the origin of the form used for the fill.
* recipes:
* Fill recipes transmitted together with the original message.
* inputElement:
* Optional input password element from the form we want to fill.
* }
*/
fillForm({ topDocument, loginFormOrigin, loginsFound, recipes }) {
fillForm({ topDocument, loginFormOrigin, loginsFound, recipes, inputElement }) {
let topState = this.stateForDocument(topDocument);
if (!topState.loginFormForFill) {
log("fillForm: There is no login form anymore. The form may have been",
"removed or the document may have changed.");
return;
}
if (LoginUtils._getPasswordOrigin(topDocument.documentURI) !=
loginFormOrigin) {
log("fillForm: The requested origin doesn't match the one form the",
"document. This may mean we navigated to a document from a different",
"site before we had a chance to indicate this change in the user",
"interface.");
return;
if (LoginUtils._getPasswordOrigin(topDocument.documentURI) != loginFormOrigin) {
if (!inputElement ||
LoginUtils._getPasswordOrigin(inputElement.ownerDocument.documentURI) != loginFormOrigin) {
log("fillForm: The requested origin doesn't match the one form the",
"document. This may mean we navigated to a document from a different",
"site before we had a chance to indicate this change in the user",
"interface.");
return;
}
}
this._fillForm(topState.loginFormForFill, true, true, true, true,
loginsFound, recipes);
let form = topState.loginFormForFill;
let clobberUsername = true;
let options = {
inputElement,
};
// If we have a target input, fills it's form.
if (inputElement) {
form = FormLikeFactory.createFromPasswordField(inputElement);
clobberUsername = false;
}
this._fillForm(form, true, clobberUsername, true, true, loginsFound, recipes, options);
},
loginsFound: function({ form, loginsFound, recipes }) {
@ -814,9 +829,11 @@ var LoginManagerContent = {
* the user
* @param {nsILoginInfo[]} foundLogins is an array of nsILoginInfo that could be used for the form
* @param {Set} recipes that could be used to affect how the form is filled
* @param {Object} [options = {}] is a list of options for this method.
- [inputElement] is an optional target input element we want to fill
*/
_fillForm : function (form, autofillForm, clobberUsername, clobberPassword,
userTriggered, foundLogins, recipes) {
userTriggered, foundLogins, recipes, {inputElement} = {}) {
let ignoreAutocomplete = true;
const AUTOFILL_RESULT = {
FILLED: 0,
@ -855,6 +872,17 @@ var LoginManagerContent = {
var [usernameField, passwordField, ignored] =
this._getFormFields(form, false, recipes);
// If we have a password inputElement parameter and it's not
// the same as the one heuristically found, use the parameter
// one instead.
if (inputElement) {
if (inputElement.type != "password") {
throw new Error("Unexpected input element type.");
}
passwordField = inputElement;
usernameField = null;
}
// Need a valid password field to do anything.
if (passwordField == null) {
log("not filling form, no password field found");

View File

@ -0,0 +1,183 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
this.EXPORTED_SYMBOLS = ["LoginManagerContextMenu"];
const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoginManagerParent",
"resource://gre/modules/LoginManagerParent.jsm");
/*
* Password manager object for the browser contextual menu.
*/
let LoginManagerContextMenu = {
dateAndTimeFormatter: new Intl.DateTimeFormat(undefined,
{ day: "numeric", month: "short", year: "numeric" }),
/**
* Look for login items and add them to the contextual menu.
*
* @param {HTMLInputElement} inputElement
* The target input element of the context menu click.
* @param {xul:browser} browser
* The browser for the document the context menu was open on.
* @param {nsIURI} documentURI
* The URI of the document that the context menu was activated from.
* This isn't the same as the browser's top-level document URI
* when subframes are involved.
* @returns {DocumentFragment} a document fragment with all the login items.
*/
addLoginsToMenu(inputElement, browser, documentURI) {
let foundLogins = this._findLogins(documentURI);
if (!foundLogins.length) {
return null;
}
let fragment = browser.ownerDocument.createDocumentFragment();
let duplicateUsernames = this._findDuplicates(foundLogins);
for (let login of foundLogins) {
let item = fragment.ownerDocument.createElement("menuitem");
let username = login.username;
// If login is empty or duplicated we want to append a modification date to it.
if (!username || duplicateUsernames.has(username)){
if (!username) {
username = this._getLocalizedString("noUsername");
}
let meta = login.QueryInterface(Ci.nsILoginMetaInfo);
let time = this.dateAndTimeFormatter.format(new Date(meta.timePasswordChanged));
username = this._getLocalizedString("loginHostAge", [username, time]);
}
item.setAttribute("label", username);
item.setAttribute("class", "context-login-item");
// login is bound so we can keep the reference to each object.
item.addEventListener("command", function(login, event) {
this._fillPassword(login, inputElement, browser, documentURI);
}.bind(this, login));
fragment.appendChild(item);
}
return fragment;
},
/**
* Undoes the work of addLoginsToMenu for the same menu.
*
* @param {Document}
* The context menu owner document.
*/
clearLoginsFromMenu(document) {
let loginItems = document.getElementsByClassName("context-login-item");
while (loginItems.item(0)) {
loginItems.item(0).remove();
}
},
/**
* Find logins for the current URI.
*
* @param {nsIURI} documentURI
* URI object with the hostname of the logins we want to find.
* This isn't the same as the browser's top-level document URI
* when subframes are involved.
*
* @returns {nsILoginInfo[]} a login list
*/
_findLogins(documentURI) {
let logins = Services.logins.findLogins({}, documentURI.prePath, "", "");
// Sort logins in alphabetical order and by date.
logins.sort((loginA, loginB) => {
// Sort alphabetically
let result = loginA.username.localeCompare(loginB.username);
if (result) {
// Forces empty logins to be at the end
if (!loginA.username) {
return 1;
}
if (!loginB.username) {
return -1;
}
return result;
}
// Same username logins are sorted by last change date
let metaA = loginA.QueryInterface(Ci.nsILoginMetaInfo);
let metaB = loginB.QueryInterface(Ci.nsILoginMetaInfo);
return metaB.timePasswordChanged - metaA.timePasswordChanged;
});
return logins;
},
/**
* Find duplicate usernames in a login list.
*
* @param {nsILoginInfo[]} loginList
* A list of logins we want to look for duplicate usernames.
*
* @returns {Set} a set with the duplicate usernames.
*/
_findDuplicates(loginList) {
let seen = new Set();
let duplicates = new Set();
for (let login of loginList) {
if (seen.has(login.username)) {
duplicates.add(login.username);
}
seen.add(login.username);
}
return duplicates;
},
/**
* @param {nsILoginInfo} login
* The login we want to fill the form with.
* @param {Element} inputElement
* The target input element we want to fill.
* @param {xul:browser} browser
* The target tab browser.
* @param {nsIURI} documentURI
* URI of the document owning the form we want to fill.
* This isn't the same as the browser's top-level
* document URI when subframes are involved.
*/
_fillPassword(login, inputElement, browser, documentURI) {
LoginManagerParent.fillForm({
browser: browser,
loginFormOrigin: documentURI.prePath,
login: login,
inputElement: inputElement,
}).catch(Cu.reportError);
},
/**
* @param {string} key
* The localized string key
* @param {string[]} formatArgs
* An array of formatting argument string
*
* @returns {string} the localized string for the specified key,
* formatted with arguments if required.
*/
_getLocalizedString(key, formatArgs) {
if (formatArgs) {
return this._stringBundle.formatStringFromName(key, formatArgs, formatArgs.length);
}
return this._stringBundle.GetStringFromName(key);
},
};
XPCOMUtils.defineLazyGetter(LoginManagerContextMenu, "_stringBundle", function() {
return Services.strings.
createBundle("chrome://passwordmgr/locale/passwordmgr.properties");
});

View File

@ -235,7 +235,7 @@ var LoginManagerParent = {
* Trigger a login form fill and send relevant data (e.g. logins and recipes)
* to the child process (LoginManagerContent).
*/
fillForm: Task.async(function* ({ browser, loginFormOrigin, login }) {
fillForm: Task.async(function* ({ browser, loginFormOrigin, login, inputElement }) {
let recipes = [];
if (loginFormOrigin) {
let formHost;
@ -251,11 +251,13 @@ var LoginManagerParent = {
// Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo
// doesn't support structured cloning.
let jsLogins = JSON.parse(JSON.stringify([login]));
let objects = inputElement ? {inputElement} : null;
browser.messageManager.sendAsyncMessage("RemoteLogins:fillForm", {
loginFormOrigin,
logins: jsLogins,
recipes,
});
}, objects);
}),
/**

View File

@ -56,7 +56,6 @@ else:
'storage-json.js',
]
EXTRA_JS_MODULES += [
'LoginDoorhangers.jsm',
'LoginImport.jsm',
'LoginStore.jsm',
]
@ -66,6 +65,12 @@ if CONFIG['OS_TARGET'] == 'WINNT':
'OSCrypto_win.js',
]
if CONFIG['MOZ_BUILD_APP'] == 'browser':
EXTRA_JS_MODULES += [
'LoginDoorhangers.jsm',
'LoginManagerContextMenu.jsm',
]
JAR_MANIFESTS += ['jar.mn']
with Files('**'):

View File

@ -26,3 +26,12 @@
<!ENTITY spellDictionaries.accesskey "l">
<!ENTITY searchTextBox.clear.label "Clear">
<!ENTITY fillLoginMenu.label "Fill Login">
<!ENTITY fillLoginMenu.accesskey "F">
<!ENTITY fillPasswordMenu.label "Fill Password">
<!ENTITY fillPasswordMenu.accesskey "F">
<!ENTITY fillUsernameMenu.label "Fill Username">
<!ENTITY fillUsernameMenu.accesskey "F">
<!ENTITY noLoginSuggestions.label "(No Login Suggestions)">
<!ENTITY viewSavedLogins.label "View Saved Logins">

View File

@ -56,3 +56,10 @@ removeAllPasswordsPrompt=Are you sure you wish to remove all passwords?
removeAllPasswordsTitle=Remove all passwords
loginsSpielAll=Passwords for the following sites are stored on your computer:
loginsSpielFiltered=The following passwords match your search:
# LOCALIZATION NOTE (loginHostAge):
# This is used to show the context menu login items with their age.
# 1st string is the username for the login, 2nd is the login's age.
loginHostAge=%1$S (%2$S)
# LOCALIZATION NOTE (noUsername):
# String is used on the context menu when a login doesn't have a username.
noUsername=No username

View File

@ -429,6 +429,9 @@ var SpellCheckHelper = {
// Set when over an <input type="number"> or other non-text field.
NUMERIC: 0x40,
// Set when over an <input type="password"> field.
PASSWORD: 0x80,
isTargetAKeywordField(aNode, window) {
if (!(aNode instanceof window.HTMLInputElement))
return false;
@ -479,6 +482,9 @@ var SpellCheckHelper = {
}
if (this.isTargetAKeywordField(element, window))
flags |= this.KEYWORD;
if (element.type == "password") {
flags |= this.PASSWORD;
}
}
} else if (element instanceof window.HTMLTextAreaElement) {
flags |= this.TEXTINPUT | this.TEXTAREA;