The following is a login pattern that I’ve been
using in all of my single page AngularJS applications (SPA). We recently
introduced it into a client project at Brewhouse, so I thought I would share.
Login on an SPA can be tough and it’s important
that your integration doesn’t interfere with the flow of your application. It
can become a crutch if you’re not really sure whether the user is authenticated
and resorting to explicit checks all over the place. Here we lay the groundwork
for a login modal that is called implicitly only when it’s needed, without
almost any knowledge from the rest of the application.
The conditions
There are only three points in the application
where the login modal should appear:
1.
When I’m on a welcome page and I
click “Login”.
2.
When I am not logged in and I attempt
to visit a page that requires login, e.g. my profile page.
3.
When I attempt to make a request that
requires a login, e.g. my session has expired whilst I’m
attempting to post something.
We will ignore the first condition as it is
trivial.
Determining which pages require a logged in user
There are many advantages to using ui-router over ngRoute and in this case we will be
taking advantage of how we can attach additional properties to a route and how
those properties will cascade down to the children of that route. In the
following code, we make sure to set a requireLogin property for each state.
//
routes.js
app.config(function ($stateProvider) {
$stateProvider
.state('welcome', {
url: '/welcome',
// ...
data: {
requireLogin: false
}
})
.state('app', {
abstract: true,
// ...
data: {
requireLogin: true //
this property will apply to all children of 'app'
}
})
.state('app.dashboard', {
// child state of `app`
// requireLogin ===
true
})
});
Clearly, for routes that do not require a login we
set requireLogin to
false. We could leave this undefined, but I prefer to show my intent.
Capturing attempted state changes
At this point, we can start capturing attempted
state changes and inspecting them for our requireLogin property. The following shows
how we are going to subscribe to ui-router’s $stateChangeStart event.
//
app.js
app.run(function ($rootScope) {
$rootScope.$on('$stateChangeStart', function (event, toState, toParams) {
var requireLogin = toState.data.requireLogin;
if (requireLogin && typeof $rootScope.currentUser === 'undefined') {
event.preventDefault();
//
get me a login modal!
}
});
});
As you can see, if the route requires a login
and $rootScope.currentUser is not yet set, we will prevent the attempted
state change and show the modal. (I prefer to attach thecurrentUser to the $rootScope only because it’s an
application-wide concept. All new scopes will inherit the currentUser property and I can always call
it from the view. Whether or not you choose to do the same is for another
discussion.)
Additionally, it should be noted that I won’t be
covering how you might store or retrieve the currentUser between page refreshes.
Building the loginModal service
In this post, I will wrap the AngularUI Bootstrap Modal;
however, you can use any modal library - I have even used the same pattern in
an ionic app with
the $ionicModal service.
An important caveat here is that we must be able to tell the modal what to do
after authentication is successful or cancelled. Preferably this is done with a
promise, so that multiple actions can be chained together. These are anonymous
functions that we will use to re-submit state changes and requests after
authenticating. The following is a example of how I might write such a wrapper
for the $modal service.
//
loginModal.js
app.service('loginModal', function ($modal, $rootScope) {
function assignCurrentUser (user) {
$rootScope.currentUser = user;
return user;
}
return function() {
var instance = $modal.open({
templateUrl: 'views/loginModalTemplate.html',
controller: 'LoginModalCtrl',
controllerAs: 'LoginModalCtrl'
})
return instance.result.then(assignCurrentUser);
};
});
There isn’t much magic in this service. It’s
important to point out that $modal.openreturns a promise which is either resolved or
rejected when the modal is closed or dismissed. When a scope is instantiated
for the LoginModalCtrl, the $close() and$dismiss() methods are attached to it.
//
LoginModalCtrl.js
app.controller('LoginModalCtrl', function ($scope, UsersApi) {
this.cancel = $scope.$dismiss;
this.submit = function (email, password) {
UsersApi.login(email, password).then(function (user) {
$scope.$close(user);
});
};
});
After a successful login, the modal is closed,
the $rootScope.currentUser is set, and the on success action will be
triggered. If the user clicks the cancel button or attempts to click away from
the modal, the on cancel action will step in.
Checking in with the state change
At this point the loginModal can be added to the event
listener. What makes this approach so slick is being able to capture the
desired state transitions in anonymous functions. After authenticating, the SPA
will implicitly redirect to the requested state as you would expect with a
traditional web app. Additionally, notice how we will redirect to the welcome
screen if a dismissal happens.
//
app.js
app.run(function ($rootScope, $state, loginModal) {
$rootScope.$on('$stateChangeStart', function (event, toState, toParams) {
var requireLogin = toState.data.requireLogin;
if (requireLogin && typeof $rootScope.currentUser === 'undefined') {
event.preventDefault();
loginModal()
.then(function () {
return $state.go(toState.name, toParams);
})
.catch(function () {
return $state.go('welcome');
});
}
});
});
Handling unauthorized requests
Storing a currentUser on the client for the lifetime of an SPA is
not without its challenges. Data that is persisted in memory may become out of
sync with the server and a 401 will mean your client-side authentication is
no longer valid. Perhaps the session has expired or perhaps you were never
logged in to begin with. This is obviously a good time to capture the request
params and show the login modal.
AngularJS makes intercepting incoming
responses pretty easy with interceptors. Below we call the login
modal when we receive a 401 response. (For the sake of brevity, we’ll
assume that a 401 is the only precondition required to verify
that a user is not logged in.)
//
app.js
app.config(function ($httpProvider) {
$httpProvider.interceptors.push(function ($timeout, $q, $injector) {
var loginModal, $http, $state;
// this trick must be
done so that we don't receive
// `Uncaught Error:
[$injector:cdep] Circular dependency found`
$timeout(function () {
loginModal = $injector.get('loginModal');
$http = $injector.get('$http');
$state = $injector.get('$state');
});
return {
responseError: function (rejection) {
if (rejection.status !== 401) {
return rejection;
}
var deferred = $q.defer();
loginModal()
.then(function () {
deferred.resolve( $http(rejection.config) );
})
.catch(function () {
$state.go('welcome');
deferred.reject(rejection);
});
return deferred.promise;
}
};
});
});
With this code, a 401 rejected request will be
re-submitted when a successful authentication occurs. In fact, the entity that
made the original request will hang until our secondary request is completed.
It’s completely oblivious to the fact that the original request was even rejected!
Conclusions
If you’re not totally jacked up right
now, I feel bad for you. The modal is so clean and separated. It’s effectively
a black box. What more could you want? The use of anonymous callbacks to
capture the user’s intent is key to this approach. With very little code we’ve
integrated a login modal that is almost completely detatched from the rest of
our application, helping to minimize the number of moving parts required for
authentication.
Please let us know what you think. Do you have
suggestions for how this could be improved further? Tweet at me or at us or leave a comment below.
//
routes.js
app.config(function ($stateProvider) {
$stateProvider
.state('welcome', {
url: '/welcome',
// ...
data: {
requireLogin: false
}
})
.state('app', {
abstract: true,
// ...
data: {
requireLogin: true //
this property will apply to all children of 'app'
}
})
.state('app.dashboard', {
// child state of `app`
// requireLogin ===
true
})
});
//
app.js
app.run(function ($rootScope) {
$rootScope.$on('$stateChangeStart', function (event, toState, toParams) {
var requireLogin = toState.data.requireLogin;
if (requireLogin && typeof $rootScope.currentUser === 'undefined') {
event.preventDefault();
//
get me a login modal!
}
});
});
//
loginModal.js
app.service('loginModal', function ($modal, $rootScope) {
function assignCurrentUser (user) {
$rootScope.currentUser = user;
return user;
}
return function() {
var instance = $modal.open({
templateUrl: 'views/loginModalTemplate.html',
controller: 'LoginModalCtrl',
controllerAs: 'LoginModalCtrl'
})
return instance.result.then(assignCurrentUser);
};
});
//
LoginModalCtrl.js
app.controller('LoginModalCtrl', function ($scope, UsersApi) {
this.cancel = $scope.$dismiss;
this.submit = function (email, password) {
UsersApi.login(email, password).then(function (user) {
$scope.$close(user);
});
};
});
//
app.js
app.run(function ($rootScope, $state, loginModal) {
$rootScope.$on('$stateChangeStart', function (event, toState, toParams) {
var requireLogin = toState.data.requireLogin;
if (requireLogin && typeof $rootScope.currentUser === 'undefined') {
event.preventDefault();
loginModal()
.then(function () {
return $state.go(toState.name, toParams);
})
.catch(function () {
return $state.go('welcome');
});
}
});
});
//
app.js
app.config(function ($httpProvider) {
$httpProvider.interceptors.push(function ($timeout, $q, $injector) {
var loginModal, $http, $state;
// this trick must be
done so that we don't receive
// `Uncaught Error:
[$injector:cdep] Circular dependency found`
$timeout(function () {
loginModal = $injector.get('loginModal');
$http = $injector.get('$http');
$state = $injector.get('$state');
});
return {
responseError: function (rejection) {
if (rejection.status !== 401) {
return rejection;
}
var deferred = $q.defer();
loginModal()
.then(function () {
deferred.resolve( $http(rejection.config) );
})
.catch(function () {
$state.go('welcome');
deferred.reject(rejection);
});
return deferred.promise;
}
};
});
});
This is a really good read for me. Must agree that you are one of the best bloggers I ever saw. Thanks for posting this informative article.
ReplyDeletevoyance par mail gratuit