Bug 764213 - Provisional desktop UI for website sign-in with Persona. r=dolske
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Tue, 12 Jun 2012 18:16:00 -0700
changeset 102284 59707ed19e48846651a27b53afbd308058024a4c
parent 102283 2d595c62abcb188f208ad7bf6703a882a826fbb6
child 102285 13d2ad44ba747fd47058082294b768b4ac72a556
push id13402
push usermozilla@noorenberghe.ca
push dateTue, 14 Aug 2012 10:01:39 +0000
treeherdermozilla-inbound@59707ed19e48 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdolske
bugs764213
milestone17.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 764213 - Provisional desktop UI for website sign-in with Persona. r=dolske
browser/base/content/browser.css
browser/base/content/browser.xul
browser/base/content/test/browser_popupNotification.js
browser/base/content/urlbarBindings.xml
browser/components/nsBrowserGlue.js
browser/locales/en-US/chrome/browser/browser.properties
browser/modules/Makefile.in
browser/modules/SignInToWebsite.jsm
browser/modules/test/Makefile.in
browser/modules/test/browser_SignInToWebsite.js
browser/themes/gnomestripe/browser.css
browser/themes/pinstripe/browser.css
browser/themes/winstripe/browser.css
toolkit/identity/RelyingParty.jsm
toolkit/identity/tests/chrome/test_sandbox.xul
toolkit/identity/tests/mochitest/head_identity.js
toolkit/identity/tests/mochitest/test_relying_party.html
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -454,28 +454,37 @@ window[chromehidden~="toolbar"] toolbar:
 .notification-anchor-icon {
   -moz-user-focus: normal;
 }
 
 .notification-anchor-icon:not([showing]) {
   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;
 }
 
 #geolocation-notification {
   -moz-binding: url("chrome://browser/content/urlbarBindings.xml#geolocation-notification");
 }
 
 #addon-progress-notification {
   -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 {
   display: -moz-box;
 }
 
 /* Remove the resizer from the statusbar compatibility shim */
 #status-bar[hideresizer] > .statusbar-resizerpanel {
--- a/browser/base/content/browser.xul
+++ b/browser/base/content/browser.xul
@@ -559,16 +559,17 @@
                  oninput="gBrowser.userTypedValue = this.value;"
                  ontextentered="this.handleCommand(param);"
                  ontextreverted="return this.handleRevert();"
                  pageproxystate="invalid"
                  onfocus="document.getElementById('identity-box').style.MozUserFocus= 'normal'"
                  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"/>
             <image id="password-notification-icon" class="notification-anchor-icon" role="button"/>
             <image id="webapps-notification-icon" class="notification-anchor-icon" role="button"/>
             <image id="plugins-notification-icon" class="notification-anchor-icon" role="button"/>
           </box>
           <!-- Use onclick instead of normal popup= syntax since the popup
--- a/browser/base/content/test/browser_popupNotification.js
+++ b/browser/base/content/test/browser_popupNotification.js
@@ -73,17 +73,17 @@ function runNextTest() {
       }
 
       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();
 }
 
--- a/browser/base/content/urlbarBindings.xml
+++ b/browser/base/content/urlbarBindings.xml
@@ -1170,16 +1170,271 @@
       <method name="onDownloadEnded">
         <body><![CDATA[
           this.updateProgress();
         ]]></body>
       </method>
     </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"
                 class="splitmenu-menuitem"
                 xbl:inherits="iconic,label,disabled,onclick=oncommand,_moz-menuactive=active"/>
       <xul:menu anonid="menu" class="splitmenu-menu"
                 xbl:inherits="disabled,_moz-menuactive=active"
                 oncommand="event.stopPropagation();">
--- a/browser/components/nsBrowserGlue.js
+++ b/browser/components/nsBrowserGlue.js
@@ -7,30 +7,31 @@ const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cr = Components.results;
 const Cu = Components.utils;
 
 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");
 
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
                                   "resource://gre/modules/PlacesUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "BookmarkHTMLUtils",
                                   "resource://gre/modules/BookmarkHTMLUtils.jsm");
 
