Bug 764213 - Provisional desktop UI for website sign-in with Persona. r=dolske

--HG--
extra : rebase_source : 8785a377e88b11f59ec00d44b58e6c21fce576a7
This commit is contained in:
Matthew Noorenberghe 2012-06-12 18:16:00 -07:00
parent 0fbd93f782
commit 2e1addbc7c
17 changed files with 1117 additions and 9 deletions

View File

@ -459,6 +459,11 @@ window[chromehidden~="toolbar"] toolbar:not(.toolbar-primary):not(.chromeclass-m
display: none;
}
#notification-popup .text-link.custom-link {
-moz-binding: url("chrome://global/content/bindings/text.xml#text-label");
text-decoration: none;
}
#invalid-form-popup > description {
max-width: 280px;
}
@ -471,6 +476,10 @@ window[chromehidden~="toolbar"] toolbar:not(.toolbar-primary):not(.chromeclass-m
-moz-binding: url("chrome://browser/content/urlbarBindings.xml#addon-progress-notification");
}
#identity-request-notification {
-moz-binding: url("chrome://browser/content/urlbarBindings.xml#identity-request-notification");
}
/* override hidden="true" for the status bar compatibility shim
in case it was persisted for the real status bar */
#status-bar {

View File

@ -564,6 +564,7 @@
onblur="setTimeout(function() document.getElementById('identity-box').style.MozUserFocus = '', 0);">
<box id="notification-popup-box" hidden="true" align="center">
<image id="default-notification-icon" class="notification-anchor-icon" role="button"/>
<image id="identity-notification-icon" class="notification-anchor-icon" role="button"/>
<image id="geo-notification-icon" class="notification-anchor-icon" role="button"/>
<image id="addons-notification-icon" class="notification-anchor-icon" role="button"/>
<image id="indexedDB-notification-icon" class="notification-anchor-icon" role="button"/>

View File

@ -78,7 +78,7 @@ function runNextTest() {
onHidden.call(nextTest, this);
if (!onHiddenArray.length)
goNext();
});
}.bind(this));
}, onHiddenArray.length);
info("[Test #" + gTestIndex + "] added listeners; panel state: " + PopupNotifications.isPanelOpen);
}

View File

