(Re)Learning Backbone Part 10

Logging In

When we last left off, we had enforced authentication on our users endpoints and provided an endpoint to authenticate against. Today, we are going to add a Login screen and route to present our credentials to the backend. Upon success, we'll store the returned token and present it on all subsequent requests.

Let's start by introducing a globals object. I like to create an object to hang constant values off of that are needed throughout the app.

/app/globals.js

define(function (require) {

    'use strict';

    return {
        auth: {
            TOKEN_KEY: 'authToken',
            USER_KEY: 'userId'
        },
        urls: {
            AUTHENTICATE: '/api/authenticate'
        }
    }
});

Here we've defined two keys we are going to use to store and retrieve authentication data and the URL we need to hit to authenticate. For entities, like User, we store the URL with the model, but for URLs that don't neatly map to a model, I like to keep them here in globals.

So, with our authentication in place, if we try to hit /#users, we'll get a 401 error when we call the /api/users endpoint, and our screen will only show our header and our footer. Let's have our app respond to 401 errors by redirecting the user to a login screen.

In /app/main.js, before we create our Router, add the following code:

$.ajaxSetup({
    statusCode: {
        401: function () {
            console.log('AJAX Handler - 401 Error Received');
            mediator.trigger('router:navigate', {route: 'login', options: {trigger: true}});
        }
    }
});

We are using jQuery to make a global change to our AJAX handling. Now, any AJAX call that gets a 401 error will get trapped and we'll trigger the router to take us to our login page. Let's update our router to handle this.

routes: {  
        '': 'home',
        'home': 'home',
        'users': 'users',
        'login': 'login'
    },

login: function() {  
    console.log('routing - login');
    require(['login/view'], function(View){
        mediator.trigger('page:displayView', new View());
    });
}

We've updated our route table to include a login path, and created a handler for it. This should seem familiar by now. We'll need a template and a view.

/app/login/template.hbs

<div class="well">  
    <h4><b>Login</b></h4>
</div>  
<div class="alert alert-warning" style="display: none;"></div>  
<form class="form-horizontal login-form">  
    <div class="form-group">
        <label for="email" class="col-lg-2 control-label">Email</label>

        <div class="col-lg-10">
            <input type="text" class="form-control" id="email" placeholder="Email">
        </div>
    </div>
    <div class="form-group">
        <label for="password" class="col-lg-2 control-label">Password</label>

        <div class="col-lg-10">
            <input type="password" class="form-control" id="password" placeholder="Password">
        </div>
    </div>
    <button type="button" class="btn btn-primary js-login pull-right">Login</button>
</form>  

This is a simple form that will allow us to collect an email address and a password. It also has an alert div we will use to display error messages and a submit button with the class js-login which will trigger the submission action.

/app/login/view.js

define(function (require) {

    'use strict';

    var Backbone = require('backbone');
    var globals = require('globals');
    var mediator = require('mediator');
    var template = require('hbs!login/template');

    return Backbone.View.extend({
        el: '#content',

        events: {
            'click .js-login': 'login'
        },

        render: function () {
            this.$el.html(template());
            return this;
        },

        login: function (e) {
            e.preventDefault();
            var formValues = {
                email: this.$('#email').val(),
                password: this.$('#password').val()
            };
            this.$('.alert').hide();
            console.log('login with ', formValues);
            $.ajax({
                url: globals.urls.AUTHENTICATE,
                type: 'POST',
                dataType: 'json',
                data: formValues,
                success: function(response){
                    if (response.success){
                        window.localStorage.setItem(globals.auth.TOKEN_KEY, response.token);
                        window.localStorage.setItem(globals.auth.USER_KEY, response._id);
                        mediator.trigger('router:navigate', {route:'home', options: {trigger: true}});
                    } else {
                        self.$('.alert-warning').text(response.message).show();
                    }
                }
            });
        }
    });
});

This is a standard view that listens for a click event on our submit button. When it receives one, we go to the login method.

Here, we get the values from the form fields. If the alert box is visible, we hide it. Finally, we make an AJAX call to our authentication endpoint, using the URL we defined in our globals.

If you remember, our authentication routine does not send back an HTTP error code. This allows us to handle any errors here. In our case, the else block, when the response message success property is false, takes the message returned from the server, adds it to the alert box and makes it visible.

When we successfully enter our credentials, we use local storage to store the auth token and the user id that was returned to us. Finally, we navigate to the home screen. Later, we can add code to redirect the user to their original destination but for now, we'll go home.

So what happens if we try to go to /#users again. Another 401 error! Just because we logged in once, the server has no idea that it is still us unless we tell it. To do this, we will include that token we just received in the header of all our AJAX calls. Go back to where we trapped for the 401 error and update the handler

$.ajaxSetup({
    statusCode: {
        401: function (context) {
            mediator.trigger('router:navigate', {route: 'login', options: {trigger: true}});
        }
    },
       beforeSend: function (xhr) {
        var token = window.localStorage.getItem(globals.auth.TOKEN_KEY);
        xhr.setRequestHeader('x-access-token', token);
    }
});

We've added the beforeSend method. This will retrieve the token from local storage, and set the x-access-token header to that value. If you recall, that is where our authenticated routes look for the token when determining if the caller is authenticated. Now if we hit our users page, the token we previously stored will be sent with our call, we'll be authenticated, and the page will display properly.

If you need to try again, you can use your browser's dev tools to remove the token from local storage, effectively logging you out. Let's make this a little easier on ourselves. Later, we'll be customizing our header view with information specific to the current user. For now, let's add a logout link.

In /page/headerTemplate.hbs replace the [Navigation Elements] place holder with

<li><a href="#logout"><span class="fa fa-lg fa-times-circle-o"></span> Logout</a></li>  

and update your router

/app/router.js

routes: {  
    '': 'home',
    'home': 'home',
    'users': 'users',
    'login': 'login',
    'logout': 'logout'
},

logout: function() {  
    window.localStorage.removeItem(globals.auth.TOKEN_KEY);
    window.localStorage.removeItem(globals.auth.USER_KEY);    
    this.home();
 }

This simply deletes our authentication info and sends the user to the home page. If it seems a little messy that we are messing with local storage in different parts of our app, you're right. Later when we coordinate loading the currently authenticated user, we'll create some convenience methods that we can trigger with messages through the mediator.

Next Steps

When we load our app, we will want to make sure we have the user object associated with the currently logged in user. We've already stored the id, so it is just a matter of loading the user. With that User, we'll customize the header. Additionally, we relied on the 401 error to redirect us to the login page. We'll be adding an isAuthenticated check to the router itself so that we can redirect to the login screen without needing to make a server call round trip.

(You can check out the project at it's current state at commit: 8a0a2b3c06575454a7ffae2b03b579d3bc47acba)