-XPCOMUtils.defineLazyModuleGetter(this, "webappsUI", 
+XPCOMUtils.defineLazyModuleGetter(this, "webappsUI",
                                   "resource:///modules/webappsUI.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "PageThumbs",
                                   "resource:///modules/PageThumbs.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "PdfJs",
                                   "resource://pdf.js/PdfJs.jsm");
 
@@ -303,16 +304,17 @@ BrowserGlue.prototype = {
       this._idleService.removeIdleObserver(this, BOOKMARKS_BACKUP_IDLE_TIME);
     if (this._isPlacesInitObserver)
       os.removeObserver(this, "places-init-complete");
     if (this._isPlacesLockedObserver)
       os.removeObserver(this, "places-database-locked");
     if (this._isPlacesShutdownObserver)
       os.removeObserver(this, "places-shutdown");
     webappsUI.uninit();
+    SignInToWebsiteUX.uninit();
   },
 
   _onAppDefaults: function BG__onAppDefaults() {
     // apply distribution customizations (prefs)
     // other customizations are applied in _onProfileStartup()
     this._distributionCustomizer.applyPrefDefaults();
   },
 
@@ -332,16 +334,18 @@ BrowserGlue.prototype = {
     // handle any UI migration
     this._migrateUI();
 
     // Initialize webapps UI
     webappsUI.init();
 
     PageThumbs.init();
 
+    SignInToWebsiteUX.init();
+
     PdfJs.init();
 
     Services.obs.notifyObservers(null, "browser-ui-startup-complete", "");
   },
 
   // the first browser window has finished initializing
   _onFirstWindowLoaded: function BG__onFirstWindowLoaded() {
 #ifdef XP_WIN
--- a/browser/locales/en-US/chrome/browser/browser.properties
+++ b/browser/locales/en-US/chrome/browser/browser.properties
@@ -378,8 +378,26 @@ social.enable.label=%S integration
 social.enable.accesskey=n
 
 # LOCALIZATION NOTE (social.remove.label): %S = brandShortName
 social.remove.label=Remove from %S
 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
--- a/browser/modules/Makefile.in
+++ b/browser/modules/Makefile.in
@@ -13,21 +13,22 @@ include $(topsrcdir)/config/config.mk
 
 TEST_DIRS += test
 
 EXTRA_JS_MODULES = \
 	openLocationLastURL.jsm \
 	NetworkPrioritizer.jsm \
 	NewTabUtils.jsm \
 	offlineAppCache.jsm \
+	SignInToWebsite.jsm \
 	TelemetryTimestamps.jsm \
 	Social.jsm \
 	webappsUI.jsm \
 	$(NULL)
 
-ifeq ($(MOZ_WIDGET_TOOLKIT),windows) 
+ifeq ($(MOZ_WIDGET_TOOLKIT),windows)
 EXTRA_JS_MODULES += \
 	WindowsPreviewPerTab.jsm \
 	WindowsJumpLists.jsm \
 	$(NULL)
 endif
 
 include $(topsrcdir)/config/rules.mk
new file mode 100644
--- /dev/null
+++ b/browser/modules/SignInToWebsite.jsm
@@ -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);
+  },
+
+};
--- a/browser/modules/test/Makefile.in
+++ b/browser/modules/test/Makefile.in
@@ -9,18 +9,19 @@ VPATH		= @srcdir@
 relativesrcdir  = @relativesrcdir@
 
 include $(DEPTH)/config/autoconf.mk
 include $(topsrcdir)/config/rules.mk
 
 _BROWSER_FILES = \
                  browser_NetworkPrioritizer.js \
                  browser_TelemetryTimestamps.js \
+                 browser_SignInToWebsite.js \
                  $(NULL)
 
-ifeq ($(MOZ_WIDGET_TOOLKIT),windows) 
+ifeq ($(MOZ_WIDGET_TOOLKIT),windows)
 _BROWSER_FILES += \
                  browser_taskbar_preview.js \
                  $(NULL)
 endif
 
 libs::	$(_BROWSER_FILES)
 	$(INSTALL) $(foreach f,$^,"$f") $(DEPTH)/_tests/testing/mochitest/browser/$(relativesrcdir)
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/browser_SignInToWebsite.js
@@ -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);
+    });
+  };
+}
--- a/browser/themes/gnomestripe/browser.css
+++ b/browser/themes/gnomestripe/browser.css
@@ -1235,16 +1235,20 @@ toolbar[iconsize="small"] #feed-button {
 .notification-anchor-icon:-moz-focusring {
   outline: 1px dotted -moz-DialogText;
 }
 
 #default-notification-icon {
   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);
 }
 
 #addons-notification-icon {
   list-style-image: url(chrome://mozapps/skin/extensions/extensionGeneric-16.png);
 }
 