@ -1175,6 +1175,261 @@
</implementation>
</binding>
<binding id="identity-request-notification" extends="chrome://global/content/bindings/notification.xml#popup-notification">
<content align="start">
<xul:image class="popup-notification-icon"
xbl:inherits="popupid,src=icon"/>
<xul:vbox flex="1">
<xul:vbox anonid="identity-deck">
<xul:vbox flex="1" pack="center"> <!-- 1: add an email -->
<html:input type="email" anonid="email" required="required" size="30"/>
<xul:description anonid="newidentitydesc"/>
<xul:spacer flex="1"/>
<xul:label class="text-link custom-link small-margin" anonid="chooseemail" hidden="true"/>
</xul:vbox>
<xul:vbox flex="1" hidden="true"> <!-- 2: choose an email -->
<xul:description anonid="chooseidentitydesc"/>
<xul:radiogroup anonid="identities">
</xul:radiogroup>
<xul:label class="text-link custom-link" anonid="newemail"/>
</xul:vbox>
</xul:vbox>
<xul:hbox class="popup-notification-button-container"
pack="end" align="center">
<xul:label anonid="tos" class="text-link" hidden="true"/>
<xul:label anonid="privacypolicy" class="text-link" hidden="true"/>
<xul:spacer flex="1"/>
<xul:image anonid="throbber" src="chrome://browser/skin/tabbrowser/loading.png"
style="visibility:hidden" width="16" height="16"/>
<xul:button anonid="button"
type="menu-button"
class="popup-notification-menubutton"
xbl:inherits="oncommand=buttoncommand,label=buttonlabel,accesskey=buttonaccesskey">
<xul:menupopup anonid="menupopup"
xbl:inherits="oncommand=menucommand">
<children/>
<xul:menuitem class="menuitem-iconic popup-notification-closeitem"
label="&closeNotificationItem.label;"
xbl:inherits="oncommand=closeitemcommand"/>
</xul:menupopup>
</xul:button>
</xul:hbox>
</xul:vbox>
<xul:vbox pack="start">
<xul:toolbarbutton anonid="closebutton"
class="messageCloseButton popup-notification-closebutton tabbable"
xbl:inherits="oncommand=closebuttoncommand"
tooltiptext="&closeNotification.tooltip;"/>
</xul:vbox>
</content>
<implementation>
<constructor><![CDATA[
// this.notification.options.identity is used to pass identity-specific info to the binding
let origin = this.identity.origin
// Populate text
this.emailField.placeholder = gNavigatorBundle.
getString("identity.newIdentity.email.placeholder");
this.newIdentityDesc.textContent = gNavigatorBundle.getFormattedString(
"identity.newIdentity.description", [origin]);
this.chooseIdentityDesc.textContent = gNavigatorBundle.getFormattedString(
"identity.chooseIdentity.description", [origin]);
// Show optional terms of service and privacy policy links
this._populateLink(this.identity.termsOfService, "tos", "identity.termsOfService");
this._populateLink(this.identity.privacyPolicy, "privacypolicy", "identity.privacyPolicy");
// Populate the list of identities to choose from. The origin is used to provide
// better suggestions.
let identities = this.SignInToWebsiteUX.getIdentitiesForSite(origin);
this._populateIdentityList(identities);
if (typeof this.step == "undefined") {
// First opening of this notification
// Show the add email pane (0) if there are no existing identities otherwise show the list
this.step = "result" in identities && identities.result.length ? 1 : 0;
} else {
// Already opened so restore previous state
if (this.identity.typedEmail) {
this.emailField.value = this.identity.typedEmail;
}
if (this.identity.selected) {
// If the user already chose an identity then update the UI to reflect that
this.onIdentitySelected();
}
// Update the view for the step
this.step = this.step;
}
// Fire notification with the chosen identity when main button is clicked
this.button.addEventListener("command", this._onButtonCommand.bind(this), true);
// Do the same if enter is pressed in the email field
this.emailField.addEventListener("keypress", function emailFieldKeypress(aEvent) {
if (aEvent.keyCode != aEvent.DOM_VK_RETURN)
return;
this._onButtonCommand(aEvent);
}.bind(this));
this.addEmailLink.value = gNavigatorBundle.getString("identity.newIdentity.label");
this.addEmailLink.accessKey = gNavigatorBundle.getString("identity.newIdentity.accessKey");
this.addEmailLink.addEventListener("click", function addEmailClick(evt) {
this.step = 0;
}.bind(this));
this.chooseEmailLink.value = gNavigatorBundle.getString("identity.chooseIdentity.label");
this.chooseEmailLink.hidden = !("result" in identities && identities.result.length);
this.chooseEmailLink.addEventListener("click", function chooseEmailClick(evt) {
this.step = 1;
}.bind(this));
this.emailField.addEventListener("blur", function onEmailBlur() {
this.identity.typedEmail = this.emailField.value;
}.bind(this));
]]></constructor>
<field name="SignInToWebsiteUX" readonly="true">
let sitw = {};
Components.utils.import("resource:///modules/SignInToWebsite.jsm", sitw);
sitw.SignInToWebsiteUX;
</field>
<field name="newIdentityDesc" readonly="true">
document.getAnonymousElementByAttribute(this, "anonid", "newidentitydesc");
</field>
<field name="chooseIdentityDesc" readonly="true">
document.getAnonymousElementByAttribute(this, "anonid", "chooseidentitydesc");
</field>
<field name="identityList" readonly="true">
document.getAnonymousElementByAttribute(this, "anonid", "identities");
</field>
<field name="emailField" readonly="true">
document.getAnonymousElementByAttribute(this, "anonid", "email");
</field>
<field name="addEmailLink" readonly="true">
document.getAnonymousElementByAttribute(this, "anonid", "newemail");
</field>
<field name="chooseEmailLink" readonly="true">
document.getAnonymousElementByAttribute(this, "anonid", "chooseemail");
</field>
<field name="throbber" readonly="true">
document.getAnonymousElementByAttribute(this, "anonid", "throbber");
</field>
<field name="identity" readonly="true">
this.notification.options.identity;
</field>
<!-- persist the state on the identity object so we can re-create the
notification state upon re-opening -->
<property name="step">
<getter>
return this.identity.step;
</getter>
<setter><![CDATA[
let deck = document.getAnonymousElementByAttribute(this, "anonid", "identity-deck");
for (let i = 0; i < deck.children.length; i++) {
deck.children[i].hidden = (val != i);
}
this.identity.step = val;
switch (val) {
case 0:
this.emailField.focus();
break;
}]]>
</setter>
</property>
<method name="onIdentitySelected">
<body><![CDATA[
this.throbber.style.visibility = "visible";
this.button.disabled = true;
this.emailField.value = this.identity.selected
this.emailField.disabled = true;
this.identityList.disabled = true;
]]></body>
</method>
<method name="_populateLink">
<parameter name="aURL"/>
<parameter name="aLinkId"/>
<parameter name="aStringId"/>
<body><![CDATA[
if (aURL) {
// Show optional link to aURL
let link = document.getAnonymousElementByAttribute(this, "anonid", aLinkId);
link.value = gNavigatorBundle.getString(aStringId);
link.href = aURL;
link.hidden = false;
}
]]></body>
</method>
<method name="_populateIdentityList">
<parameter name="aIdentities"/>
<body><![CDATA[
let foundLastUsed = false;
let lastUsed = this.identity.selected || aIdentities.lastUsed;
for (let id in aIdentities.result) {
let label = aIdentities.result[id];
let opt = this.identityList.appendItem(label);
if (label == lastUsed) {
this.identityList.selectedItem = opt;
foundLastUsed = true;
}
}
if (!foundLastUsed) {
this.identityList.selectedIndex = -1;
}
]]></body>
</method>
<method name="_onButtonCommand">
<parameter name="aEvent"/>
<body><![CDATA[
if (aEvent.target != aEvent.currentTarget)
return;
let chosenId;
switch (this.step) {
case 0:
aEvent.stopPropagation();
if (!this.emailField.validity.valid) {
this.emailField.focus();
return;
}
chosenId = this.emailField.value;
break;
case 1:
aEvent.stopPropagation();
let selectedItem = this.identityList.selectedItem
chosenId = selectedItem ? selectedItem.label : null;
if (!chosenId)
return;
break;
default:
throw new Error("Unknown case");
return;
}
// Actually select the identity
this.SignInToWebsiteUX.selectIdentity(this.identity.rpId, chosenId);
this.identity.selected = chosenId;
this.onIdentitySelected();
]]></body>
</method>
</implementation>
</binding>
<binding id="splitmenu">
<content>
<xul:hbox anonid="menuitem" flex="1"

View File

