diff --git a/horizon/static/framework/framework.module.js b/horizon/static/framework/framework.module.js index d731902d6f..fb3fb86ba9 100644 --- a/horizon/static/framework/framework.module.js +++ b/horizon/static/framework/framework.module.js @@ -22,7 +22,11 @@ 'horizon.framework.widgets' ]) .config(config) - .run(run); + .run(run) + .factory('horizon.framework.redirect', httpRedirectLogin) + .constant('horizon.framework.events', { + FORCE_LOGOUT: 'FORCE_LOGOUT' + }); config.$inject = [ '$injector', @@ -70,23 +74,9 @@ // Global http error handler // if user is not authorized, log user out // this can happen when session expires - $httpProvider.interceptors.push(redirect); + $httpProvider.interceptors.push(httpRedirectLogin); $httpProvider.interceptors.push(stripAjaxHeaderForCORS); - redirect.$inject = ['$q']; - - function redirect($q) { - return { - responseError: function (error) { - if (error.status === 401) { - var $window = $windowProvider.$get(); - $window.location.replace($window.WEBROOT + 'auth/logout'); - } - return $q.reject(error); - } - }; - } - stripAjaxHeaderForCORS.$inject = []; // Standard CORS middleware used in OpenStack services doesn't expect // X-Requested-With header to be set for requests and rejects requests @@ -125,4 +115,38 @@ } } + httpRedirectLogin.$inject = [ + '$q', + '$rootScope', + '$window', + 'horizon.framework.events', + 'horizon.framework.widgets.toast.service' + ]; + + function httpRedirectLogin($q, $rootScope, $window, frameworkEvents, toastService) { + return { + responseError: function (error) { + if (error.status === 401) { + var msg = gettext('Unauthorized. Redirecting to login'); + handleRedirectMessage(msg, $rootScope, $window, frameworkEvents, toastService); + } + if (error.status === 403) { + var msg2 = gettext('Forbidden. Redirecting to login'); + handleRedirectMessage(msg2, $rootScope, $window, frameworkEvents, toastService); + } + return $q.reject(error); + } + }; + } + + function handleRedirectMessage(msg, $rootScope, $window, frameworkEvents, toastService) { + var toast = toastService.find('error', msg); + //Suppress the multiple duplicate redirect toast messages. + if (!toast) { + toastService.add('error', msg); + $rootScope.$broadcast(frameworkEvents.FORCE_LOGOUT, msg); + } + $window.location.replace($window.WEBROOT + 'auth/logout'); + } + })(); diff --git a/horizon/static/framework/framework.module.spec.js b/horizon/static/framework/framework.module.spec.js index 2028cd2784..f998d2bd4d 100644 --- a/horizon/static/framework/framework.module.spec.js +++ b/horizon/static/framework/framework.module.spec.js @@ -35,15 +35,45 @@ })); describe('when unauthorized', function() { - it('should redirect to /auth/logout', inject(function($http, $httpBackend, $window) { - $window.WEBROOT = '/dashboard/'; - $httpBackend.when('GET', '/api').respond(401, ''); + it('should redirect to /auth/logout and add an unauthorized toast message ', inject( + function($http, $httpBackend, $window, $injector, $rootScope) { + $window.WEBROOT = '/dashboard/'; + $httpBackend.when('GET', '/api').respond(401, ''); - $http.get('/api').error(function() { - expect($window.location.replace).toHaveBeenCalledWith('/dashboard/auth/logout'); - }); - $httpBackend.flush(); - })); + var toastService = $injector.get('horizon.framework.widgets.toast.service'); + spyOn(toastService, 'add'); + + spyOn($rootScope, '$broadcast').and.callThrough(); + + $http.get('/api').error(function() { + expect(toastService.add).toHaveBeenCalled(); + expect($rootScope.$broadcast).toHaveBeenCalled(); + expect($window.location.replace).toHaveBeenCalledWith('/dashboard/auth/logout'); + }); + $httpBackend.flush(); + }) + ); + }); + + describe('when forbidden', function() { + it('should redirect to /auth/logout and add a forbidden toast message ', inject( + function($http, $httpBackend, $window, $injector, $rootScope) { + $window.WEBROOT = '/dashboard/'; + $httpBackend.when('GET', '/api').respond(403, ''); + + var toastService = $injector.get('horizon.framework.widgets.toast.service'); + spyOn(toastService, 'add'); + + spyOn($rootScope, '$broadcast').and.callThrough(); + + $http.get('/api').error(function() { + expect(toastService.add).toHaveBeenCalled(); + expect($rootScope.$broadcast).toHaveBeenCalled(); + expect($window.location.replace).toHaveBeenCalledWith('/dashboard/auth/logout'); + }); + $httpBackend.flush(); + }) + ); }); }); })(); diff --git a/horizon/static/framework/widgets/toast/toast.service.js b/horizon/static/framework/widgets/toast/toast.service.js index e62f3e446e..90822ffdf8 100644 --- a/horizon/static/framework/widgets/toast/toast.service.js +++ b/horizon/static/framework/widgets/toast/toast.service.js @@ -45,6 +45,7 @@ types: {}, add: add, get: get, + find: find, cancel: cancel, clearAll: clearAll, clearErrors: clearErrors, @@ -118,6 +119,20 @@ return toasts; } + /** + * find a matching existing toast based on type and message + * + * @param type type of the message + * @param msg localized message of the toast + * @returns {*} return toast object if find matching one + */ + function find(type, msg) { + return toasts.find(function(toast) { + var toastType = (type === 'error' ? 'danger' : type); + return (toast.type === toastType && toast.msg.localeCompare(msg) === 0); + }); + } + /** * Remove all toasts. */ diff --git a/horizon/static/framework/widgets/toast/toast.spec.js b/horizon/static/framework/widgets/toast/toast.spec.js index e7c33c32de..3e6bac36b5 100644 --- a/horizon/static/framework/widgets/toast/toast.spec.js +++ b/horizon/static/framework/widgets/toast/toast.spec.js @@ -67,6 +67,18 @@ expect(service.get()[0].type).toBe('danger'); }); + it('should find the added toast message', function() { + service.add('error', dangerMsg); + var toast = service.find('error', dangerMsg); + expect(toast.type).toBe('danger'); + expect(toast.msg).toBe(dangerMsg); + + service.add('success', successMsg); + toast = service.find('success', successMsg); + expect(toast.type).toBe('success'); + expect(toast.msg).toBe(successMsg); + }); + it('should provide a function to clear all toasts', function() { service.add('success', successMsg); service.add('success', successMsg); diff --git a/horizon/static/framework/widgets/wizard/wizard.controller.js b/horizon/static/framework/widgets/wizard/wizard.controller.js index e9f586e36e..8f9227c1ca 100644 --- a/horizon/static/framework/widgets/wizard/wizard.controller.js +++ b/horizon/static/framework/widgets/wizard/wizard.controller.js @@ -27,7 +27,8 @@ '$scope', '$q', 'horizon.framework.widgets.wizard.labels', - 'horizon.framework.widgets.wizard.events' + 'horizon.framework.widgets.wizard.events', + 'horizon.framework.events' ]; /** @@ -36,7 +37,8 @@ * @description * Controller used by 'wizard' */ - function WizardController($scope, $q, wizardLabels, wizardEvents) { + function WizardController($scope, $q, wizardLabels, wizardEvents, frameworkEvents) { + var ctrl = this; var viewModel = $scope.viewModel = {}; var initTask = $q.defer(); @@ -55,6 +57,10 @@ $scope.switchTo = switchTo; $scope.showError = showError; + ctrl.toggleHelpBtn = toggleHelpBtn; + ctrl.onInitSuccess = onInitSuccess; + ctrl.onInitError = onInitError; + /*eslint-enable angular/controller-as */ viewModel.btnText = extend({}, wizardLabels, $scope.workflow.btnText); @@ -81,7 +87,7 @@ from: $scope.currentIndex, to: index }); - toggleHelpBtn(index); + ctrl.toggleHelpBtn(index); /*eslint-disable angular/controller-as */ $scope.currentIndex = index; $scope.openHelp = false; @@ -118,9 +124,13 @@ } function onInitSuccess() { + if (viewModel.hasError) { + return; + } + $scope.$broadcast(wizardEvents.ON_INIT_SUCCESS); if (steps.length > 0) { - toggleHelpBtn(0); + ctrl.toggleHelpBtn(0); } } @@ -128,6 +138,12 @@ $scope.$broadcast(wizardEvents.ON_INIT_ERROR); } + $scope.$on(frameworkEvents.FORCE_LOGOUT, function(evt, arg) { + viewModel.hasError = true; + viewModel.errorMessage = arg; + return; + }); + function toggleHelpBtn(index) { // Toggle help icon button if a step's helpUrl is not defined if (angular.isUndefined(steps[index].helpUrl)) { diff --git a/horizon/static/framework/widgets/wizard/wizard.controller.spec.js b/horizon/static/framework/widgets/wizard/wizard.controller.spec.js new file mode 100644 index 0000000000..d52538d663 --- /dev/null +++ b/horizon/static/framework/widgets/wizard/wizard.controller.spec.js @@ -0,0 +1,96 @@ +/* + * (c) Copyright 2017 SUSE Linux + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +(function() { + 'use strict'; + + describe("WizardController", function() { + var ctrl, scope, wizardLabels, wizardEvents, frameworkEvents, rootScope; + beforeEach(module('horizon.framework')); + beforeEach(inject(function($controller, $rootScope, $injector, $q) { + scope = $rootScope.$new(); + rootScope = $rootScope; + wizardLabels = $injector.get('horizon.framework.widgets.wizard.labels'); + wizardEvents = $injector.get('horizon.framework.widgets.wizard.events'); + frameworkEvents = $injector.get('horizon.framework.events'); + ctrl = $controller('WizardController', { + $scope: scope, + $q: $q, + wizardLabels: wizardLabels, + wizardEvents: wizardEvents, + frameworkEvents: frameworkEvents + }); + scope.$apply(); + })); + + it('is defined', function() { + expect(ctrl).toBeDefined(); + }); + + it('viewModel is defined', function() { + expect(scope.viewModel).toBeDefined(); + }); + + it('call switchTo', function() { + spyOn(ctrl, 'toggleHelpBtn'); + spyOn(scope, '$broadcast'); + scope.switchTo(1); + scope.$apply(); + expect(ctrl.toggleHelpBtn).toHaveBeenCalled(); + expect(scope.currentIndex).toBe(1); + expect(scope.openHelp).toBe(false); + expect(scope.$broadcast).toHaveBeenCalled(); + }); + + it('call showError', function() { + spyOn(scope, 'showError').and.callThrough(); + scope.showError('in valid'); + scope.$apply(); + expect(scope.viewModel.hasError).toBe(true); + expect(scope.viewModel.errorMessage).toBe('in valid'); + }); + + it('call onInitSuccess with logout event', function() { + rootScope.$broadcast(frameworkEvents.FORCE_LOGOUT, 'logout'); + ctrl.onInitSuccess(); + scope.$apply(); + expect(scope.viewModel.hasError).toBe(true); + }); + + it('call onInitSuccess without logout event', function() { + spyOn(scope, '$broadcast'); + ctrl.onInitSuccess(); + scope.$apply(); + expect(scope.viewModel.hasError).toBe(false); + expect(scope.$broadcast).toHaveBeenCalledWith(wizardEvents.ON_INIT_SUCCESS); + }); + + it('call onInitError with logout event', function() { + rootScope.$broadcast(frameworkEvents.FORCE_LOGOUT, 'logout'); + ctrl.onInitError(); + scope.$apply(); + expect(scope.viewModel.hasError).toBe(true); + }); + + it('call onInitError without logout event', function() { + spyOn(scope, '$broadcast'); + ctrl.onInitError(); + scope.$apply(); + expect(scope.viewModel.hasError).toBe(false); + expect(scope.$broadcast).toHaveBeenCalledWith(wizardEvents.ON_INIT_ERROR); + }); + }); + +})();