--- a/browser/themes/pinstripe/browser.css
+++ b/browser/themes/pinstripe/browser.css
@@ -2364,22 +2364,29 @@ toolbarbutton.chevron > .toolbarbutton-m
   box-shadow: 0 0 2px 1px -moz-mac-focusring inset,
               0 0 3px 2px -moz-mac-focusring;
 }
 
 #default-notification-icon {
   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);
 }
 
+#notification-popup .text-link {
+  color: #fff;
+}
+
 .geolocation-text-link {
-  color: #fff;
   -moz-margin-start: 0; /* override default label margin to match description margin */
 }
 
 .telemetry-text-link {
   color: #fff;
 }
 
 #addons-notification-icon {
--- a/browser/themes/winstripe/browser.css
+++ b/browser/themes/winstripe/browser.css
@@ -2370,16 +2370,20 @@ toolbarbutton.bookmark-item[dragover="tr
   outline: 1px dotted -moz-DialogText;
   outline-offset: -3px;
 }
 
 #default-notification-icon {
   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);
 }
 
 #addons-notification-icon {
   list-style-image: url(chrome://mozapps/skin/extensions/extensionGeneric-16.png);
 }
 
--- a/toolkit/identity/RelyingParty.jsm
+++ b/toolkit/identity/RelyingParty.jsm
@@ -205,23 +205,23 @@ IdentityRelyingParty.prototype = {
    * @param aRPId
    *        (integer)  the id of the doc object obtained in .watch()
    *
    * @param aOptions
    *        (Object)  options including privacyPolicy, termsOfService
    */
   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]) {
         options[optionName] = baseURI.resolve(aOptions[optionName]);
       }
     }
 
     Services.obs.notifyObservers({wrappedJSObject: options}, "identity-request", null);
--- a/toolkit/identity/tests/chrome/test_sandbox.xul
+++ b/toolkit/identity/tests/chrome/test_sandbox.xul
@@ -212,17 +212,17 @@ function WindowObserver(aCallback) {
   this.observe = function(aSubject, aTopic, aData) {
     if (aTopic != "domwindowopened") {
       return;
     }
     Services.ww.unregisterNotification(this);
 
     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();
     });
   }
 }
 
 // Can the sandbox call window.alert() or popup other UI?
--- a/toolkit/identity/tests/mochitest/head_identity.js
+++ b/toolkit/identity/tests/mochitest/head_identity.js
@@ -24,16 +24,27 @@ const TEST_IDPPARAMS = {
 };
 
 const Services = Cu.import("resource://gre/modules/Services.jsm").Services;
 
 // Set the debug pref before loading other modules
 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;
 const XPCOMUtils = Cu.import("resource://gre/modules/XPCOMUtils.jsm").XPCOMUtils;
 const IDService = Cu.import("resource://gre/modules/identity/Identity.jsm").IdentityService;
 const IdentityProvider = Cu.import("resource://gre/modules/identity/IdentityProvider.jsm").IdentityProvider;
 
 const identity = navigator.id || navigator.mozId;
@@ -170,16 +181,25 @@ function setup_provisioning(identity, af
 function resetState() {
   IDService.reset();
 }
 
 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 = [];
 
 function run_next_test() {
   if (!identity) {
     todo(false, "DOM API is not available. Skipping tests.");
     cleanup();
--- a/toolkit/identity/tests/mochitest/test_relying_party.html
+++ b/toolkit/identity/tests/mochitest/test_relying_party.html
@@ -24,17 +24,17 @@ Test of Relying Party (RP) using the DOM
 
 "use strict";
 
 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() {
   let rpContext = RelyingParty._rpFlows[outerWinId];
   if (rpContext) {
     makeObserver("identity-DOM-state-reset", function() {
       SimpleTest.executeSoon(run_next_test);
     });