@ -12,6 +12,7 @@ const XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource:///modules/SignInToWebsite.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
"resource://gre/modules/AddonManager.jsm");
@ -308,6 +309,7 @@ BrowserGlue.prototype = {
if (this._isPlacesShutdownObserver)
os.removeObserver(this, "places-shutdown");
webappsUI.uninit();
SignInToWebsiteUX.uninit();
},
_onAppDefaults: function BG__onAppDefaults() {
@ -337,6 +339,8 @@ BrowserGlue.prototype = {
PageThumbs.init();
SignInToWebsiteUX.init();
PdfJs.init();
Services.obs.notifyObservers(null, "browser-ui-startup-complete", "");

View File

@ -383,3 +383,21 @@ social.remove.accesskey=R
# LOCALIZATION NOTE (social.enabled.message): %1$S is the name of the social provider, %2$S is brandShortName (e.g. Firefox)
social.activated.message=%1$S integration with %2$S has been activated.
# Identity notifications popups
identity.termsOfService = Terms of Service
identity.privacyPolicy = Privacy Policy
identity.chooseIdentity.description = Sign in to %S
identity.chooseIdentity.label = Use an existing email
identity.newIdentity.label = Use a different email
identity.newIdentity.accessKey = e
identity.newIdentity.email.placeholder = Email
# LOCALIZATION NOTE (identity.newIdentity.description, identity.chooseIdentity.description): %S is the website origin (ie. https://www.mozilla.org) shown in popup notifications.
identity.newIdentity.description = Enter your email address to sign in to %S
identity.next.label = Next
identity.next.accessKey = n
# LOCALIZATION NOTE: shown in the popup notification when a user successfully logs into a website
# LOCALIZATION NOTE (identity.loggedIn.description): %S is the website origin (ie. https://www.mozilla.org)
identity.loggedIn.description = Signed in as: %S
identity.loggedIn.signOut.label = Sign Out
identity.loggedIn.signOut.accessKey = O

View File

@ -18,6 +18,7 @@ EXTRA_JS_MODULES = \
NetworkPrioritizer.jsm \
NewTabUtils.jsm \
offlineAppCache.jsm \
SignInToWebsite.jsm \
TelemetryTimestamps.jsm \
Social.jsm \
webappsUI.jsm \

View File

@ -0,0 +1,235 @@
/* 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";
const EXPORTED_SYMBOLS = ["SignInToWebsiteUX"];
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "IdentityService",
"resource://gre/modules/identity/Identity.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Logger",
"resource://gre/modules/identity/LogUtils.jsm");
function log(...aMessageArgs) {
Logger.log.apply(Logger, ["SignInToWebsiteUX"].concat(aMessageArgs));
}
let SignInToWebsiteUX = {
init: function SignInToWebsiteUX_init() {
Services.obs.addObserver(this, "identity-request", false);
Services.obs.addObserver(this, "identity-auth", false);
Services.obs.addObserver(this, "identity-auth-complete", false);
Services.obs.addObserver(this, "identity-login-state-changed", false);
},
uninit: function SignInToWebsiteUX_uninit() {
Services.obs.removeObserver(this, "identity-request");
Services.obs.removeObserver(this, "identity-auth");
Services.obs.removeObserver(this, "identity-auth-complete");
Services.obs.removeObserver(this, "identity-login-state-changed");
},
observe: function SignInToWebsiteUX_observe(aSubject, aTopic, aData) {
log("observe: received", aTopic, "with", aData, "for", aSubject);
let options = null;
if (aSubject) {
options = aSubject.wrappedJSObject;
}
switch(aTopic) {
case "identity-request":
this.requestLogin(options);
break;
case "identity-auth":
this._openAuthenticationUI(aData, options);
break;
case "identity-auth-complete":
this._closeAuthenticationUI(aData);
break;
case "identity-login-state-changed":
let emailAddress = aData;
if (emailAddress) {
this._removeRequestUI(options);
this._showLoggedInUI(emailAddress, options);
} else {
this._removeLoggedInUI(options);
}
break;
default:
Logger.reportError("SignInToWebsiteUX", "Unknown observer notification:", aTopic);
break;
}
},
/**
* The website is requesting login so the user must choose an identity to use.
*/
requestLogin: function SignInToWebsiteUX_requestLogin(aOptions) {
let windowID = aOptions.rpId;
log("requestLogin", aOptions);
let [chromeWin, browserEl] = this._getUIForWindowID(windowID);
// message is not shown in the UI but is required
let message = aOptions.origin;
let mainAction = {
label: chromeWin.gNavigatorBundle.getString("identity.next.label"),
accessKey: chromeWin.gNavigatorBundle.getString("identity.next.accessKey"),
callback: function() {}, // required
};
let options = {
identity: {
origin: aOptions.origin,
},
};
let secondaryActions = [];
// add some extra properties to the notification to store some identity-related state
for (let opt in aOptions) {
options.identity[opt] = aOptions[opt];
}
log("requestLogin: rpId: ", options.identity.rpId);
chromeWin.PopupNotifications.show(browserEl, "identity-request", message,
"identity-notification-icon", mainAction,
[], options);
},
/**
* Get the list of possible identities to login to the given origin.
*/
getIdentitiesForSite: function SignInToWebsiteUX_getIdentitiesForSite(aOrigin) {
return IdentityService.RP.getIdentitiesForSite(aOrigin);
},
/**
* User chose a new or existing identity from the doorhanger after a request() call
*/
selectIdentity: function SignInToWebsiteUX_selectIdentity(aRpId, aIdentity) {
log("selectIdentity: rpId: ", aRpId, " identity: ", aIdentity);
IdentityService.selectIdentity(aRpId, aIdentity);
},
// Private
/**
* Return the chrome window and <browser> for the given outer window ID.
*/
_getUIForWindowID: function(aWindowID) {
let someWindow = Services.wm.getMostRecentWindow("navigator:browser");
if (!someWindow) {
Logger.reportError("SignInToWebsiteUX", "no window");
return [null, null];
}
let windowUtils = someWindow.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDOMWindowUtils);
let content = windowUtils.getOuterWindowWithId(aWindowID);
if (content) {
let browser = content.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIWebNavigation)
.QueryInterface(Ci.nsIDocShell).chromeEventHandler;
let chromeWin = browser.ownerDocument.defaultView;
return [chromeWin, browser];
}
Logger.reportError("SignInToWebsiteUX", "no content");
return [null, null];
},
/**
* Open UI with a content frame displaying aAuthURI so that the user can authenticate with their
* IDP. Then tell Identity.jsm the identifier for the window so that it knows that the DOM API
* calls are for this authentication flow.
*/
_openAuthenticationUI: function _openAuthenticationUI(aAuthURI, aContext) {
// Open a tab/window with aAuthURI with an identifier (aID) attached so that the DOM APIs know this is an auth. window.
let chromeWin = Services.wm.getMostRecentWindow('navigator:browser');
let features = "chrome=false,width=640,height=480,centerscreen,location=yes,resizable=yes,scrollbars=yes,status=yes";
log("aAuthURI: ", aAuthURI);
let authWin = Services.ww.openWindow(chromeWin, "about:blank", "", features, null);
let windowID = authWin.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils).outerWindowID;
log("authWin outer id: ", windowID);
let provId = aContext.provId;
// Tell the ID service about the id before loading the url
IdentityService.IDP.setAuthenticationFlow(windowID, provId);
authWin.location = aAuthURI;
},
_closeAuthenticationUI: function _closeAuthenticationUI(aAuthId) {
log("_closeAuthenticationUI:", aAuthId);
let [chromeWin, browserEl] = this._getUIForWindowID(aAuthId);
if (chromeWin)
chromeWin.close();
else
Logger.reportError("SignInToWebsite", "Could not close window with ID", aAuthId);
},
/**
* Show a doorhanger indicating the currently logged-in user.
*/
_showLoggedInUI: function _showLoggedInUI(aIdentity, aContext) {
let windowID = aContext.rpId;
log("_showLoggedInUI for ", windowID);
let [chromeWin, browserEl] = this._getUIForWindowID(windowID);
let message = chromeWin.gNavigatorBundle.getFormattedString("identity.loggedIn.description",
[aIdentity]);
let mainAction = {
label: chromeWin.gNavigatorBundle.getString("identity.loggedIn.signOut.label"),
accessKey: chromeWin.gNavigatorBundle.getString("identity.loggedIn.signOut.accessKey"),
callback: function() {
log("sign out callback fired");
IdentityService.RP.logout(windowID);
},
};
let secondaryActions = [];
let options = {
dismissed: true,
};
let loggedInNot = chromeWin.PopupNotifications.show(browserEl, "identity-logged-in", message,
"identity-notification-icon", mainAction,
secondaryActions, options);
loggedInNot.rpId = windowID;
},
/**
* Remove the doorhanger indicating the currently logged-in user.
*/
_removeLoggedInUI: function _removeLoggedInUI(aContext) {
let windowID = aContext.rpId;
log("_removeLoggedInUI for ", windowID);
if (!windowID)
throw "_removeLoggedInUI: Invalid RP ID";
let [chromeWin, browserEl] = this._getUIForWindowID(windowID);
let loggedInNot = chromeWin.PopupNotifications.getNotification("identity-logged-in", browserEl);
if (loggedInNot)
chromeWin.PopupNotifications.remove(loggedInNot);
},
/**
* Remove the doorhanger indicating the currently logged-in user.
*/
_removeRequestUI: function _removeRequestUI(aContext) {
let windowID = aContext.rpId;
log("_removeRequestUI for ", windowID);
let [chromeWin, browserEl] = this._getUIForWindowID(windowID);
let requestNot = chromeWin.PopupNotifications.getNotification("identity-request", browserEl);
if (requestNot)
chromeWin.PopupNotifications.remove(requestNot);
},
};

