Chapter 6: User Accounts

User Accounts

As a beginning developer, one of the coolest things about Meteor is the ability to quickly add a user accounts system to any project. With just a couple of commands, we can allow users to both register and login:

meteor add accounts-password
meteor add accounts-ui

It’s all sorts of magical.

But the problem with this approach is that we end up with an interface that looks like every other Meteor application that’s built by a beginner, and that doesn’t exactly throw off the best vibe to users of the application.

To combat this, we can continue to rely on various features that are provided to us by Meteor, but we can also:

  • Design a custom registration form.
  • Design a custom login form.
  • Learn a bit about error-handling.

None of this is even that difficult. There’s just a few more steps involved when compares to what we covered in Your First Meteor Application.

accounts-password

When building this user accounts system, we’re going to do a lot of the work ourselves, but there’s no reason for us to reinvent the wheel from scratch. As such, we’re still going to use the “accounts-password” package.

To install this package, run the following command:

meteor add accounts-password

As explained in the previous book, this package will:

  • Create a “Meteor.users” collection to store the user’s data.
  • Provide the foundation for an accounts system that relies on an email and a password for registration and logging-in.
  • Provide us with a number of useful functions to speed up development.

This means we can focus on building the parts of the application that the users actually see while focusing as little as possible on the boring stuff that the developers of Meteor have already taken care of.

Registration

Right off the bat, we’re going to focus on creating a registration form that allows users to sign up for an account within the application.

To do this, add a registration form to the “register” template:

<template name="register">
    <h2>Register</h2>
    <form class="register">
        <p>Email: <input type="email" name="email"></p>
        <p>Password: <input type="password" name="password"></p>
        <p><input type="submit" value="Register"></p>
    </form>
</template>

Here, there’s a few things going on with this form:

  • The form has the class attribute defined as “register”.
  • There’s two fields within the form: “email” and “password”.
  • Both of the form’s fields have name attributes.

At this stage, the interface should resemble:

Inside the JavaScript file, create an event for this form:

Template.register.events({
    'submit form': function(){
        // code goes here
    }
});

Then, within the event, use the preventDefault function to prevent the default functionality of the form, and store the values of the “email” and “password” form fields within a pair of variables:

Template.register.events({
    'submit form': function(event){
        event.preventDefault();
        var email = $('[name=email]').val();
        var password = $('[name=password]').val();
    }
});

You might assume that the next step is to use the insert function to create a document within the “Meteor.users” collection:

Meteor.users.insert({
    email: email,
    password: password
});

But because we’ve installed the “accounts-password” package, we don’t need to do anything so fragile.

Instead, we can use an Accounts.createUser function that:

  1. Checks to make sure the user is not already registered.
  2. Encrypts the user’s password before it reaches the server.
  3. Logs the user into their account after registration.

All we have to do is place the Accounts.createUser function within the event and pass through the values of the “email” and “password” fields:

Template.register.events({
    'submit form': function(event){
        event.preventDefault();
        var email = $('[name=email]').val();
        var password = $('[name=password]').val();
        Accounts.createUser({
            email: email,
            password: password
        });
    }
});

Then save the file, navigate to the applications “Register” page, fill out an email and a password, and click the “Register” button.

There won’t be any visual feedback to confirm that an account has been created, but to see that one has, use the find and fetch functions on the “Meteor.users” collection:

Meteor.users.find().fetch();

You should see a document in the collection that holds the data of the account we just created:

When a user registers though, it doesn’t make sense for them to remain on the “Register” page. Instead, they should be redirected to the home page.

This can be achieved with the Router.go function:

Template.register.events({
    'submit form': function(event){
        event.preventDefault();
        var email = $('[name=email]').val();
        var password = $('[name=password]').val();
        Accounts.createUser({
            email: email,
            password: password
        });
        Router.go('home');
    }
});

Logging-out

Like I mentioned earlier, the Accounts.createUser function logs the user into the application after registration. This means we’re actually logged-in at the moment, although there’s no visual feedback to confirm this.

To kill two birds with one stone, we’ll create a “Logout” link. This will show that we’re currently logged-in, while also allowing us to logout.

Inside the “navigation” template, create a “logout” link:

<ul>
    <li><a href="{{pathFor route='home'}}">Home</a></li>
    <li><a href="#" class="logout">Logout</a></li>
    <li><a href="{{pathFor route='register'}}">Register</a></li>
    <li><a href="{{pathFor route='login'}}">Login</a></li>
</ul>

This link should have the “#” symbol within the href attribute and a class attribute that’s set to “logout”.

Then wrap this link within a conditional:

