Bug 1047164 - Handle authentication errors (e.g. token expiry) for FxA Loop sessions and notify users. r=jaws
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Fri, 03 Oct 2014 03:17:32 -0700
changeset 231861 003a858d0fe7fb6dff5ff7a35b26cee9520e7640
parent 231860 849c7d93fba19c5aa7f336027e8a8c0e868a562e
child 231862 cb6a72e1fd95fb615ccb77c5cb1f4be3799cd3c4
push id4187
push userbhearsum@mozilla.com
push dateFri, 28 Nov 2014 15:29:12 +0000
treeherdermozilla-beta@f23cc6a30c11 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjaws
bugs1047164
milestone35.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 1047164 - Handle authentication errors (e.g. token expiry) for FxA Loop sessions and notify users. r=jaws
browser/components/loop/MozLoopAPI.jsm
browser/components/loop/MozLoopService.jsm
browser/components/loop/content/js/client.js
browser/components/loop/content/js/panel.js
browser/components/loop/content/js/panel.jsx
browser/components/loop/content/shared/css/common.css
browser/components/loop/content/shared/js/models.js
browser/components/loop/content/shared/js/views.js
browser/components/loop/content/shared/js/views.jsx
browser/components/loop/test/desktop-local/client_test.js
browser/components/loop/test/desktop-local/panel_test.js
browser/components/loop/test/mochitest/browser_fxa_login.js
browser/components/loop/test/mochitest/head.js
browser/components/loop/test/xpcshell/head.js
browser/components/loop/test/xpcshell/test_loopservice_hawk_errors.js
browser/components/loop/test/xpcshell/xpcshell.ini
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -139,16 +139,38 @@ function injectLoopAPI(targetWindow) {
       get: function() {
         return MozLoopService.doNotDisturb;
       },
       set: function(aFlag) {
         MozLoopService.doNotDisturb = aFlag;
       }
     },
 