View File

@ -14,6 +14,7 @@ include $(topsrcdir)/config/rules.mk
_BROWSER_FILES = \
browser_NetworkPrioritizer.js \
browser_TelemetryTimestamps.js \
browser_SignInToWebsite.js \
$(NULL)
ifeq ($(MOZ_WIDGET_TOOLKIT),windows)

View File

@ -0,0 +1,549 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
/**
* TO TEST:
* - test state saved on doorhanger dismissal
* - links to switch steps
* - TOS and PP link clicks
* - identityList is populated correctly
*/
Services.prefs.setBoolPref("toolkit.identity.debug", true);
XPCOMUtils.defineLazyModuleGetter(this, "IdentityService",
"resource://gre/modules/identity/Identity.jsm");
const TEST_ORIGIN = "https://example.com";
const TEST_EMAIL = "user@example.com";
let gTestIndex = 0;
let outerWinId = gBrowser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDOMWindowUtils).outerWindowID;
function NotificationBase(aNotId) {
this.id = aNotId;
}
NotificationBase.prototype = {
message: TEST_ORIGIN,
mainAction: {
label: "",
callback: function() {
this.mainActionClicked = true;
}.bind(this),
},
secondaryActions: [],
options: {
"identity": {
origin: TEST_ORIGIN,
rpId: outerWinId,
},
},
};
let tests = [
{
name: "test_request_required_typed",
run: function() {
setupRPFlow();
this.notifyOptions = {
rpId: outerWinId,
origin: TEST_ORIGIN,
};
this.notifyObj = new NotificationBase("identity-request");
Services.obs.notifyObservers({wrappedJSObject: this.notifyOptions},
"identity-request", null);
},
onShown: function(popup) {
checkPopup(popup, this.notifyObj);
let notification = popup.childNodes[0];
// Check identity popup state
let state = notification.identity;
ok(!state.typedEmail, "Nothing should be typed yet");
ok(!state.selected, "Identity should not be selected yet");
ok(!state.termsOfService, "No TOS specified");
ok(!state.privacyPolicy, "No PP specified");
is(state.step, 0, "Step should be persisted with default value");
is(state.rpId, outerWinId, "Check rpId");
is(state.origin, TEST_ORIGIN, "Check origin");
is(notification.step, 0, "Should be on the new email step");
is(notification.chooseEmailLink.hidden, true, "Identity list is empty so link to list view should be hidden");
is(notification.addEmailLink.parentElement.hidden, true, "We are already on the email input step so choose email pane should be hidden");
is(notification.emailField.value, "", "Email field should default to empty on a new notification");
let notifDoc = notification.ownerDocument;
ok(notifDoc.getAnonymousElementByAttribute(notification, "anonid", "tos").hidden,
"TOS link should be hidden");
ok(notifDoc.getAnonymousElementByAttribute(notification, "anonid", "privacypolicy").hidden,
"PP link should be hidden");
// Try to continue with a missing email address
triggerMainCommand(popup);
is(notification.throbber.style.visibility, "hidden", "is throbber visible");
ok(!notification.button.disabled, "Button should not be disabled");
is(window.gIdentitySelected, null, "Check no identity selected");
// Fill in an invalid email address and try again
notification.emailField.value = "foo";
triggerMainCommand(popup);
is(notification.throbber.style.visibility, "hidden", "is throbber visible");
ok(!notification.button.disabled, "Button should not be disabled");
is(window.gIdentitySelected, null, "Check no identity selected");
// Fill in an email address and try again
notification.emailField.value = TEST_EMAIL;
triggerMainCommand(popup);
is(window.gIdentitySelected.rpId, outerWinId, "Check identity selected rpId");
is(window.gIdentitySelected.identity, TEST_EMAIL, "Check identity selected email");
is(notification.identity.selected, TEST_EMAIL, "Check persisted email");
is(notification.throbber.style.visibility, "visible", "is throbber visible");
ok(notification.button.disabled, "Button should be disabled");
ok(notification.emailField.disabled, "Email field should be disabled");
ok(notification.identityList.disabled, "Identity list should be disabled");
PopupNotifications.getNotification("identity-request").remove();
},
onHidden: function(popup) { },
},
{
name: "test_request_optional",
run: function() {
this.notifyOptions = {
rpId: outerWinId,
origin: TEST_ORIGIN,
privacyPolicy: TEST_ORIGIN + "/pp.txt",
termsOfService: TEST_ORIGIN + "/tos.tzt",
};
this.notifyObj = new NotificationBase("identity-request");
Services.obs.notifyObservers({ wrappedJSObject: this.notifyOptions },
"identity-request", null);
},
onShown: function(popup) {
checkPopup(popup, this.notifyObj);
let notification = popup.childNodes[0];
// Check identity popup state
let state = notification.identity;
ok(!state.typedEmail, "Nothing should be typed yet");
ok(!state.selected, "Identity should not be selected yet");
is(state.termsOfService, this.notifyOptions.termsOfService, "Check TOS URL");
is(state.privacyPolicy, this.notifyOptions.privacyPolicy, "Check PP URL");
is(state.step, 0, "Step should be persisted with default value");
is(state.rpId, outerWinId, "Check rpId");
is(state.origin, TEST_ORIGIN, "Check origin");
is(notification.step, 0, "Should be on the new email step");
is(notification.chooseEmailLink.hidden, true, "Identity list is empty so link to list view should be hidden");
is(notification.addEmailLink.parentElement.hidden, true, "We are already on the email input step so choose email pane should be hidden");
is(notification.emailField.value, "", "Email field should default to empty on a new notification");
let notifDoc = notification.ownerDocument;
let tosLink = notifDoc.getAnonymousElementByAttribute(notification, "anonid", "tos");
ok(!tosLink.hidden, "TOS link should be visible");
is(tosLink.href, this.notifyOptions.termsOfService, "Check TOS link URL");
let ppLink = notifDoc.getAnonymousElementByAttribute(notification, "anonid", "privacypolicy");
ok(!ppLink.hidden, "PP link should be visible");
is(ppLink.href, this.notifyOptions.privacyPolicy, "Check PP link URL");
// Try to continue with a missing email address
triggerMainCommand(popup);
is(notification.throbber.style.visibility, "hidden", "is throbber visible");
ok(!notification.button.disabled, "Button should not be disabled");
is(window.gIdentitySelected, null, "Check no identity selected");
// Fill in an invalid email address and try again
notification.emailField.value = "foo";
triggerMainCommand(popup);
is(notification.throbber.style.visibility, "hidden", "is throbber visible");
ok(!notification.button.disabled, "Button should not be disabled");
is(window.gIdentitySelected, null, "Check no identity selected");
// Fill in an email address and try again
notification.emailField.value = TEST_EMAIL;
triggerMainCommand(popup);
is(window.gIdentitySelected.rpId, outerWinId, "Check identity selected rpId");
is(window.gIdentitySelected.identity, TEST_EMAIL, "Check identity selected email");
is(notification.identity.selected, TEST_EMAIL, "Check persisted email");
is(notification.throbber.style.visibility, "visible", "is throbber visible");
ok(notification.button.disabled, "Button should be disabled");
ok(notification.emailField.disabled, "Email field should be disabled");
ok(notification.identityList.disabled, "Identity list should be disabled");
PopupNotifications.getNotification("identity-request").remove();
},
onHidden: function(popup) {},
},
{
name: "test_login_state_changed",
run: function () {
this.notifyOptions = {
rpId: outerWinId,
};
this.notifyObj = new NotificationBase("identity-logged-in");
this.notifyObj.message = "Signed in as: user@example.com";
this.notifyObj.mainAction.label = "Sign Out";
this.notifyObj.mainAction.accessKey = "O";
Services.obs.notifyObservers({ wrappedJSObject: this.notifyOptions },
"identity-login-state-changed", TEST_EMAIL);
executeSoon(function() {
PopupNotifications.getNotification("identity-logged-in").anchorElement.click();
});
},
onShown: function(popup) {
checkPopup(popup, this.notifyObj);
// Fire the notification that the user is no longer logged-in to close the UI.
Services.obs.notifyObservers({ wrappedJSObject: this.notifyOptions },
"identity-login-state-changed", null);
},
onHidden: function(popup) {},
},
{
name: "test_login_state_changed_logout",
run: function () {
this.notifyOptions = {
rpId: outerWinId,
};
this.notifyObj = new NotificationBase("identity-logged-in");
this.notifyObj.message = "Signed in as: user@example.com";
this.notifyObj.mainAction.label = "Sign Out";
this.notifyObj.mainAction.accessKey = "O";
Services.obs.notifyObservers({ wrappedJSObject: this.notifyOptions },
"identity-login-state-changed", TEST_EMAIL);
executeSoon(function() {
PopupNotifications.getNotification("identity-logged-in").anchorElement.click();
});
},
onShown: function(popup) {
checkPopup(popup, this.notifyObj);
// This time trigger the Sign Out button and make sure the UI goes away.
triggerMainCommand(popup);
},
onHidden: function(popup) {},
},
];
function test_auth() {
let notifyOptions = {
provId: outerWinId,
origin: TEST_ORIGIN,
};
Services.obs.addObserver(function() {
// prepare to send auth-complete and close the window
let winCloseObs = new WindowObserver(function(closedWin) {
info("closed window");
finish();
}, "domwindowclosed");
Services.ww.registerNotification(winCloseObs);
Services.obs.notifyObservers(null, "identity-auth-complete", IdentityService.IDP.authenticationFlowSet.authId);
}, "test-identity-auth-window", false);
let winObs = new WindowObserver(function(authWin) {
ok(authWin, "Authentication window opened");
ok(authWin.contentWindow.location);
});
Services.ww.registerNotification(winObs);
Services.obs.notifyObservers({ wrappedJSObject: notifyOptions },
"identity-auth", TEST_ORIGIN + "/auth");
}
function test() {
waitForExplicitFinish();
registerCleanupFunction(cleanUp);
let sitw = {};
Components.utils.import("resource:///modules/SignInToWebsite.jsm", sitw);
ok(sitw.SignInToWebsiteUX, "SignInToWebsiteUX object exists");
// Replace implementation of ID Service functions for testing
window.selectIdentity = sitw.SignInToWebsiteUX.selectIdentity;
sitw.SignInToWebsiteUX.selectIdentity = function(aRpId, aIdentity) {
info("Identity selected: " + aIdentity);
window.gIdentitySelected = {rpId: aRpId, identity: aIdentity};
};
window.setAuthenticationFlow = IdentityService.IDP.setAuthenticationFlow;
IdentityService.IDP.setAuthenticationFlow = function(aAuthId, aProvId) {
info("setAuthenticationFlow: " + aAuthId + " : " + aProvId);
this.authenticationFlowSet = { authId: aAuthId, provId: aProvId };
Services.obs.notifyObservers(null, "test-identity-auth-window", aAuthId);
};
runNextTest();
}
// Cleanup between tests
function resetState() {
delete window.gIdentitySelected;
delete IdentityService.IDP.authenticationFlowSet;
IdentityService.reset();
}
// Cleanup after all tests
function cleanUp() {
info("cleanup");
resetState();
for (let topic in gActiveObservers)
Services.obs.removeObserver(gActiveObservers[topic], topic);
for (let eventName in gActiveListeners)
PopupNotifications.panel.removeEventListener(eventName, gActiveListeners[eventName], false);
delete IdentityService.RP._rpFlows[outerWinId];
// Put the JSM functions back to how they were
IdentityService.IDP.setAuthenticationFlow = window.setAuthenticationFlow;
delete window.setAuthenticationFlow;
let sitw = {};
Components.utils.import("resource:///modules/SignInToWebsite.jsm", sitw);
sitw.SignInToWebsiteUX.selectIdentity = window.selectIdentity;
delete window.selectIdentity;
Services.prefs.clearUserPref("toolkit.identity.debug");
}
let gActiveListeners = {};
let gActiveObservers = {};
let gShownState = {};
function runNextTest() {
let nextTest = tests[gTestIndex];
function goNext() {
resetState();
if (++gTestIndex == tests.length)
executeSoon(test_auth);
else
executeSoon(runNextTest);
}
function addObserver(topic) {
function observer() {
Services.obs.removeObserver(observer, "PopupNotifications-" + topic);
delete gActiveObservers["PopupNotifications-" + topic];
info("[Test #" + gTestIndex + "] observer for " + topic + " called");
nextTest[topic]();
goNext();
}
Services.obs.addObserver(observer, "PopupNotifications-" + topic, false);
gActiveObservers["PopupNotifications-" + topic] = observer;
}
if (nextTest.backgroundShow) {
addObserver("backgroundShow");
} else if (nextTest.updateNotShowing) {
addObserver("updateNotShowing");
} else {
doOnPopupEvent("popupshowing", function () {
info("[Test #" + gTestIndex + "] popup showing");
});
doOnPopupEvent("popupshown", function () {
gShownState[gTestIndex] = true;
info("[Test #" + gTestIndex + "] popup shown");
nextTest.onShown(this);
});
// We allow multiple onHidden functions to be defined in an array. They're
// called in the order they appear.
let onHiddenArray = nextTest.onHidden instanceof Array ?
nextTest.onHidden :
[nextTest.onHidden];
doOnPopupEvent("popuphidden", function () {
if (!gShownState[gTestIndex]) {
// TODO: needed?
info("Popup from test " + gTestIndex + " was hidden before its popupshown fired");
}
let onHidden = onHiddenArray.shift();
info("[Test #" + gTestIndex + "] popup hidden (" + onHiddenArray.length + " hides remaining)");
executeSoon(function () {
onHidden.call(nextTest, this);
if (!onHiddenArray.length)
goNext();
}.bind(this));
}, onHiddenArray.length);
info("[Test #" + gTestIndex + "] added listeners; panel state: " + PopupNotifications.isPanelOpen);
}
info("[Test #" + gTestIndex + "] running test");
nextTest.run();
}
function doOnPopupEvent(eventName, callback, numExpected) {
gActiveListeners[eventName] = function (event) {
if (event.target != PopupNotifications.panel)
return;
if (typeof(numExpected) === "number")
numExpected--;
if (!numExpected) {
PopupNotifications.panel.removeEventListener(eventName, gActiveListeners[eventName], false);
delete gActiveListeners[eventName];
}
callback.call(PopupNotifications.panel);
};
PopupNotifications.panel.addEventListener(eventName, gActiveListeners[eventName], false);
}
function checkPopup(popup, notificationObj) {
info("[Test #" + gTestIndex + "] checking popup");
let notifications = popup.childNodes;
is(notifications.length, 1, "only one notification displayed");
let notification = notifications[0];
let icon = document.getAnonymousElementByAttribute(notification, "class", "popup-notification-icon");
is(notification.getAttribute("label"), notificationObj.message, "message matches");
is(notification.id, notificationObj.id + "-notification", "id matches");
if (notificationObj.id != "identity-request" && notificationObj.mainAction) {
is(notification.getAttribute("buttonlabel"), notificationObj.mainAction.label, "main action label matches");
is(notification.getAttribute("buttonaccesskey"), notificationObj.mainAction.accessKey, "main action accesskey matches");
}
let actualSecondaryActions = notification.childNodes;
let secondaryActions = notificationObj.secondaryActions || [];
let actualSecondaryActionsCount = actualSecondaryActions.length;
if (secondaryActions.length) {
let lastChild = actualSecondaryActions.item(actualSecondaryActions.length - 1);
is(lastChild.tagName, "menuseparator", "menuseparator exists");
actualSecondaryActionsCount--;
}
is(actualSecondaryActionsCount, secondaryActions.length, actualSecondaryActions.length + " secondary actions");
secondaryActions.forEach(function (a, i) {
is(actualSecondaryActions[i].getAttribute("label"), a.label, "label for secondary action " + i + " matches");
is(actualSecondaryActions[i].getAttribute("accesskey"), a.accessKey, "accessKey for secondary action " + i + " matches");
});
}
function triggerMainCommand(popup) {
info("[Test #" + gTestIndex + "] triggering main command");
let notifications = popup.childNodes;
ok(notifications.length > 0, "at least one notification displayed");
let notification = notifications[0];
// 20, 10 so that the inner button is hit
EventUtils.synthesizeMouse(notification.button, 20, 10, {});
}
function triggerSecondaryCommand(popup, index) {
info("[Test #" + gTestIndex + "] triggering secondary command");
let notifications = popup.childNodes;
ok(notifications.length > 0, "at least one notification displayed");
let notification = notifications[0];
notification.button.focus();
popup.addEventListener("popupshown", function () {
popup.removeEventListener("popupshown", arguments.callee, false);
// Press down until the desired command is selected
for (let i = 0; i <= index; i++)
EventUtils.synthesizeKey("VK_DOWN", {});
// Activate
EventUtils.synthesizeKey("VK_ENTER", {});
}, false);
// One down event to open the popup
EventUtils.synthesizeKey("VK_DOWN", { altKey: (navigator.platform.indexOf("Mac") == -1) });
}
function dismissNotification(popup) {
info("[Test #" + gTestIndex + "] dismissing notification");
executeSoon(function () {
EventUtils.synthesizeKey("VK_ESCAPE", {});
});
}
function partial(fn) {
let args = Array.prototype.slice.call(arguments, 1);
return function() {
return fn.apply(this, args.concat(Array.prototype.slice.call(arguments)));
};
}
// create a mock "doc" object, which the Identity Service
// uses as a pointer back into the doc object
function mock_doc(aIdentity, aOrigin, aDoFunc) {
let mockedDoc = {};
mockedDoc.id = outerWinId;
mockedDoc.loggedInEmail = aIdentity;
mockedDoc.origin = aOrigin;
mockedDoc['do'] = aDoFunc;
mockedDoc.doReady = partial(aDoFunc, 'ready');
mockedDoc.doLogin = partial(aDoFunc, 'login');
mockedDoc.doLogout = partial(aDoFunc, 'logout');
mockedDoc.doError = partial(aDoFunc, 'error');
mockedDoc.doCancel = partial(aDoFunc, 'cancel');
mockedDoc.doCoffee = partial(aDoFunc, 'coffee');
return mockedDoc;
}
// takes a list of functions and returns a function that
// when called the first time, calls the first func,
// then the next time the second, etc.
function call_sequentially() {
let numCalls = 0;
let funcs = arguments;
return function() {
if (!funcs[numCalls]) {
let argString = Array.prototype.slice.call(arguments).join(",");
ok(false, "Too many calls: " + argString);
return;
}
funcs[numCalls].apply(funcs[numCalls], arguments);
numCalls += 1;
};
}
function setupRPFlow(aIdentity) {
IdentityService.RP.watch(mock_doc(aIdentity, TEST_ORIGIN, call_sequentially(
function(action, params) {
is(action, "ready", "1st callback");
is(params, null);
},
function(action, params) {
is(action, "logout", "2nd callback");
is(params, null);
},
function(action, params) {
is(action, "ready", "3rd callback");
is(params, null);
}
)));
}
function WindowObserver(aCallback, aObserveTopic = "domwindowopened") {
this.observe = function(aSubject, aTopic, aData) {
if (aTopic != aObserveTopic) {
return;
}
info(aObserveTopic);
Services.ww.unregisterNotification(this);
SimpleTest.executeSoon(function() {
let domWin = aSubject.QueryInterface(Ci.nsIDOMWindow);
aCallback(domWin);
});
};
}

