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 102298 59707ed19e48846651a27b53afbd308058024a4c
parent 102297 2d595c62abcb188f208ad7bf6703a882a826fbb6
child 102299 13d2ad44ba747fd47058082294b768b4ac72a556
push id1
push userroot
push dateMon, 20 Oct 2014 17:29:22 +0000
reviewersdolske
bugs764213
milestone17.0a1
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);
     });