+    errors: {
+      enumerable: true,
+      get: function() {
+        let errors = {};
+        for (let [type, error] of MozLoopService.errors) {
+          // if error.error is an nsIException, just delete it since it's hard
+          // to clone across the boundary.
+          if (error.error instanceof Ci.nsIException) {
+            MozLoopService.log.debug("Warning: Some errors were omitted from MozLoopAPI.errors " +
+                                     "due to issues copying nsIException across boundaries.",
+                                     error.error);
+            delete error.error;
+          }
+
+          // We have to clone the error property since it may be an Error object.
+          errors[type] = Cu.cloneInto(error, targetWindow);
+
+        }
+        return Cu.cloneInto(errors, targetWindow);
+      },
+    },
+
     /**
      * Returns the current locale of the browser.
      *
      * @returns {String} The locale string
      */
     locale: {
       enumerable: true,
       get: function() {
@@ -508,19 +530,19 @@ function injectLoopAPI(targetWindow) {
           try {
             appVersionInfo = Cu.cloneInto({
               channel: defaults.getCharPref("app.update.channel"),
               version: appInfo.version,
               OS: appInfo.OS
             }, targetWindow);
           } catch (ex) {
             // only log outside of xpcshell to avoid extra message noise
-            if (typeof window !== 'undefined' && "console" in window) {
-              console.log("Failed to construct appVersionInfo; if this isn't " +
-                          "an xpcshell unit test, something is wrong", ex);
+            if (typeof targetWindow !== 'undefined' && "console" in targetWindow) {
+              MozLoopService.log.error("Failed to construct appVersionInfo; if this isn't " +
+                                       "an xpcshell unit test, something is wrong", ex);
             }
           }
         }
         return appVersionInfo;
       }
     },
 
     /**
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -1,15 +1,15 @@
 /* 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 { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 // Invalid auth token as per
 // https://github.com/mozilla-services/loop-server/blob/45787d34108e2f0d87d74d4ddf4ff0dbab23501c/loop/errno.json#L6
 const INVALID_AUTH_TOKEN = 110;
 
 // Ticket numbers are 24 bits in length.
 // The highest valid ticket number is 16777214 (2^24 - 2), so that a "now
 // serving" number of 2^24 - 1 is greater than it.
@@ -325,21 +325,70 @@ let MozLoopServiceInternal = {
   },
 
   notifyStatusChanged: function(aReason = null) {
     log.debug("notifyStatusChanged with reason:", aReason);
     Services.obs.notifyObservers(null, "loop-status-changed", aReason);
   },
 
   /**
+   * Record an error and notify interested UI with the relevant user-facing strings attached.
+   *
    * @param {String} errorType a key to identify the type of error. Only one
-   *                           error of a type will be saved at a time.
+   *                           error of a type will be saved at a time. This value may be used to
+   *                           determine user-facing (aka. friendly) strings.
    * @param {Object} error     an object describing the error in the format from Hawk errors
    */
   setError: function(errorType, error) {
+    let messageString, detailsString, detailsButtonLabelString;
+    const NETWORK_ERRORS = [
+      Cr.NS_ERROR_CONNECTION_REFUSED,
+      Cr.NS_ERROR_NET_INTERRUPT,
+      Cr.NS_ERROR_NET_RESET,
+      Cr.NS_ERROR_NET_TIMEOUT,
+      Cr.NS_ERROR_OFFLINE,
+      Cr.NS_ERROR_PROXY_CONNECTION_REFUSED,
+      Cr.NS_ERROR_UNKNOWN_HOST,
+      Cr.NS_ERROR_UNKNOWN_PROXY_HOST,
+    ];
+
+    if (error.code === null && error.errno === null &&
+        error.error instanceof Ci.nsIException &&
+        NETWORK_ERRORS.indexOf(error.error.result) != -1) {
+      // Network error. Override errorType so we can easily clear it on the next succesful request.
+      errorType = "network";
+      messageString = "could_not_connect";
+      detailsString = "check_internet_connection";
+      detailsButtonLabelString = "retry_button";
+    } else if (errorType == "profile" && error.code >= 500 && error.code < 600) {
+      messageString = "problem_accessing_account";
+    } else if (error.code == 401) {
+      if (errorType == "login") {
+        messageString = "could_not_authenticate"; // XXX: Bug 1076377
+        detailsString = "password_changed_question";
+        detailsButtonLabelString = "retry_button";
+      } else {
+        messageString = "session_expired_error_description";
+      }
+    } else if (error.code >= 500 && error.code < 600) {
+      messageString = "service_not_available";
+      detailsString = "try_again_later";
+      detailsButtonLabelString = "retry_button";
+    } else {
+      messageString = "generic_failure_title";
+    }
+
+    error.friendlyMessage = this.localizedStrings[messageString].textContent;
+    error.friendlyDetails = detailsString ?
+                              this.localizedStrings[detailsString].textContent :
+                              null;
+    error.friendlyDetailsButtonLabel = detailsButtonLabelString ?
+                                         this.localizedStrings[detailsButtonLabelString].textContent :
+                                         null;
+
     gErrors.set(errorType, error);
     this.notifyStatusChanged();
   },
 
   clearError: function(errorType) {
     gErrors.delete(errorType);
     this.notifyStatusChanged();
   },
@@ -406,17 +455,40 @@ let MozLoopServiceInternal = {
 
     let credentials;
     if (sessionToken) {
       // true = use a hex key, as required by the server (see bug 1032738).
       credentials = deriveHawkCredentials(sessionToken, "sessionToken",
                                           2 * 32, true);
     }
 
-    return gHawkClient.request(path, method, credentials, payloadObj);
+    return gHawkClient.request(path, method, credentials, payloadObj).then((result) => {
+      this.clearError("network");
+      return result;
+    }, (error) => {
+      if (error.code == 401) {
+        this.clearSessionToken(sessionType);
+
+        if (sessionType == LOOP_SESSION_TYPE.FXA) {
+          MozLoopService.logOutFromFxA().then(() => {
+            // Set a user-visible error after logOutFromFxA clears existing ones.
+            this.setError("login", error);
+          });
+        } else {
+          if (!this.urlExpiryTimeIsInFuture()) {
+            // If there are no Guest URLs in the future, don't use setError to notify the user since
+            // there isn't a need for a Guest registration at this time.
+            throw error;
+          }
+
+          this.setError("registration", error);
+        }
+      }
+      throw error;
+    });
   },
 
   /**
    * Generic hawkRequest onError handler for the hawkRequest promise.
    *
    * @param {Object} error - error reporting object
    *
    */
@@ -529,31 +601,23 @@ let MozLoopServiceInternal = {
         if (!this.storeSessionToken(sessionType, response.headers))
           return;
 
         log.debug("Successfully registered with server for sessionType", sessionType);
         this.clearError("registration");
       }, (error) => {
         // There's other errors than invalid auth token, but we should only do the reset
         // as a last resort.
-        if (error.code === 401 && error.errno === INVALID_AUTH_TOKEN) {
-          if (this.urlExpiryTimeIsInFuture()) {
-            // XXX Should this be reported to the user is a visible manner?
-            Cu.reportError("Loop session token is invalid, all previously "
-                           + "generated urls will no longer work.");
-          }
-
+        if (error.code === 401) {
           // Authorization failed, invalid token, we need to try again with a new token.
-          this.clearSessionToken(sessionType);
           if (retry) {
             return this.registerWithLoopServer(sessionType, pushUrl, false);
           }
         }
 
-        // XXX Bubble the precise details up to the UI somehow (bug 1013248).
         log.error("Failed to register with the loop server. Error: ", error);
         this.setError("registration", error);
         throw error;
       }
     );
   },
 
   /**
@@ -563,26 +627,31 @@ let MozLoopServiceInternal = {
    * guest session with the device.
    *
    * @param {LOOP_SESSION_TYPE} sessionType The type of session e.g. guest or FxA
    * @param {String} pushURL The push URL previously given by the push server.
    *                         This may not be necessary to unregister in the future.
    * @return {Promise} resolving when the unregistration request finishes
    */
   unregisterFromLoopServer: function(sessionType, pushURL) {
+    let prefType = Services.prefs.getPrefType(this.getSessionTokenPrefName(sessionType));
+    if (prefType == Services.prefs.PREF_INVALID) {
+      return Promise.resolve("already unregistered");
+    }
+
     let unregisterURL = "/registration?simplePushURL=" + encodeURIComponent(pushURL);
     return this.hawkRequest(sessionType, unregisterURL, "DELETE")
       .then(() => {
         log.debug("Successfully unregistered from server for sessionType", sessionType);
         MozLoopServiceInternal.clearSessionToken(sessionType);
       },
       error => {
         // Always clear the registration token regardless of whether the server acknowledges the logout.
         MozLoopServiceInternal.clearSessionToken(sessionType);
-        if (error.code === 401 && error.errno === INVALID_AUTH_TOKEN) {
+        if (error.code === 401) {
           // Authorization failed, invalid token. This is fine since it may mean we already logged out.
           return;
         }
 
         log.error("Failed to unregister with the loop server. Error: ", error);
         throw error;
       });
   },
@@ -1112,16 +1181,17 @@ this.MozLoopService = {
    * with the Loop server. It will return early if already registered.
    *
    * @param {Object} mockPushHandler Optional, test-only mock push handler. Used
    *                                 to allow mocking of the MozLoopPushHandler.
    * @returns {Promise} a promise that is resolved with no params on completion, or
    *          rejected with an error code or string.
    */
   register: function(mockPushHandler, mockWebSocket) {
+    log.debug("registering");
     // Don't do anything if loop is not enabled.
     if (!Services.prefs.getBoolPref("loop.enabled")) {
       throw new Error("Loop is not enabled");
     }
 
     if (Services.prefs.getBoolPref("loop.throttled")) {
       throw new Error("Loop is disabled by the soft-start mechanism");
     }
@@ -1191,16 +1261,20 @@ this.MozLoopService = {
   get userProfile() {
     return gFxAOAuthProfile;
   },
 
   get errors() {
     return MozLoopServiceInternal.errors;
   },
 
+  get log() {
+    return log;
+  },
+
   /**
    * Returns the current locale
    *
    * @return {String} The code of the current locale.
    */
   get locale() {
     try {
       return Services.prefs.getComplexValue("general.useragent.locale",
@@ -1326,62 +1400,75 @@ this.MozLoopService = {
       return tokenData;
     }).then(tokenData => {
       return gRegisteredDeferred.promise.then(Task.async(function*() {
         if (gPushHandler.pushUrl) {
           yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, gPushHandler.pushUrl);
         } else {
           throw new Error("No pushUrl for FxA registration");
         }
+        MozLoopServiceInternal.clearError("login");
+        MozLoopServiceInternal.clearError("profile");
         return gFxAOAuthTokenData;
       }));
     }).then(tokenData => {
       let client = new FxAccountsProfileClient({
         serverURL: gFxAOAuthClient.parameters.profile_uri,
         token: tokenData.access_token
       });
       client.fetchProfile().then(result => {
         gFxAOAuthProfile = result;
         MozLoopServiceInternal.notifyStatusChanged("login");
       }, error => {
         log.error("Failed to retrieve profile", error);
+        this.setError("profile", error);
         gFxAOAuthProfile = null;
         MozLoopServiceInternal.notifyStatusChanged();
       });
       return tokenData;
     }).catch(error => {
       gFxAOAuthTokenData = null;
       gFxAOAuthProfile = null;
       throw error;
+    }).catch((error) => {
+      MozLoopServiceInternal.setError("login", error);
+      // Re-throw for testing
+      throw error;
     });
   },
 
   /**
    * Logs the user out from FxA.
    *
    * Gracefully handles if the user is already logged out.
    *
    * @return {Promise} that resolves when the FxA logout flow is complete.
    */
   logOutFromFxA: Task.async(function*() {
     log.debug("logOutFromFxA");
-    yield MozLoopServiceInternal.unregisterFromLoopServer(LOOP_SESSION_TYPE.FXA,
-                                                          gPushHandler.pushUrl);
+    if (gPushHandler && gPushHandler.pushUrl) {
+      yield MozLoopServiceInternal.unregisterFromLoopServer(LOOP_SESSION_TYPE.FXA,
+                                                            gPushHandler.pushUrl);
+    } else {
+      MozLoopServiceInternal.clearSessionToken(LOOP_SESSION_TYPE.FXA);
+    }
 
     gFxAOAuthTokenData = null;
     gFxAOAuthProfile = null;
 
     // Reset the client since the initial promiseFxAOAuthParameters() call is
     // what creates a new session.
     gFxAOAuthClient = null;
     gFxAOAuthClientPromise = null;
 
     // clearError calls notifyStatusChanged so should be done last when the
     // state is clean.
     MozLoopServiceInternal.clearError("registration");
+    MozLoopServiceInternal.clearError("login");
+    MozLoopServiceInternal.clearError("profile");
   }),
 
   openFxASettings: function() {
     let url = new URL("/settings", gFxAOAuthClient.parameters.content_uri);
     let win = Services.wm.getMostRecentWindow("navigator:browser");
     win.switchToTabHavingURI(url.toString(), true);
   },
 
--- a/browser/components/loop/content/js/client.js
+++ b/browser/components/loop/content/js/client.js
@@ -73,17 +73,17 @@ loop.Client = (function($) {
      * Generic handler for XHR failures.
      *
      * @param {Function} cb Callback(err)
      * @param {Object} error See MozLoopAPI.hawkRequest
      */
     _failureHandler: function(cb, error) {
       var message = "HTTP " + error.code + " " + error.error + "; " + error.message;
       console.error(message);
-      cb(new Error(message));
+      cb(error);
     },
 
     /**
      * Ensures the client is registered with the push server.
      *
      * Callback parameters:
      * - err null on successful registration, non-null otherwise.
      *
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -307,20 +307,22 @@ loop.panel = (function(_, mozL10n) {
      */
     _fetchCallUrl: function() {
       this.setState({pending: true});
       this.props.client.requestCallUrl(this.conversationIdentifier(),
                                        this._onCallUrlReceived);
     },
 
     _onCallUrlReceived: function(err, callUrlData) {
-      this.props.notifications.reset();
-
       if (err) {
-        this.props.notifications.errorL10n("unable_retrieve_url");
+        if (err.code != 401) {
+          // 401 errors are already handled in hawkRequest and show an error
+          // message about the session.
+          this.props.notifications.errorL10n("unable_retrieve_url");
+        }
         this.setState(this.getInitialState());
       } else {
         try {
           var callUrl = new window.URL(callUrlData.callUrl);
           // XXX the current server vers does not implement the callToken field
           // but it exists in the API. This workaround should be removed in the future
           var token = callUrlData.callToken ||
                       callUrl.pathname.split('/').pop();
@@ -440,35 +442,67 @@ loop.panel = (function(_, mozL10n) {
     },
 
     getInitialState: function() {
       return {
         userProfile: this.props.userProfile || navigator.mozLoop.userProfile,
       };
     },
 
-    _onAuthStatusChange: function() {
+    _serviceErrorToShow: function() {
+      if (!navigator.mozLoop.errors || !Object.keys(navigator.mozLoop.errors).length) {
+        return null;
+      }
+      // Just get the first error for now since more than one should be rare.
+      var firstErrorKey = Object.keys(navigator.mozLoop.errors)[0];
+      return {
+        type: firstErrorKey,
+        error: navigator.mozLoop.errors[firstErrorKey],
+      };
+    },
+
+    updateServiceErrors: function() {
+      var serviceError = this._serviceErrorToShow();
+      if (serviceError) {
+        this.props.notifications.set({
+          id: "service-error",
+          level: "error",
+          message: serviceError.error.friendlyMessage,
+          details: serviceError.error.friendlyDetails,
+          detailsButtonLabel: serviceError.error.friendlyDetailsButtonLabel,
+        });
+      } else {
+        this.props.notifications.remove(this.props.notifications.get("service-error"));
+      }
+    },
+
+    _onStatusChanged: function() {
       this.setState({userProfile: navigator.mozLoop.userProfile});
+      this.updateServiceErrors();
     },
 
     startForm: function(name, contact) {
       this.refs[name].initForm(contact);
       this.selectTab(name);
     },
 
     selectTab: function(name) {
       this.refs.tabView.setState({ selectedTab: name });
     },
 
+    componentWillMount: function() {
+      this.updateServiceErrors();
+    },
+
     componentDidMount: function() {
-      window.addEventListener("LoopStatusChanged", this._onAuthStatusChange);
+      window.addEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
     componentWillUnmount: function() {
-      window.removeEventListener("LoopStatusChanged", this._onAuthStatusChange);
+      window.removeEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
     render: function() {
       var NotificationListView = sharedViews.NotificationListView;
       var displayName = this.state.userProfile && this.state.userProfile.email ||
                         __("display_name_guest");
       return (
         React.DOM.div(null, 
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -307,20 +307,22 @@ loop.panel = (function(_, mozL10n) {
      */
     _fetchCallUrl: function() {
       this.setState({pending: true});
       this.props.client.requestCallUrl(this.conversationIdentifier(),
                                        this._onCallUrlReceived);
     },
 
     _onCallUrlReceived: function(err, callUrlData) {
-      this.props.notifications.reset();
-
       if (err) {
-        this.props.notifications.errorL10n("unable_retrieve_url");
+        if (err.code != 401) {
+          // 401 errors are already handled in hawkRequest and show an error
+          // message about the session.
+          this.props.notifications.errorL10n("unable_retrieve_url");
+        }
         this.setState(this.getInitialState());
       } else {
         try {
           var callUrl = new window.URL(callUrlData.callUrl);
           // XXX the current server vers does not implement the callToken field
           // but it exists in the API. This workaround should be removed in the future
           var token = callUrlData.callToken ||
                       callUrl.pathname.split('/').pop();
@@ -440,35 +442,67 @@ loop.panel = (function(_, mozL10n) {
     },
 
     getInitialState: function() {
       return {
         userProfile: this.props.userProfile || navigator.mozLoop.userProfile,
       };
     },
 
-    _onAuthStatusChange: function() {
+    _serviceErrorToShow: function() {
+      if (!navigator.mozLoop.errors || !Object.keys(navigator.mozLoop.errors).length) {
+        return null;
+      }
+      // Just get the first error for now since more than one should be rare.
+      var firstErrorKey = Object.keys(navigator.mozLoop.errors)[0];
+      return {
+        type: firstErrorKey,
+        error: navigator.mozLoop.errors[firstErrorKey],
+      };
+    },
+
+    updateServiceErrors: function() {
+      var serviceError = this._serviceErrorToShow();
+      if (serviceError) {
+        this.props.notifications.set({
+          id: "service-error",
+          level: "error",
+          message: serviceError.error.friendlyMessage,
+          details: serviceError.error.friendlyDetails,
+          detailsButtonLabel: serviceError.error.friendlyDetailsButtonLabel,
+        });
+      } else {
+        this.props.notifications.remove(this.props.notifications.get("service-error"));
+      }
+    },
+
+    _onStatusChanged: function() {
       this.setState({userProfile: navigator.mozLoop.userProfile});
+      this.updateServiceErrors();
     },
 
     startForm: function(name, contact) {
       this.refs[name].initForm(contact);
       this.selectTab(name);
     },
 
     selectTab: function(name) {
       this.refs.tabView.setState({ selectedTab: name });
     },
 
+    componentWillMount: function() {
+      this.updateServiceErrors();
+    },
+
     componentDidMount: function() {
-      window.addEventListener("LoopStatusChanged", this._onAuthStatusChange);
+      window.addEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
     componentWillUnmount: function() {
-      window.removeEventListener("LoopStatusChanged", this._onAuthStatusChange);
+      window.removeEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
     render: function() {
       var NotificationListView = sharedViews.NotificationListView;
       var displayName = this.state.userProfile && this.state.userProfile.email ||
                         __("display_name_guest");
       return (
         <div>
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -93,16 +93,17 @@ p {
   white-space: nowrap;
   font-size: .9em;
   cursor: pointer;
 }
 
 .btn-info {
   background-color: #0096dd;
   border: 1px solid #0095dd;
+  color: #fff;
 }
 
   .btn-info:hover {
     background-color: #008acb;
     border: 1px solid #008acb;
   }
 
   .btn-info:active {
@@ -224,22 +225,30 @@ p {
 
 .btn-chevron-menu-group .btn {
   flex: 1;
   border-radius: 2px;
   border-bottom-right-radius: 0;
   border-top-right-radius: 0;
 }
 
-/* Alerts */
+/* Alerts/Notifications */
+.notificationContainer {
+  border-bottom: 2px solid #E9E9E9;
+  margin-bottom: 1em;
+}
+
+.messages > .notificationContainer > .alert {
+  text-align: center;
+}
+
+.notificationContainer > .detailsBar,
 .alert {
   background: #eee;
   padding: .4em 1em;
-  margin-bottom: 1em;
-  border-bottom: 2px solid #E9E9E9;
 }
 
 .alert p.message {
   padding: 0;
   margin: 0;
 }
 
 .alert-error {
@@ -247,16 +256,21 @@ p {
   color: #fff;
 }
 
 .alert-warning {
   background: #fcf8e3;
   border: 1px solid #fbeed5;
 }
 
+.notificationContainer > .details-error {
+  background: #fbebeb;
+  color: #d74345
+}
+
 .alert .close {
   position: relative;
   top: -.1rem;
   right: -1rem;
 }
 
 /* Misc */
 
--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -327,16 +327,18 @@ loop.shared.models = (function(l10n) {
     },
   });
 
   /**
    * Notification model.
    */
   var NotificationModel = Backbone.Model.extend({
     defaults: {
+      details: "",
+      detailsButtonLabel: "",
       level: "info",
       message: ""
     }
   });
 
   /**
    * Notification collection
    */
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -612,19 +612,29 @@ loop.shared.views = (function(_, OT, l10
     propTypes: {
       notification: React.PropTypes.object.isRequired,
       key: React.PropTypes.number.isRequired
     },
 
     render: function() {
       var notification = this.props.notification;
       return (
-        React.DOM.div({key: this.props.key, 
-             className: "alert alert-" + notification.get("level")}, 
-          React.DOM.span({className: "message"}, notification.get("message"))
+        React.DOM.div({className: "notificationContainer"}, 
+          React.DOM.div({key: this.props.key, 
+               className: "alert alert-" + notification.get("level")}, 
+            React.DOM.span({className: "message"}, notification.get("message"))
+          ), 
+          React.DOM.div({className: "detailsBar details-" + notification.get("level"), 
+               hidden: !notification.get("details")}, 
+            React.DOM.button({className: "detailsButton btn-info", 
+                    hidden: true || !notification.get("detailsButtonLabel")}, 
+              notification.get("detailsButtonLabel")
+            ), 
+            React.DOM.span({className: "details"}, notification.get("details"))
+          )
         )
       );
     }
   });
 
   /**
    * Notification list view.
    */
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -612,19 +612,29 @@ loop.shared.views = (function(_, OT, l10
     propTypes: {
       notification: React.PropTypes.object.isRequired,
       key: React.PropTypes.number.isRequired
     },
 
     render: function() {
       var notification = this.props.notification;
       return (
-        <div key={this.props.key}
-             className={"alert alert-" + notification.get("level")}>
-          <span className="message">{notification.get("message")}</span>
+        <div className="notificationContainer">
+          <div key={this.props.key}
+               className={"alert alert-" + notification.get("level")}>
+            <span className="message">{notification.get("message")}</span>
+          </div>
+          <div className={"detailsBar details-" + notification.get("level")}
+               hidden={!notification.get("details")}>
+            <button className="detailsButton btn-info"
+                    hidden={true || !notification.get("detailsButtonLabel")}>
+              {notification.get("detailsButtonLabel")}
+            </button>
+            <span className="details">{notification.get("details")}</span>
+          </div>
         </div>
       );
     }
   });
 
   /**
    * Notification list view.
    */
--- a/browser/components/loop/test/desktop-local/client_test.js
+++ b/browser/components/loop/test/desktop-local/client_test.js
@@ -95,17 +95,17 @@ describe("loop.Client", function() {
         // Sets up the hawkRequest stub to trigger the callback with
         // an error
         hawkRequestStub.callsArgWith(4, fakeErrorRes);
 
         client.deleteCallUrl(fakeToken, callback);
 
         sinon.assert.calledOnce(callback);
         sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
-          return /400.*invalid token/.test(err.message);
+          return err.code == 400 && "invalid token" == err.message;
         }));
       });
     });
 
     describe("#requestCallUrl", function() {
       it("should ensure loop is registered", function() {
         client.requestCallUrl("foo", callback);
 
@@ -213,17 +213,17 @@ describe("loop.Client", function() {
         // Sets up the hawkRequest stub to trigger the callback with
         // an error
         hawkRequestStub.callsArgWith(4, fakeErrorRes);
 
         client.requestCallUrl("foo", callback);
 
         sinon.assert.calledOnce(callback);
         sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
-          return /400.*invalid token/.test(err.message);
+          return err.code == 400 && "invalid token" == err.message;
         }));
       });
 
       it("should send an error if the data is not valid", function() {
         // Sets up the hawkRequest stub to trigger the callback with
         // an error
         hawkRequestStub.callsArgWith(4, null, "{}");
 
@@ -296,17 +296,17 @@ describe("loop.Client", function() {
 
       it("should send an error when the request fails", function() {
         hawkRequestStub.callsArgWith(4, fakeErrorRes);
 
         client.setupOutgoingCall(calleeIds, callType, callback);
 
         sinon.assert.calledOnce(callback);
         sinon.assert.calledWithExactly(callback, sinon.match(function(err) {
-          return /400.*invalid token/.test(err.message);
+          return err.code == 400 && "invalid token" == err.message;
         }));
       });
 
       it("should send an error if the data is not valid", function() {
         // Sets up the hawkRequest stub to trigger the callback with
         // an error
         hawkRequestStub.callsArgWith(4, null, "{}");
 
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -341,18 +341,18 @@ describe("loop.panel", function() {
         });
 
       it("should update CallUrlResult with the call url", function() {
         var urlField = view.getDOMNode().querySelector("input[type='url']");
 
         expect(urlField.value).eql(callUrlData.callUrl);
       });
 
-      it("should reset all pending notifications", function() {
-        sinon.assert.calledOnce(view.props.notifications.reset);
+      it("should have 0 pending notifications", function() {
+        expect(view.props.notifications.length).eql(0);
       });
 
       it("should display a share button for email", function() {
         fakeClient.requestCallUrl = sandbox.stub();
         var view = TestUtils.renderIntoDocument(loop.panel.CallUrlResult({
           notifications: notifications,
           client: fakeClient
         }));
--- a/browser/components/loop/test/mochitest/browser_fxa_login.js
+++ b/browser/components/loop/test/mochitest/browser_fxa_login.js
@@ -9,25 +9,53 @@
 
 const {
   gFxAOAuthTokenData,
   gFxAOAuthProfile,
 } = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
 
 const BASE_URL = "http://mochi.test:8888/browser/browser/components/loop/test/mochitest/loop_fxa.sjs?";
 
+function* checkFxA401() {
+  let err = MozLoopService.errors.get("login");
+  ise(err.code, 401, "Check error code");
+  ise(err.friendlyMessage, getLoopString("could_not_authenticate"),
+      "Check friendlyMessage");
+  ise(err.friendlyDetails, getLoopString("password_changed_question"),
+      "Check friendlyDetails");
+  ise(err.friendlyDetailsButtonLabel, getLoopString("retry_button"),
+      "Check friendlyDetailsButtonLabel");
+  let loopButton = document.getElementById("loop-call-button");
+  is(loopButton.getAttribute("state"), "error",
+     "state of loop button should be error after a 401 with login");
+
+  let loopPanel = document.getElementById("loop-notification-panel");
+  yield loadLoopPanel({loopURL: BASE_URL });
+  let loopDoc = document.getElementById("loop").contentDocument;
+  is(loopDoc.querySelector(".alert-error .message").textContent,
+     getLoopString("could_not_authenticate"),
+     "Check error bar message");
+  is(loopDoc.querySelector(".details-error .details").textContent,
+     getLoopString("password_changed_question"),
+     "Check error bar details message");
+  is(loopDoc.querySelector(".details-error .detailsButton").textContent,
+     getLoopString("retry_button"),
+     "Check error bar details button");
+  loopPanel.hidePopup();
+}
+
 add_task(function* setup() {
   Services.prefs.setCharPref("loop.server", BASE_URL);
   Services.prefs.setCharPref("services.push.serverURL", "ws://localhost/");
   registerCleanupFunction(function* () {
     info("cleanup time");
     yield promiseDeletedOAuthParams(BASE_URL);
     Services.prefs.clearUserPref("loop.server");
     Services.prefs.clearUserPref("services.push.serverURL");
-    resetFxA();
+    yield resetFxA();
     Services.prefs.clearUserPref(MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.GUEST));
   });
 });
 
 add_task(function* checkOAuthParams() {
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
@@ -47,39 +75,39 @@ add_task(function* checkOAuthParams() {
 
 add_task(function* basicAuthorization() {
   let result = yield MozLoopServiceInternal.promiseFxAOAuthAuthorization();
   is(result.code, "code1", "Check code");
   is(result.state, "state", "Check state");
 });
 
 add_task(function* sameOAuthClientForTwoCalls() {
-  resetFxA();
+  yield resetFxA();
   let client1 = yield MozLoopServiceInternal.promiseFxAOAuthClient();
   let client2 = yield MozLoopServiceInternal.promiseFxAOAuthClient();
   ise(client1, client2, "The same client should be returned");
 });
 
 add_task(function* paramsInvalid() {
-  resetFxA();
+  yield resetFxA();
   // Delete the params so an empty object is returned.
   yield promiseDeletedOAuthParams(BASE_URL);
   let result = null;
   let loginPromise = MozLoopService.logInToFxA();
   let caught = false;
   yield loginPromise.catch(() => {
     ok(true, "The login promise should be rejected due to invalid params");
     caught = true;
   });
   ok(caught, "Should have caught the rejection");
   is(result, null, "No token data should be returned");
 });
 
 add_task(function* params_no_hawk_session() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
     test_error: "params_no_hawk",
   };
@@ -96,78 +124,79 @@ add_task(function* params_no_hawk_sessio
   ise(Services.prefs.getPrefType(prefName),
       Services.prefs.PREF_INVALID,
       "Check FxA hawk token is not set");
 });
 
 add_task(function* params_nonJSON() {
   Services.prefs.setCharPref("loop.server", "https://loop.invalid");
   // Reset after changing the server so a new HawkClient is created
-  resetFxA();
+  yield resetFxA();
 
   let loginPromise = MozLoopService.logInToFxA();
   let caught = false;
   yield loginPromise.catch(() => {
     ok(true, "The login promise should be rejected due to non-JSON params");
     caught = true;
   });
   ok(caught, "Should have caught the rejection");
   Services.prefs.setCharPref("loop.server", BASE_URL);
 });
 
 add_task(function* invalidState() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "invalid_state",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
   let loginPromise = MozLoopService.logInToFxA();
   yield loginPromise.catch((error) => {
     ok(error, "The login promise should be rejected due to invalid state");
   });
 });
 
 add_task(function* basicRegistrationWithoutSession() {
-  resetFxA();
+  yield resetFxA();
   yield promiseDeletedOAuthParams(BASE_URL);
 
   let caught = false;
   yield MozLoopServiceInternal.promiseFxAOAuthToken("code1", "state").catch((error) => {
     caught = true;
     is(error.code, 401, "Should have returned a 401");
   });
   ok(caught, "Should have caught the error requesting /token without a hawk session");
+  yield checkFxA401();
 });
 
 add_task(function* basicRegistration() {
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
-  resetFxA();
+  yield resetFxA();
   // Create a fake FxA hawk session token
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
 
   let tokenData = yield MozLoopServiceInternal.promiseFxAOAuthToken("code1", "state");
   is(tokenData.access_token, "code1_access_token", "Check access_token");
   is(tokenData.scope, "profile", "Check scope");
   is(tokenData.token_type, "bearer", "Check token_type");
 });
 
 add_task(function* registrationWithInvalidState() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "invalid_state",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
@@ -181,17 +210,17 @@ add_task(function* registrationWithInval
     ok(false, "Promise should have rejected");
   },
   error => {
     is(error.code, 400, "Check error code");
   });
 });
 
 add_task(function* registrationWith401() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
     test_error: "token_401",
   };
@@ -199,20 +228,22 @@ add_task(function* registrationWith401()
 
   let tokenPromise = MozLoopServiceInternal.promiseFxAOAuthToken("code1", "state");
   yield tokenPromise.then(body => {
     ok(false, "Promise should have rejected");
   },
   error => {
     is(error.code, 401, "Check error code");
   });
+
+  yield checkFxA401();
 });
 
 add_task(function* basicAuthorizationAndRegistration() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
@@ -267,17 +298,17 @@ add_task(function* basicAuthorizationAnd
   registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
   ise(registrationResponse.response, null,
       "Check registration was deleted on the server");
   is(visibleEmail.textContent, "Guest", "Guest should be displayed on the panel again after logout");
   is(MozLoopService.userProfile, null, "userProfile should be null after logout");
 });
 
 add_task(function* loginWithParams401() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
     test_error: "params_401",
   };
@@ -287,20 +318,22 @@ add_task(function* loginWithParams401() 
   let loginPromise = MozLoopService.logInToFxA();
   yield loginPromise.then(tokenData => {
     ok(false, "Promise should have rejected");
   },
   error => {
     ise(error.code, 401, "Check error code");
     ise(gFxAOAuthTokenData, null, "Check there is no saved token data");
   });
+
+  yield checkFxA401();
 });
 
 add_task(function* logoutWithIncorrectPushURL() {
-  resetFxA();
+  yield resetFxA();
   let pushURL = "http://www.example.com/";
   mockPushHandler.pushUrl = pushURL;
 
   // Create a fake FxA hawk session token
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
 
   yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, pushURL);
@@ -313,40 +346,36 @@ add_task(function* logoutWithIncorrectPu
   });
   ok(caught, "Should have caught an error logging out with a mismatched push URL");
   checkLoggedOutState();
   registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
   ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL wasn't deleted");
 });
 
 add_task(function* logoutWithNoPushURL() {
-  resetFxA();
+  yield resetFxA();
   let pushURL = "http://www.example.com/";
   mockPushHandler.pushUrl = pushURL;
 
   // Create a fake FxA hawk session token
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
 
   yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, pushURL);
   let registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
   ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL");
   mockPushHandler.pushUrl = null;
-  let caught = false;
-  yield MozLoopService.logOutFromFxA().catch((error) => {
-    caught = true;
-  });
-  ok(caught, "Should have caught an error logging out without a push URL");
+  yield MozLoopService.logOutFromFxA();
   checkLoggedOutState();
   registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
   ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL wasn't deleted");
 });
 
 add_task(function* loginWithRegistration401() {
-  resetFxA();
+  yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
     test_error: "token_401",
   };
@@ -355,9 +384,11 @@ add_task(function* loginWithRegistration
   let loginPromise = MozLoopService.logInToFxA();
   yield loginPromise.then(tokenData => {
     ok(false, "Promise should have rejected");
   },
   error => {
     ise(error.code, 401, "Check error code");
     ise(gFxAOAuthTokenData, null, "Check there is no saved token data");
   });
+
+  yield checkFxA401();
 });
--- a/browser/components/loop/test/mochitest/head.js
+++ b/browser/components/loop/test/mochitest/head.js
@@ -14,20 +14,31 @@ const WAS_OFFLINE = Services.io.offline;
 
 var gMozLoopAPI;
 
 function promiseGetMozLoopAPI() {
   let deferred = Promise.defer();
   let loopPanel = document.getElementById("loop-notification-panel");
   let btn = document.getElementById("loop-call-button");
 
-  // Wait for the popup to be shown, then we can get the iframe and
+  // Wait for the popup to be shown if it's not already, then we can get the iframe and
   // wait for the iframe's load to be completed.
-  loopPanel.addEventListener("popupshown", function onpopupshown() {
-    loopPanel.removeEventListener("popupshown", onpopupshown, true);
+  if (loopPanel.state == "closing" || loopPanel.state == "closed") {
+    loopPanel.addEventListener("popupshown", () => {
+      loopPanel.removeEventListener("popupshown", onpopupshown, true);
+      onpopupshown();
+    }, true);
+
+    // Now we're setup, click the button.
+    btn.click();
+  } else {
+    setTimeout(onpopupshown, 0);
+  }
+
+  function onpopupshown() {
     let iframe = document.getElementById(btn.getAttribute("notificationFrameId"));
 
     if (iframe.contentDocument &&
         iframe.contentDocument.readyState == "complete") {
       gMozLoopAPI = iframe.contentWindow.navigator.wrappedJSObject.mozLoop;
 
       deferred.resolve();
     } else {
@@ -36,20 +47,17 @@ function promiseGetMozLoopAPI() {
 
         gMozLoopAPI = iframe.contentWindow.navigator.wrappedJSObject.mozLoop;
 
         // We do this in an execute soon to allow any other event listeners to
         // be handled, just in case.
         deferred.resolve();
       }, true);
     }
-  }, true);
-
-  // Now we're setup, click the button.
-  btn.click();
+  }
 
   // Remove the iframe after each test. This also avoids mochitest complaining
   // about leaks on shutdown as we intentionally hold the iframe open for the
   // life of the application.
   registerCleanupFunction(function() {
     loopPanel.hidePopup();
     let frameId = btn.getAttribute("notificationFrameId");
     let frame = document.getElementById(frameId);
@@ -102,25 +110,29 @@ function promiseOAuthParamsSetup(baseURL
   xhr.setRequestHeader("X-Params", JSON.stringify(params));
   xhr.addEventListener("load", () => deferred.resolve(xhr));
   xhr.addEventListener("error", error => deferred.reject(error));
   xhr.send();
 
   return deferred.promise;
 }
 
-function resetFxA() {
+function* resetFxA() {
   let global = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
   global.gHawkClient = null;
   global.gFxAOAuthClientPromise = null;
   global.gFxAOAuthClient = null;
   global.gFxAOAuthTokenData = null;
   global.gFxAOAuthProfile = null;
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.clearUserPref(fxASessionPref);
+  MozLoopService.errors.clear();
+  let notified = promiseObserverNotified("loop-status-changed");
+  MozLoopServiceInternal.notifyStatusChanged();
+  yield notified;
 }
 
 function setInternalLoopGlobal(aName, aValue) {
   let global = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
   global[aName] = aValue;
 }
 
 function checkLoggedOutState() {
@@ -167,16 +179,20 @@ function promiseOAuthGetRegistration(bas
   xhr.responseType = "json";
   xhr.addEventListener("load", () => deferred.resolve(xhr));
   xhr.addEventListener("error", deferred.reject);
   xhr.send();
 
   return deferred.promise;
 }
 
+function getLoopString(stringID) {
+  return MozLoopServiceInternal.localizedStrings[stringID].textContent;
+}
+
 /**
  * This is used to fake push registration and notifications for
  * MozLoopService tests. There is only one object created per test instance, as
  * once registration has taken place, the object cannot currently be changed.
  */
 let mockPushHandler = {
   // This sets the registration result to be returned when initialize
   // is called. By default, it is equivalent to success.
--- a/browser/components/loop/test/xpcshell/head.js
+++ b/browser/components/loop/test/xpcshell/head.js
@@ -2,19 +2,18 @@
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Http.jsm");
 Cu.import("resource://testing-common/httpd.js");
-
-XPCOMUtils.defineLazyModuleGetter(this, "MozLoopService",
-                                  "resource:///modules/loop/MozLoopService.jsm");
+Cu.import("resource:///modules/loop/MozLoopService.jsm");
+const { MozLoopServiceInternal } = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
 
 XPCOMUtils.defineLazyModuleGetter(this, "MozLoopPushHandler",
                                   "resource:///modules/loop/MozLoopPushHandler.jsm");
 
 const kMockWebSocketChannelName = "Mock WebSocket Channel";
 const kWebSocketChannelContractID = "@mozilla.org/network/protocol;1?name=wss";
 
 const kServerPushUrl = "http://localhost:3456";
@@ -57,16 +56,20 @@ function waitForCondition(aConditionFn, 
     do_timeout(aCheckInterval, tryNow);
   }
   let deferred = Promise.defer();
   let tries = 0;
   tryAgain();
   return deferred.promise;
 }
 
+function getLoopString(stringID) {
+  return MozLoopServiceInternal.localizedStrings[stringID].textContent;
+}
+
 /**
  * This is used to fake push registration and notifications for
  * MozLoopService tests. There is only one object created per test instance, as
  * once registration has taken place, the object cannot currently be changed.
  */
 let mockPushHandler = {
   // This sets the registration result to be returned when initialize
   // is called. By default, it is equivalent to success.
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/xpcshell/test_loopservice_hawk_errors.js
@@ -0,0 +1,194 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Unit tests for the error handling for hawkRequest via setError.
+ *
+ * hawkRequest calls setError itself for 401. Consumers need to report other
+ * errors to setError themseleves.
+ */
+
+"use strict";
+
+const { INVALID_AUTH_TOKEN } = Cu.import("resource:///modules/loop/MozLoopService.jsm");
+
+/**
+ * An HTTP request for /NNN responds with a request with a status of NNN.
+ */
+function errorRequestHandler(request, response) {
+  let responseCode = request.path.substring(1);
+  response.setStatusLine(null, responseCode, "Error");
+  if (responseCode == 401) {
+    response.write(JSON.stringify({
+      code: parseInt(responseCode),
+      errno: INVALID_AUTH_TOKEN,
+      error: "INVALID_AUTH_TOKEN",
+      message: "INVALID_AUTH_TOKEN",
+    }));
+  }
+}
+
+add_task(function* setup_server() {
+  loopServer.registerPathHandler("/401", errorRequestHandler);
+  loopServer.registerPathHandler("/404", errorRequestHandler);
+  loopServer.registerPathHandler("/500", errorRequestHandler);
+  loopServer.registerPathHandler("/503", errorRequestHandler);
+});
+
+add_task(function* error_offline() {
+  Services.io.offline = true;
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/offline", "GET").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      MozLoopServiceInternal.setError("testing", error);
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      // Network errors are converted to the "network" errorType.
+      let err = MozLoopService.errors.get("network");
+      Assert.strictEqual(err.code, null);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("could_not_connect"));
+      Assert.strictEqual(err.friendlyDetails, getLoopString("check_internet_connection"));
+      Assert.strictEqual(err.friendlyDetailsButtonLabel, getLoopString("retry_button"));
+  });
+  Services.io.offline = false;
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* guest_401() {
+  Services.prefs.setCharPref("loop.hawk-session-token", "guest");
+  Services.prefs.setCharPref("loop.hawk-session-token.fxa", "fxa");
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/401", "POST").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      Assert.strictEqual(Services.prefs.getPrefType("loop.hawk-session-token"),
+                         Services.prefs.PREF_INVALID,
+                         "Guest session token should have been cleared");
+      Assert.strictEqual(Services.prefs.getCharPref("loop.hawk-session-token.fxa"),
+                         "fxa",
+                         "FxA session token should NOT have been cleared");
+
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      let err = MozLoopService.errors.get("registration");
+      Assert.strictEqual(err.code, 401);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("session_expired_error_description"));
+      Assert.equal(err.friendlyDetails, null);
+      Assert.equal(err.friendlyDetailsButtonLabel, null);
+  });
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* fxa_401() {
+  Services.prefs.setCharPref("loop.hawk-session-token", "guest");
+  Services.prefs.setCharPref("loop.hawk-session-token.fxa", "fxa");
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.FXA, "/401", "POST").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      Assert.strictEqual(Services.prefs.getCharPref("loop.hawk-session-token"),
+                         "guest",
+                         "Guest session token should NOT have been cleared");
+      Assert.strictEqual(Services.prefs.getPrefType("loop.hawk-session-token.fxa"),
+                         Services.prefs.PREF_INVALID,
+                         "Fxa session token should have been cleared");
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      let err = MozLoopService.errors.get("login");
+      Assert.strictEqual(err.code, 401);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("could_not_authenticate"));
+      Assert.strictEqual(err.friendlyDetails, getLoopString("password_changed_question"));
+      Assert.strictEqual(err.friendlyDetailsButtonLabel, getLoopString("retry_button"));
+  });
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* error_404() {
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/404", "GET").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      MozLoopServiceInternal.setError("testing", error);
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      let err = MozLoopService.errors.get("testing");
+      Assert.strictEqual(err.code, 404);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("generic_failure_title"));
+      Assert.equal(err.friendlyDetails, null);
+      Assert.equal(err.friendlyDetailsButtonLabel, null);
+  });
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* error_500() {
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/500", "GET").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      MozLoopServiceInternal.setError("testing", error);
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      let err = MozLoopService.errors.get("testing");
+      Assert.strictEqual(err.code, 500);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("service_not_available"));
+      Assert.strictEqual(err.friendlyDetails, getLoopString("try_again_later"));
+      Assert.strictEqual(err.friendlyDetailsButtonLabel, getLoopString("retry_button"));
+  });
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* profile_500() {
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/500", "GET").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      MozLoopServiceInternal.setError("profile", error);
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      let err = MozLoopService.errors.get("profile");
+      Assert.strictEqual(err.code, 500);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("problem_accessing_account"));
+      Assert.equal(err.friendlyDetails, null);
+      Assert.equal(err.friendlyDetailsButtonLabel, null);
+  });
+});
+
+add_task(cleanup_between_tests);
+
+add_task(function* error_503() {
+  yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/503", "GET").then(
+    () => Assert.ok(false, "Should have rejected"),
+    (error) => {
+      MozLoopServiceInternal.setError("testing", error);
+      Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
+
+      let err = MozLoopService.errors.get("testing");
+      Assert.strictEqual(err.code, 503);
+      Assert.strictEqual(err.friendlyMessage, getLoopString("service_not_available"));
+      Assert.strictEqual(err.friendlyDetails, getLoopString("try_again_later"));
+      Assert.strictEqual(err.friendlyDetailsButtonLabel, getLoopString("retry_button"));
+  });
+});
+
+add_task(cleanup_between_tests);
+
+function run_test() {
+  setupFakeLoopServer();
+
+  // Set the expiry time one hour in the future so that an error is shown when the guest session expires.
+  MozLoopServiceInternal.expiryTimeSeconds = (Date.now() / 1000) + 3600;
+
+  do_register_cleanup(() => {
+    Services.prefs.clearUserPref("loop.hawk-session-token");
+    Services.prefs.clearUserPref("loop.hawk-session-token.fxa");
+    Services.prefs.clearUserPref("loop.urlsExpiryTimeSeconds");
+    MozLoopService.errors.clear();
+  });
+
+  run_next_test();
+}
+
+function* cleanup_between_tests() {
+  MozLoopService.errors.clear();
+  Services.io.offline = false;
+}
--- a/browser/components/loop/test/xpcshell/xpcshell.ini
+++ b/browser/components/loop/test/xpcshell/xpcshell.ini
@@ -2,16 +2,17 @@
 head = head.js
 tail =
 firefox-appdir = browser
 
 [test_loopapi_hawk_request.js]
 [test_looppush_initialize.js]
 [test_loopservice_dnd.js]
 [test_loopservice_expiry.js]
+[test_loopservice_hawk_errors.js]
 [test_loopservice_loop_prefs.js]
 [test_loopservice_initialize.js]
 [test_loopservice_locales.js]
 [test_loopservice_notification.js]
 [test_loopservice_registration.js]
 [test_loopservice_token_invalid.js]
 [test_loopservice_token_save.js]
 [test_loopservice_token_send.js]
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -73,17 +73,22 @@
   var mockWebSocket = new loop.CallConnectionWebSocket({
     url: "fake",
     callId: "fakeId",
     websocketToken: "fakeToken"
   });
 
   var notifications = new loop.shared.models.NotificationCollection();
   var errNotifications = new loop.shared.models.NotificationCollection();
-  errNotifications.error("Error!");
+  errNotifications.add({
+    level: "error",
+    message: "Could Not Authenticate",
+    details: "Did you change your password?",
+    detailsButtonLabel: "Retry",
+  });
 
   var Example = React.createClass({displayName: 'Example',
     render: function() {
       var cx = React.addons.classSet;
       return (
         React.DOM.div({className: "example"}, 
           React.DOM.h3(null, this.props.summary), 
           React.DOM.div({className: cx({comp: true, dashed: this.props.dashed}), 
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -73,17 +73,22 @@
   var mockWebSocket = new loop.CallConnectionWebSocket({
     url: "fake",
     callId: "fakeId",
     websocketToken: "fakeToken"
   });
 
   var notifications = new loop.shared.models.NotificationCollection();
   var errNotifications = new loop.shared.models.NotificationCollection();
-  errNotifications.error("Error!");
+  errNotifications.add({
+    level: "error",
+    message: "Could Not Authenticate",
+    details: "Did you change your password?",
+    detailsButtonLabel: "Retry",
+  });
 
   var Example = React.createClass({
     render: function() {
       var cx = React.addons.classSet;
       return (
         <div className="example">
           <h3>{this.props.summary}</h3>
           <div className={cx({comp: true, dashed: this.props.dashed})}