View File

@ -1240,6 +1240,10 @@ toolbar[iconsize="small"] #feed-button {
list-style-image: url(chrome://global/skin/icons/information-16.png);
}
#identity-notification-icon {
list-style-image: url(chrome://mozapps/skin/profile/profileicon.png);
}
#geo-notification-icon {
list-style-image: url(chrome://browser/skin/Geolocation-16.png);
}

View File

@ -2369,12 +2369,19 @@ toolbarbutton.chevron > .toolbarbutton-menu-dropmarker {
list-style-image: url(chrome://global/skin/icons/information-16.png);
}
#identity-notification-icon {
list-style-image: url(chrome://mozapps/skin/profile/profileicon.png);
}
#geo-notification-icon {
list-style-image: url(chrome://browser/skin/Geolocation-16.png);
}
.geolocation-text-link {
#notification-popup .text-link {
color: #fff;
}
.geolocation-text-link {
-moz-margin-start: 0; /* override default label margin to match description margin */
}

View File

@ -2375,6 +2375,10 @@ toolbarbutton.bookmark-item[dragover="true"][open="true"] {
list-style-image: url(chrome://global/skin/icons/information-16.png);
}
#identity-notification-icon {
list-style-image: url(chrome://mozapps/skin/profile/profileicon.png);
}
#geo-notification-icon {
list-style-image: url(chrome://browser/skin/Geolocation-16.png);
}

View File

@ -210,13 +210,13 @@ IdentityRelyingParty.prototype = {
*/
request: function request(aRPId, aOptions) {
log("request: rpId:", aRPId);
let rp = this._rpFlows[aRPId];
// Notify UX to display identity picker.
// Pass the doc id to UX so it can pass it back to us later.
let options = {rpId: aRPId};
let options = {rpId: aRPId, origin: rp.origin};
// Append URLs after resolving
let rp = this._rpFlows[aRPId];
let baseURI = Services.io.newURI(rp.origin, null, null);
for (let optionName of ["privacyPolicy", "termsOfService"]) {
if (aOptions[optionName]) {

View File

@ -217,7 +217,7 @@ function WindowObserver(aCallback) {
let domWin = aSubject.QueryInterface(Ci.nsIDOMWindow);
ok(!domWin, "No window should be opened");
SimpleTest.executesoon(function() {
SimpleTest.executeSoon(function() {
info("Closing opened window");
domWin.close();
aCallback();

View File

@ -29,6 +29,17 @@ const Services = Cu.import("resource://gre/modules/Services.jsm").Services;
SpecialPowers.setBoolPref("toolkit.identity.debug", true);
SpecialPowers.setBoolPref("dom.identity.enabled", true);
// Shutdown the UX if it exists so that it won't interfere with tests by also responding to
// observer notifications.
try {
const SignInToWebsiteUX = Cu.import("resource:///modules/SignInToWebsite.jsm").SignInToWebsiteUX;
if (SignInToWebsiteUX) {
SignInToWebsiteUX.uninit();
}
} catch (ex) {
// The module doesn't exist
}
const jwcrypto = Cu.import("resource://gre/modules/identity/jwcrypto.jsm").jwcrypto;
const IdentityStore = Cu.import("resource://gre/modules/identity/IdentityStore.jsm").IdentityStore;
const RelyingParty = Cu.import("resource://gre/modules/identity/RelyingParty.jsm").RelyingParty;
@ -175,6 +186,15 @@ function cleanup() {
resetState();
SpecialPowers.clearUserPref("toolkit.identity.debug");
SpecialPowers.clearUserPref("dom.identity.enabled");
// Re-init the UX that we uninit
try {
const SignInToWebsiteUX = Cu.import("resource:///modules/SignInToWebsite.jsm").SignInToWebsiteUX;
if (SignInToWebsiteUX) {
SignInToWebsiteUX.init();
}
} catch (ex) {
// The module doesn't exist
}
}
var TESTS = [];

View File

@ -29,7 +29,7 @@ SimpleTest.waitForExplicitFinish();
const DOMIdentity = Cu.import("resource://gre/modules/DOMIdentity.jsm")
.DOMIdentity;
let outerWinId = window.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDOMWindowUtils).outerWindowID;
.getInterface(Ci.nsIDOMWindowUtils).outerWindowID;
// Reset the DOM state then run the next test
function run_next_rp_test() {