mirror of
https://gitlab.winehq.org/wine/wine-gecko.git
synced 2024-09-13 09:24:08 -07:00
Bug 1047164 - Handle authentication errors (e.g. token expiry) for FxA Loop sessions and notify users. r=jaws
This commit is contained in:
parent
68a2628da0
commit
5c7ac1f4de
@ -144,6 +144,28 @@ function injectLoopAPI(targetWindow) {
|
||||
}
|
||||
},
|
||||
|
||||
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.
|
||||
*
|
||||
@ -513,9 +535,9 @@ function injectLoopAPI(targetWindow) {
|
||||
}, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@
|
||||
|
||||
"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
|
||||
@ -330,11 +330,60 @@ let MozLoopServiceInternal = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 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();
|
||||
},
|
||||
@ -411,7 +460,30 @@ let MozLoopServiceInternal = {
|
||||
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;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@ -534,21 +606,13 @@ let MozLoopServiceInternal = {
|
||||
}, (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;
|
||||
@ -568,6 +632,11 @@ let MozLoopServiceInternal = {
|
||||
* @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(() => {
|
||||
@ -577,7 +646,7 @@ let MozLoopServiceInternal = {
|
||||
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;
|
||||
}
|
||||
@ -1117,6 +1186,7 @@ this.MozLoopService = {
|
||||
* 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");
|
||||
@ -1196,6 +1266,10 @@ this.MozLoopService = {
|
||||
return MozLoopServiceInternal.errors;
|
||||
},
|
||||
|
||||
get log() {
|
||||
return log;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the current locale
|
||||
*
|
||||
@ -1331,6 +1405,8 @@ this.MozLoopService = {
|
||||
} else {
|
||||
throw new Error("No pushUrl for FxA registration");
|
||||
}
|
||||
MozLoopServiceInternal.clearError("login");
|
||||
MozLoopServiceInternal.clearError("profile");
|
||||
return gFxAOAuthTokenData;
|
||||
}));
|
||||
}).then(tokenData => {
|
||||
@ -1343,6 +1419,7 @@ this.MozLoopService = {
|
||||
MozLoopServiceInternal.notifyStatusChanged("login");
|
||||
}, error => {
|
||||
log.error("Failed to retrieve profile", error);
|
||||
this.setError("profile", error);
|
||||
gFxAOAuthProfile = null;
|
||||
MozLoopServiceInternal.notifyStatusChanged();
|
||||
});
|
||||
@ -1351,6 +1428,10 @@ this.MozLoopService = {
|
||||
gFxAOAuthTokenData = null;
|
||||
gFxAOAuthProfile = null;
|
||||
throw error;
|
||||
}).catch((error) => {
|
||||
MozLoopServiceInternal.setError("login", error);
|
||||
// Re-throw for testing
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
|
||||
@ -1363,8 +1444,12 @@ this.MozLoopService = {
|
||||
*/
|
||||
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;
|
||||
@ -1377,6 +1462,8 @@ this.MozLoopService = {
|
||||
// clearError calls notifyStatusChanged so should be done last when the
|
||||
// state is clean.
|
||||
MozLoopServiceInternal.clearError("registration");
|
||||
MozLoopServiceInternal.clearError("login");
|
||||
MozLoopServiceInternal.clearError("profile");
|
||||
}),
|
||||
|
||||
openFxASettings: function() {
|
||||
|
@ -78,7 +78,7 @@ loop.Client = (function($) {
|
||||
_failureHandler: function(cb, error) {
|
||||
var message = "HTTP " + error.code + " " + error.error + "; " + error.message;
|
||||
console.error(message);
|
||||
cb(new Error(message));
|
||||
cb(error);
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -312,10 +312,12 @@ loop.panel = (function(_, mozL10n) {
|
||||
},
|
||||
|
||||
_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 {
|
||||
@ -445,8 +447,36 @@ loop.panel = (function(_, mozL10n) {
|
||||
};
|
||||
},
|
||||
|
||||
_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) {
|
||||
@ -458,12 +488,16 @@ loop.panel = (function(_, mozL10n) {
|
||||
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() {
|
||||
|
@ -312,10 +312,12 @@ loop.panel = (function(_, mozL10n) {
|
||||
},
|
||||
|
||||
_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 {
|
||||
@ -445,8 +447,36 @@ loop.panel = (function(_, mozL10n) {
|
||||
};
|
||||
},
|
||||
|
||||
_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) {
|
||||
@ -458,12 +488,16 @@ loop.panel = (function(_, mozL10n) {
|
||||
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() {
|
||||
|
@ -98,6 +98,7 @@ p {
|
||||
.btn-info {
|
||||
background-color: #0096dd;
|
||||
border: 1px solid #0095dd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-info:hover {
|
||||
@ -229,12 +230,20 @@ p {
|
||||
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 {
|
||||
@ -252,6 +261,11 @@ p {
|
||||
border: 1px solid #fbeed5;
|
||||
}
|
||||
|
||||
.notificationContainer > .details-error {
|
||||
background: #fbebeb;
|
||||
color: #d74345
|
||||
}
|
||||
|
||||
.alert .close {
|
||||
position: relative;
|
||||
top: -.1rem;
|
||||
|
@ -332,6 +332,8 @@ loop.shared.models = (function(l10n) {
|
||||
*/
|
||||
var NotificationModel = Backbone.Model.extend({
|
||||
defaults: {
|
||||
details: "",
|
||||
detailsButtonLabel: "",
|
||||
level: "info",
|
||||
message: ""
|
||||
}
|
||||
|
@ -617,9 +617,19 @@ loop.shared.views = (function(_, OT, l10n) {
|
||||
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"))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -617,9 +617,19 @@ loop.shared.views = (function(_, OT, l10n) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -100,7 +100,7 @@ describe("loop.Client", function() {
|
||||
|
||||
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;
|
||||
}));
|
||||
});
|
||||
});
|
||||
@ -218,7 +218,7 @@ describe("loop.Client", function() {
|
||||
|
||||
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;
|
||||
}));
|
||||
});
|
||||
|
||||
@ -301,7 +301,7 @@ describe("loop.Client", function() {
|
||||
|
||||
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;
|
||||
}));
|
||||
});
|
||||
|
||||
|
@ -346,8 +346,8 @@ describe("loop.panel", function() {
|
||||
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() {
|
||||
|
@ -14,6 +14,34 @@ const {
|
||||
|
||||
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/");
|
||||
@ -22,7 +50,7 @@ add_task(function* setup() {
|
||||
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));
|
||||
});
|
||||
});
|
||||
@ -52,14 +80,14 @@ add_task(function* basicAuthorization() {
|
||||
});
|
||||
|
||||
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;
|
||||
@ -74,7 +102,7 @@ add_task(function* paramsInvalid() {
|
||||
});
|
||||
|
||||
add_task(function* params_no_hawk_session() {
|
||||
resetFxA();
|
||||
yield resetFxA();
|
||||
let params = {
|
||||
client_id: "client_id",
|
||||
content_uri: BASE_URL + "/content",
|
||||
@ -101,7 +129,7 @@ add_task(function* params_no_hawk_session() {
|
||||
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;
|
||||
@ -114,7 +142,7 @@ add_task(function* params_nonJSON() {
|
||||
});
|
||||
|
||||
add_task(function* invalidState() {
|
||||
resetFxA();
|
||||
yield resetFxA();
|
||||
let params = {
|
||||
client_id: "client_id",
|
||||
content_uri: BASE_URL + "/content",
|
||||
@ -130,7 +158,7 @@ add_task(function* invalidState() {
|
||||
});
|
||||
|
||||
add_task(function* basicRegistrationWithoutSession() {
|
||||
resetFxA();
|
||||
yield resetFxA();
|
||||
yield promiseDeletedOAuthParams(BASE_URL);
|
||||
|
||||
let caught = false;
|
||||
@ -139,6 +167,7 @@ add_task(function* basicRegistrationWithoutSession() {
|
||||
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() {
|
||||
@ -150,7 +179,7 @@ add_task(function* basicRegistration() {
|
||||
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));
|
||||
@ -162,7 +191,7 @@ add_task(function* basicRegistration() {
|
||||
});
|
||||
|
||||
add_task(function* registrationWithInvalidState() {
|
||||
resetFxA();
|
||||
yield resetFxA();
|
||||
let params = {
|
||||
client_id: "client_id",
|
||||
content_uri: BASE_URL + "/content",
|
||||
@ -186,7 +215,7 @@ add_task(function* registrationWithInvalidState() {
|
||||
});
|
||||
|
||||
add_task(function* registrationWith401() {
|
||||
resetFxA();
|
||||
yield resetFxA();
|
||||
let params = {
|
||||
client_id: "client_id",
|
||||
content_uri: BASE_URL + "/content",
|
||||
@ -204,10 +233,12 @@ add_task(function* registrationWith401() {
|
||||
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",
|
||||
@ -272,7 +303,7 @@ add_task(function* basicAuthorizationAndRegistration() {
|
||||
});
|
||||
|
||||
add_task(function* loginWithParams401() {
|
||||
resetFxA();
|
||||
yield resetFxA();
|
||||
let params = {
|
||||
client_id: "client_id",
|
||||
content_uri: BASE_URL + "/content",
|
||||
@ -292,10 +323,12 @@ add_task(function* loginWithParams401() {
|
||||
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;
|
||||
|
||||
@ -318,7 +351,7 @@ add_task(function* logoutWithIncorrectPushURL() {
|
||||
});
|
||||
|
||||
add_task(function* logoutWithNoPushURL() {
|
||||
resetFxA();
|
||||
yield resetFxA();
|
||||
let pushURL = "http://www.example.com/";
|
||||
mockPushHandler.pushUrl = pushURL;
|
||||
|
||||
@ -330,18 +363,14 @@ add_task(function* logoutWithNoPushURL() {
|
||||
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",
|
||||
@ -360,4 +389,6 @@ add_task(function* loginWithRegistration401() {
|
||||
ise(error.code, 401, "Check error code");
|
||||
ise(gFxAOAuthTokenData, null, "Check there is no saved token data");
|
||||
});
|
||||
|
||||
yield checkFxA401();
|
||||
});
|
||||
|
@ -19,10 +19,21 @@ function promiseGetMozLoopAPI() {
|
||||
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 &&
|
||||
@ -41,10 +52,7 @@ function promiseGetMozLoopAPI() {
|
||||
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
|
||||
@ -107,7 +115,7 @@ function promiseOAuthParamsSetup(baseURL, params) {
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
function resetFxA() {
|
||||
function* resetFxA() {
|
||||
let global = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
|
||||
global.gHawkClient = null;
|
||||
global.gFxAOAuthClientPromise = null;
|
||||
@ -116,6 +124,10 @@ function resetFxA() {
|
||||
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) {
|
||||
@ -172,6 +184,10 @@ function promiseOAuthGetRegistration(baseURL) {
|
||||
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
|
||||
|
@ -7,9 +7,8 @@ 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");
|
||||
@ -62,6 +61,10 @@ function waitForCondition(aConditionFn, aMaxTries=50, aCheckInterval=100) {
|
||||
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
|
||||
|
@ -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;
|
||||
}
|
@ -7,6 +7,7 @@ firefox-appdir = browser
|
||||
[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]
|
||||
|
@ -78,7 +78,12 @@
|
||||
|
||||
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() {
|
||||
|
@ -78,7 +78,12 @@
|
||||
|
||||
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() {
|
||||
|
Loading…
Reference in New Issue
Block a user