<ul>
    <li><a href="{{pathFor route='home'}}">Home</a></li>
    {{#if currentUser}}
        <li><a href="#" class="logout">Logout</a></li>
    {{/if}}
    <li><a href="{{pathFor route='register'}}">Register</a></li>
    <li><a href="{{pathFor route='login'}}">Login</a></li>
</ul>

This conditional will only return true if the current user is logged-in, so the “Logout” link will only display to currently logged-in users.

You can also use the else clause to hide the “Register” and “Login” links when the current user is logged-in:

<ul>
    <li><a href="{{pathFor route='home'}}">Home</a></li>
    {{#if currentUser}}
        <li><a href="#" class="logout">Logout</a></li>
    {{else}}
        <li><a href="{{pathFor route='register'}}">Register</a></li>
        <li><a href="{{pathFor route='login'}}">Login</a></li>
    {{/if}}
</ul>

The interface should then resemble:

Inside the JavaScript file, create an event for this link that uses the preventDefault function, so we have complete control over the link:

Template.navigation.events({
    'click .logout': function(event){
        event.preventDefault();
    }
});

Then, within the event, use a Meteor.logout function that is provided to us by the “accounts-password” package:

Template.navigation.events({
    'click .logout': function(event){
        event.preventDefault();
        Meteor.logout();
    }
});

You can now click the “Logout” link to logout from the application.

After logging-out, the “Logout” link should disappear and be replaced by the “Register” and “Login” links.

In addition to this, we should redirect users to the “Login” page as soon as they logout. This can be achieved with the Router.go function:

Template.navigation.events({
    'click .logout': function(event){
        event.preventDefault();
        Meteor.logout();
        Router.go('login');
    }
});

To allow users to log back into the application, create the following form within the “login” template:

<template name="login">
    <h2>Login</h2>
    <form class="login">
        <p>Email: <input type="email" name="email"></p>
        <p>Password: <input type="password" name="password"></p>
        <p><input type="submit" value="Login"></p>
    </form>
</template>

Here, the form is very similar to the “register” form:

  • The form has the class attribute defined as “register”.
  • There’s two fields within the form: “email” and “password”.
  • Both of the form fields have name attributes.

Then, inside the JavaScript file, create a “submit form” event for the “login” template:

Template.login.events({
    'submit form': function(event){
        event.preventDefault();
        var email = $('[name=email]').val();
        var password = $('[name=password]').val();
    }
});

Here, we’re preventing the default behavior of the form and storing the values of the “email” and “password” fields in a pair of variables.

Then, to initiate the login process, we can use a loginWithPassword function that is provided to us by the “accounts-password” package:

Template.login.events({
    'submit form': function(event){
        event.preventDefault();
        var email = $('[name=email]').val();
        var password = $('[name=password]').val();
        Meteor.loginWithPassword(email, password);
    }
});

You can now use this form to log back into the application:

Note: At the moment, we’re not using the Router.go function to redirect users after logging-in, but that’s something we’ll fix in a moment.

Error Handling, Part 1

Something we haven’t considered so far is error handling, but what happens if a user tries to login with incorrect details?

At the moment, the login process will silently fail if the user enters an incorrect email and password, which will make for a fairly frustrating user experience.

To prepare for this problem, pass a callback function into the loginWithPassword function:

Meteor.loginWithPassword(email, password, function(){
    // code goes here
});

The code within this function will execute as soon as the user has initiated the login process, which can be seen with a console.log statement:

Meteor.loginWithPassword(email, password, function(){
    console.log("You initiated the login process.");
});

Next, pass a parameter of “error” through the function’s parentheses and output the value of “error” with a console.log statement:

Meteor.loginWithPassword(email, password, function(error){
    console.log(error);
});

Then try to login with incorrect details.

You should see an error appear within the Console:

To make the output a little friendlier to read though, extract the “reason” property from the “error” object:

Meteor.loginWithPassword(email, password, function(error){
    console.log(error.reason);
});

The errors will now appear as strings:

These errors are provided to us by the “accounts-password” package, and in this case, there are three possible errors that can be returned:

  • “Match failed”, if both of the form fields are empty.
  • “User not found”, if the email doesn’t belong to a registered user.
  • “Incorrect password”, if the user is found but their password is wrong.

Later on, we’ll make it so the errors appear within the interface, but for the moment, it’s fine that they’re just showing up in the Console since we’re more interested in the behavior of the login form.

If the user tries to login with incorrect details, the error should appear in the Console. But if the user logs in with correct details, then they should be redirected to the “home” route.

This can be achieved with a simple conditional inside the callback function:

Meteor.loginWithPassword(email, password, function(error){
    if(error){
        console.log(error.reason);
    } else {
        Router.go("home");
    }
});

Here, we’re checking if an error is returned when the user attempts to login. If an error is returned, then the “reason” property for that error appears in the Console. But if no error is returned, the user is redirected to the “home” route with the help of the Router.go function.

Error Handling, Part 2

Errors can also be experienced when a user registers for an account, but at the moment, the application won’t respond to those errors.

What, for instance, happens if a user tries to register for an account using an email address that’s already attached to a registered user?

The registration process will fail, since the “accounts-password” package won’t allow for two accounts with the same email address, but there’ll be no sign that it failed. Once again, it will fail silently.

To fix this, pass a callback function into the Accounts.createUser function:

Accounts.createUser({
    email: email,
    password: password
}, function(error){
    // code goes here    
});

Then, within this function, use the exact same conditional that we created a moment ago:

Accounts.createUser({
    email: email,
    password: password
}, function(error){
    if(error){
        console.log(error.reason); // Output error if registration fails
    } else {
        Router.go("home"); // Redirect user if registration succeeds
    }
});

As a result, if an error occurs during registration, that error will appear in the Console. But if an error doesn’t occur, then the registration process will succeed and the user will be redirected to the home page.

In terms of errors, there are three that might occur in this context:

  • “Email already exists”, if the email is registered to another user.
  • “Need to set a username or email”, if the email field is empty.
  • “Password may not be empty”, if the password field is empty.

This remains a fairly basic example of error handling, but we’ll explore a more complete solution throughout the rest of this book.

Restricted Content

At this point, users can register, login, and logout, but none of the lists or tasks within the database are attached to any particular user. This means that every user has the same level of control over every list and every task.

Before we fix this problem, stop the local server with the CTRL + C hot-key and run the following command:

meteor reset

Here, we’re resetting the project’s database.

Why?

We’re about to make it so all newly created tasks and lists are attached to registered users, and since all of the current tasks and lists will remain un-attached to any particular user, the current data will become redundant.

It makes sense, then, to wipe the database and start over.

Afterwards, launch the local server and register for an account within the application. Do not, however, create a new list or a new task. There are a couple of things we need to take care of first.

At this point, the “trick” is to attach the currently logged-in user’s ID to the document of every list and every task that is created.

To achieve this, return to the “submit form” event for the “addList” template and create a “currentUser” variable:

'submit form': function(event){
    event.preventDefault();
    var listName = $('[name=listName]').val();
    var currentUser = Meteor.userId();
    Lists.insert({
        name: listName
    }, function(error, results){
        Router.go('listPage', { _id: results });
    });
    $('[name=listName]').val('');
}

Here, this “currentUser” variable holds the value of Meteor.userId, which in itself returns the unique ID of the currently logged-in user.

Then, within the insert function, define a “createdBy” field and pass through the “currentUser” variable as the value for that field:

'submit form': function(event){
    event.preventDefault();
    var listName = $('[name=listName]').val();
    var currentUser = Meteor.userId();
    Lists.insert({
        name: listName,
        createdBy: currentUser
    }, function(error, results){
        Router.go('listPage', { _id: results });
    });
    $('[name=listName]').val('');
}

Whenever a user creates a list, the unique ID of that user will now be stored within that list’s document. This is the simplest way to “attach” a list to a user.

To test this, save the file, switch back to the browser, create a list while logged into an account, and then use the find and fetch functions:

Lists.find().fetch();

You should see something like the following:

Then repeat this process for the “addTodo” events block:

'submit form': function(event){
    event.preventDefault();
    var todoName = $('[name="todoName"]').val();
    var currentUser = Meteor.userId();
    var currentList = this._id;
    Todos.insert({
        name: todoName,
        completed: false,
        createdAt: new Date(),
        createdBy: currentUser,
        listId: currentList
    });
    $('[name="todoName"]').val('');
}

This will ensure that, when individual tasks are created, they’re attached to the currently logged-in users.

But at the moment, if we were to log into a different account, all of the same tasks and lists would continue to appear within the interface.

Why?

Because we haven’t specified that we only want to display the tasks and lists that specifically belong to the currently logged-in user.

To implement this feature, return to the “list” helper that’s attached to the “lists” template:

Template.lists.helpers({
    'list': function(){
        return Lists.find({}, {sort: {name: 1}})
    }
});

…and from within the find function, specify that we only want to retrieve the lists that belong to the currently logged-in user:

Template.lists.helpers({
    'list': function(){
        var currentUser = Meteor.userId();
        return Lists.find({ createdBy: currentUser }, {sort: {name: 1}})
    }
});

Then find the “todo” helper that’s attached to the “todos” template:

Template.todos.helpers({
    'todo': function(){
        var currentList = this._id;
        return Todos.find({ listId: currentList }, {sort: {createdAt: -1}})
    }
});

…and make the same modification:

Template.todos.helpers({
    'todo': function(){
        var currentList = this._id;
        var currentUser = Meteor.userId();
        return Todos.find({ listId: currentList, createdBy: currentUser }, {sort: {createdAt: -1}})
    }
});

This ensures that both lists and tasks only appear if they’re owned by the currently logged-in user.

If we logout though, the “lists” template will continue to appear:

This problem can be solved with a conditional inside the “main” template:

<template name="main">
    <h1>Todos</h1>
    {{> navigation}}
    {{#if currentUser}}
        {{> lists}}
    {{/if}}
    {{> yield}}
    <hr />
    <p>Copyright &copy; Todos, 2014-2015.</p>
</template>

The “lists” template will not only appear for logged-in users.