tutorial // Nov 12, 2021

How to Wire Up User Accounts and Authenticated Routing in Joystick

How create user accounts in Joystick, log users in, and help them reset their password as well as how to create protected routes that redirect based on a user's logged in status.

How to Wire Up User Accounts and Authenticated Routing in Joystick

Getting Started

For this tutorial, we're going to use CheatCode's full-stack JavaScript framework, Joystick. Joystick brings together a front-end UI framework with a Node.js back-end for building apps.

To begin, we'll want to install Joystick via NPM. Make sure you're using Node.js 16+ before installing to ensure compatibility (give this tutorial a read first if you need to learn how to install Node.js or run multiple versions on your computer):

Terminal

npm i -g @joystick.js/cli

This will install Joystick globally on your computer. Once installed, next, let's create a fresh project:

Terminal

joystick create app

After a few seconds, you will see a message logged out to cd into your new project and run joystick start:

Terminal

cd app && joystick start

After this, your app should be running and we're ready to get started.

Adding some global CSS

Before we dig into the logic for our user accounts, real quick, we're going to add some global CSS to clean up our UI:

/index.css

*, *:before, *:after {
  box-sizing: border-box;
}

body {
  font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
  font-size: 16px;
  background: #fff;
  margin: 20px;
}

form {
  width: 100%;
  max-width: 400px;
}

.form-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  column-gap: 20px;
}

.form-field {
  margin-bottom: 20px;
}

label {
  font-size: 15px;
  font-weight: bold;
  display: block;
  margin-bottom: 10px;
  display: flex;
}

label a {
  display: inline-block;
  margin-left: auto;
  font-weight: normal;
  color: #aaa;
}

input {
  width: 100%;
  max-width: 100%;
  border: 1px solid #ddd;
  padding: 10px 15px;
  border-radius: 3px;
  font-size: 16px;
}

input:focus {
  outline: 0;
  border: 1px solid #0099ff;
  box-shadow: 0px 0px 0px 3px rgba(0, 153, 255, 0.3);
}

.input-hint {
  font-size: 14px;
  margin-bottom: 0px;
}

.input-hint.error {
  color: red;
}

button {
  padding: 10px 15px;
  font-size: 16px;
  background: #0099ff;
  color: #fff;
  border-radius: 3px;
  border: none;
}

Later in the tutorial, our UI will consist solely of forms used for managing a user's account. To make our UI easier to understand, above, we're adding some global CSS into the /index.css file at the root of our app. This file is automatically loaded by Joystick in the /index.html file at the root of our project (the base HTML template rendered for all pages in your app).

In addition to form styles, we've also added some simple resets for the box-sizing attribute (this ensures padding and margins are respected in the browser) and on the body element, set a default font, font-size, and have even added a small margin to the <body></body> so our content is offset from the browser edge a bit.

Adding routes and pages

Digging into the code, our goal is to wire up a set of pages for managing the entire lifecycle of an account. Now, we want to set up a series of routes on the server that will render the pages displayed to users in the browser:

  • /signup will render a form where users can create a new account.
  • /login will render a form where users can login to an existing account.
  • /recover-password will render a form where users can trigger a password reset request for an existing account.
  • /reset-password/:token will render a form where the user can enter a new password and update their user record in the database.

All routes in a Joystick app are passed to the node.app() function's options object, located in the /index.server.js file at the root of the project:

/index.server.js

import node from "@joystick.js/node";
import api from "./api";

node.app({
  api,
  routes: {
    "/dashboard": (req, res) => {
      res.render("ui/pages/dashboard/index.js", {
        layout: "ui/layouts/app/index.js",
      });
    },
    "/signup": (req, res) => {
      res.render("ui/pages/signup/index.js", {
        layout: "ui/layouts/app/index.js",
      });
    },
    "/login": (req, res) => {
      res.render("ui/pages/login/index.js", {
        layout: "ui/layouts/app/index.js",
      });
    },
    "/recover-password": (req, res) => {
      res.render("ui/pages/recoverPassword/index.js", {
        layout: "ui/layouts/app/index.js",
      });
    },
    "/reset-password/:token": (req, res) => {
      res.render("ui/pages/resetPassword/index.js", {
        layout: "ui/layouts/app/index.js",
      });
    },
    "*": (req, res) => {
      res.render("ui/pages/error/index.js", {
        layout: "ui/layouts/app/index.js",
        props: {
          statusCode: 404,
        },
      });
    },
  },
});

By default, when you run joystick create, the project template that's set up for you has two routes defined for us / and *. The former renders an example Joystick component and the latter renders the 404 or "error" page displayed when a matching route can't be found for the current URL.

For our work, we're going to begin by replacing the / route with a route that will act as a faux "logged in" page. In the code above, we're doing a few things:

  1. Swapping the / route with a /dashboard route and rendering back a page defined as a Joystick component at /ui/pages/dashboard/index.js.
  2. For each of the pages we outlined above, defining a route under the routes object passed to the options for node.app(). This is the function used by Joystick to start up an Express.js server for us. When that server starts up, each of the routes we list under routes is added as an HTTP GET route.
  3. For each route, rendering back a page defined as a Joystick component using @joystick.js/ui in the /ui/pages directory at the root of our app.

In order for this to work, we need to make sure all of our pages are defined in the /ui/pages directory.

New to Joystick and Joystick components? Check out this tutorial on how they work to get up to speed.

Next, let's go ahead and create some skeleton pages as placeholders (we'll spend the majority of the tutorial wiring these up afterward):

/ui/pages/dashboard/index.js

import ui from '@joystick.js/ui';

const Dashboard = ui.component({
  render: () => {
    return `
      <div>
        <p>Dashboard</p>
      </div>
    `;
  },
});

export default Dashboard;

/ui/pages/signup/index.js

import ui from '@joystick.js/ui';

const Signup = ui.component({
  render: () => {
    return `
      <div>
        <p>Signup</p>
      </div>
    `;
  },
});

export default Signup;

/ui/pages/login/index.js

import ui from '@joystick.js/ui';

const Login = ui.component({
  render: () => {
    return `
      <div>
        <p>Login</p>
      </div>
    `;
  },
});

export default Login;

/ui/pages/recoverPassword/index.js

import ui from '@joystick.js/ui';

const RecoverPassword = ui.component({
  render: () => {
    return `
      <div>
        <p>RecoverPassword</p>
      </div>
    `;
  },
});

export default RecoverPassword;

/ui/pages/resetPassword/index.js

import ui from '@joystick.js/ui';

const ResetPassword = ui.component({
  render: () => {
    return `
      <div>
        <p>ResetPassword</p>
      </div>
    `;
  },
});

export default ResetPassword;

With those in place, now, if we load up our app in the browser at http://localhost:2600 and check out each of the routes we've defined up above, we should see our placeholder components.

NOTE: As of writing Joystick is in beta and may have crashed when renaming the /ui/pages/index/index.js file to /ui/pages/dashboard/index.js. If it did (you will see an ENOENT error in your terminal), just run joystick start again to start the server back up.

Now, to start getting things working, we're going to wire up the /signup page.

Wiring up the signup page

Predictably, the /signup page will be where our users can create an account. To begin, let's add the HTML markup for our page and discuss what's happening and then add in the functionality to create an account.

/ui/pages/signup/index.js

import ui from '@joystick.js/ui';

const Signup = ui.component({
  render: () => {
    return `
      <form>
        <div class="form-grid">
          <div class="form-field">
            <label for="firstName">First Name</label>
            <input type="text" name="firstName" placeholder="First Name" />
          </div>
          <div class="form-field">
            <label for="lastName">LastName</label>
            <input type="text" name="lastName" placeholder="LastName" />
          </div>
        </div>
        <div class="form-field">
          <label for="emailAddress">Email Address</label>
          <input type="email" name="emailAddress" placeholder="Email Address" />
        </div>
        <div class="form-field">
          <label for="password">Password</label>
          <input type="password" name="password" placeholder="Password" />
        </div>
        <button type="submit">Sign Up</button>
      </form>
    `;
  },
});

export default Signup;

Above, we're starting to build out our /signup page by filling in the HTML in our component's render() function.

Our form will be simple: just a few inputs asking for a first and last name, an email address, and a password followed by a submit button.

/ui/pages/signup/index.js

import ui, { accounts } from '@joystick.js/ui';

const Signup = ui.component({
  events: {
    'submit form': (event, component) => {
      event.preventDefault();
      component.validateForm(event.target, {
        rules: {
          firstName: {
            required: true,
          },
          lastName: {
            required: true,
          },
          emailAddress: {
            required: true,
            email: true,
          },
          password: {
            required: true,
            minLength: 6,
          },
        },
        messages: {
          firstName: {
            required: 'First name is required.',
          },
          lastName: {
            required: 'Last name is required.',
          },
          emailAddress: {
            required: 'An email address is required.',
            email: 'Please use a valid email.',
          },
          password: {
            required: 'A password is required.',
            minLength: 'Please use at least six characters.',
          },
        },
      }).then(() => {
        accounts.signup({
          emailAddress: event.target.emailAddress.value,
          password: event.target.password.value,
          metadata: {
            name: {
              first: event.target.firstName.value,
              last: event.target.lastName.value,
            },
          },
        }).then(() => {
          location.pathname = '/dashboard';
        });
      });
    },
  },
  render: () => {
    return `
      <form>
        ...
      </form>
    `;
  },
});

export default Signup;

Now for the fun stuff. First, we want to call attention to the top of our file. Notice that we've added an additional, named, import for a variable accounts from the @joystick.js/ui package. This object contains all of the accounts-related functions for Joystick (HTTP calls to the pre-defined accounts routes on our server). For this component, we'll be using the accounts.signup() function.

Before we make our call to that function, we're going to take advantage of the .validateForm() method that Joystick includes on our component instance. If we look at the code above, what we're doing here is adding an event listener for the submit event on the <form></form> we're rendering down in the render() function.

Inside of the function assigned to the 'submit form' event—this is what will be called whenever a submit event is detected on our form—we first make a call to event.preventDefault() to stop the default browser behavior of serializing the contents of our form into query params and trying to submit them to a URL (in non-JavaScript apps, a form's contents are usually sent as an HTTP POST request to some URL defined by the action attribute on the <form></form> element).

Instead, we want to take full control of our form's submit event and instead call to the accounts.signup() function we hinted at above. Before we do, though, we want to use component.validateForm() (pre-defined for us internally in Joystick on the component instance which we can access in our event handlers as the second argument of the handler's callback function) to verify that the user's input conforms to our expectations.

Here, .validateForm() takes two arguments: first, a DOM node representing the <form></form> we want to validate and second, an options object with two properties, rules and messages. rules contains the validation rules for each of our inputs, setting the specific rules for each input to a property matching the name attribute of the input down in our render() function.

To each property, we pass an object containing the individual rules we want to set for each input. Here, we're using three rules:

  1. required which marks the input as requiring a value.
  2. email which marks the input as requiring a valid email address.
  3. minLength which marks the input as requiring a value in length equal to the passed value (here, 6 on the password field).

To improve the UX and feedback of our form, if a user fails to pass any of the validation, the .validateForm() function will automatically render an error message beneath the input with an issue, displaying one of the error messages defined in the messages object set beneath rules.

For each of the rules that we specify under rules, we also pass a corresponding message for each of those rules. So, for the password field, because we have a required rule and a minLength rule, we provide error messages in the event that the user's input does not comply with those rules.

After .validateForm() is called, assuming that the user's input is "good" and complies with our validation, the .then() callback (.validateForm() returns a JavaScript Promise to us) will be fired. If the validation fails, the .catch() callback will be fired (we've skipped defining this here, but if you want to display additional feedback to the user—like a toast alert—that can be done in the .catch()).

Inside of the .then() callback, we finally make our call to accounts.signup() passing an object with the fields that the function expects. For our needs, we're passing three:

  • emailAddress set to the value of the emailAddress field on our form, accessed via event.target.emailAddress.value where event.target is our form, emailAddress is the input with a name attribute equal to emailAddress, and value is the current value of that input.
  • password set to the value of the password field on our form, following the same logic as emailAddress.
  • metadata set to an object of miscellaneous values that we want to assign to the user record, here, a name for the user set to an object containing a first and last property with values from the corresponding firstName and lastName fields in our form.

Similar to .validateForm(), the accounts.signup() function returns a JavaScript Promise, so again, we add a .then() callback to that function which will fire after our user is successfully created. Inside, because we know we have a logged in user (Joystick will automatically set a cookie in the browser with a login token for the user) we redirect the user to the /dashboard route we set up earlier (location.pathname is a value set on the window.location object which when set will redirect the browser to that path).

That does it for sign up. The good news: the rest of our pages follow this exact same pattern, so we'll move through them a lot quicker.

Wiring up the login page

Moving on to the /login page, let's take a look at the full component and review what we learned above:

/ui/pages/login/index.js

import ui, { accounts } from '@joystick.js/ui';

const Login = ui.component({
  events: {
    'submit form': (event, component) => {
      event.preventDefault();
      component.validateForm(event.target, {
        rules: {
          emailAddress: {
            required: true,
            email: true,
          },
          password: {
            required: true,
            minLength: 6,
          },
        },
        messages: {
          emailAddress: {
            required: 'An email address is required.',
            email: 'Please use a valid email.',
          },
          password: {
            required: 'A password is required.',
            minLength: 'Please use at least six characters.',
          },
        },
      }).then(() => {
        accounts.login({
          emailAddress: event.target.emailAddress.value,
          password: event.target.password.value,
        }).then(() => {
          location.pathname = '/dashboard';
        });
      });
    },
  },
  render: () => {
    return `
      <form>
        <div class="form-field">
          <label for="emailAddress">Email Address</label>
          <input type="email" name="emailAddress" placeholder="Email Address" />
        </div>
        <div class="form-field">
          <label for="password">Password <a href="/recover-password">Forget your password?</a></label>
          <input type="password" name="password" placeholder="Password" />
        </div>
        <button type="submit">Log In</button>
      </form>
    `;
  },
});

export default Login;

Again, same idea. Up top we import ui from @joystick.js/ui, calling to ui.component() to set up our component. Down in the render() function, we add the HTML markup for our form.

Up in the events object—remember, these are the DOM events that Joystick will automatically listen for on our behalf—we define a listener for the submit form event. So it's clear, when defining an event in Joystick, we use the key/property name of the event handler to describe:

  1. The type of JavaScript DOM event we're listening for (e.g., submit, click, keyup, etc).
  2. The selector we want to listen for the event on (here, a form tag but it could also be a CSS class like .login-form).

To that key/property name, we assign the function to be called whenever that event occurs. Inside, we make sure to call event.preventDefault() to ensure the browser does not perform the default behavior in the browser of serializing our form values and trying to HTTP POST them to the action attribute on our form (which doesn't exist).

Next, we bring back in our .validateForm() function which is automatically handed to us as part of @joystick.js/ui via the component instance. To that function, just like we saw before, we pass in the DOM element for our form (here, just pulling the target property from the original DOM event in the browser), followed by an options object describing the rules we want to validate our form by and the error messages to display if the user's input fails that validation.

Because we expect .validateForm() to return a JavaScript Promise, we chain a .then() callback on the end where we can call the accounts.login() function (a sibling of the accounts.signup() function we used earlier on the accounts object imported from @joystick.js/ui).

To that function, on an object, from the event.target representing our form, we pass the values for the emailAddress field (remember, this maps to the input with that name attribute) and the password field.

Assuming that our user's email address and password match a user, accounts.login() will return a JavaScript Promise which we chain a .then() callback to to handle the success state. In that callback, just like we did on the /signup page, we redirect to the /dashboard route by setting the pathname attribute on the window's location object (again, we haven't defined or imported this—this exists globally in the browser).

That does it for the /login page. Now, let's move on to password recovery and reset.

Wiring up the password recovery page

In order to reset a user's password, we need to generate a reset attempt/token and add it to their user record in the database. To do it, we're going to build out a "recover password" page where a user can enter their email to kickoff the reset attempt.

The good news: everything we learned above applies here, too. Let's take a look at the full component as this one doesn't have much code to it:

/ui/pages/recoverPassword/index.js

import ui, { accounts } from '@joystick.js/ui';

const RecoverPassword = ui.component({
  events: {
    'submit form': (event, component) => {
      event.preventDefault();
      component.validateForm(event.target, {
        rules: {
          emailAddress: {
            required: true,
            email: true,
          },
        },
        messages: {
          emailAddress: {
            required: 'An email address is required.',
            email: 'Please use a valid email.',
          },
        },
      }).then(() => {
        accounts.recoverPassword({
          emailAddress: event.target.emailAddress.value,
        }).then(() => {
          window.alert(`Check your email at ${event.target.emailAddress.value} for a reset link.`);
        });
      });
    },
  },
  render: () => {
    return `
      <form>
        <div class="form-field">
          <label for="emailAddress">Email Address</label>
          <input type="email" name="emailAddress" placeholder="Email Address" />
        </div>
        <button type="submit">Reset Password</button>
      </form>
    `;
  },
});

export default RecoverPassword;

Again, though it may be boring, we want to stress the importance of following a pattern. Here, we follow the exact same steps we saw above, rendering our HTML, adding an event listener, validating our form, and then performing the related action (in this case, calling to accounts.recoverPassword() and passing an emailAddress).

One more component to go (which introduces us to some new functionality): resetting the password.

Wiring up the password reset page

After a recover password attempt has been submitted using the /recover-password page we wired up above, if your config.smtp settings are present in your settings.<env>.json file at the root of your project, Joystick will attempt to send off a password reset email. In development, Joystick will automatically log out a password reset URL to your terminal (where you started the Joystick app) for testing.

That URL goes to /reset-password/:token where :token is a dynamically generated token like joXUGGscutZcvanJQ8Ao9qABjZkGUdSB which maps to the passwordResetTokens array on the user in the database (corresponding to the email address entered on the recovery page).

/ui/pages/resetPassword/index.js

import ui, { accounts } from '@joystick.js/ui';

const ResetPassword = ui.component({
  events: {
    'submit form': (event, component) => {
      event.preventDefault();
      component.validateForm(event.target, {
        rules: {
          newPassword: {
            required: true,
            minLength: 6,
          },
          repeatNewPassword: {
            required: true,
            minLength: 6,
            equals: event.target.newPassword.value,
          },
        },
        messages: {
          newPassword: {
            required: 'Must enter a new password.',
            minLength: 'Password must be at least six characters.',
          },
          repeatNewPassword: {
            required: 'Must repeat new password.',
            minLength: 'Password must be at least six characters.',
            equals: 'Passwords must match.',
          },
        },
      }).then(() => {
        accounts.resetPassword({
          token: component.url.params.token,
          password: event.target.newPassword.value,
        }).then(() => {
          window.alert(`Password reset, logging you back in...`);
          location.pathname = '/dashboard';
        });
      });
    },
  },
  render: () => {
    return `
      <form>
        <div class="form-field">
          <label for="newPassword">New Password</label>
          <input type="password" name="newPassword" placeholder="New Password" />
        </div>
        <div class="form-field">
          <label for="repeatNewPassword">Repeat New Password</label>
          <input type="password" name="repeatNewPassword" placeholder="Repeat New Password" />
        </div>
        <button type="submit">Reset Password</button>
      </form>
    `;
  },
});

export default ResetPassword;

Similar concept with some minor differences. How we render the HTML for our component and the usage of an event listener is the same, but look close at two things: the rules on .validateForm() and what we're passing to accounts.resetPassword().

For the rules, we're using an odd rule, equals. Notice that this is set equal to the value of the input with a name attribute equal to newPassword. This is because for this page, in order to reset someone's password, we want to confirm that they've correctly entered their new password before changing it.

Second, down in our call to accounts.resetPassword() notice that we're passing a token field which is set to component.url.params.token. In Joystick, information about the current URL is available in the url object on the component instance. Here, we're saying "give us the current value of the :token param in the URL."

This token maps—hypothetically—to some user in the database via their passwordResetTokens array. When we call to accounts.resetPassword(), assuming the token is valid, the user's password is updated, the token is expired (removed from their passwordResetTokens array) and the user is automatically logged in.

We hint at this in the .then() callback for accounts.resetPassword() by alerting the user to the automatic login and then redirect them to /dashboard assuming we have a logged in user token in the browser's cookies (denoted there as joystickLoginToken).

Adding authenticated and public routes

While we have all of our accounts pages set up, before we wrap up, it's important to take a look at creating authenticated vs. public routes in Joystick. An "authenticated route" is one that requires a logged in user to view it, while a "public route" is one that does not require a logged in user to view it.

In Joystick, we're given two helper methods for managing this process on the server: .ifLoggedIn() and .ifNotLoggedIn(), both assigned to the req.context object of the inbound HTTP requests in our routes. Heading back to the server, let's see how they work:

/index.server.js

import node from "@joystick.js/node";
import api from "./api";

node.app({
  api,
  routes: {
    "/dashboard": (req, res) => {
      req.context.ifNotLoggedIn('/login', () => {
        res.render("ui/pages/dashboard/index.js", {
          layout: "ui/layouts/app/index.js",
        });
      });
    },
    "/signup": (req, res) => {
      req.context.ifLoggedIn('/dashboard', () => {
        res.render("ui/pages/signup/index.js", {
          layout: "ui/layouts/app/index.js",
        });
      });
    },
    "/login": (req, res) => {
      req.context.ifLoggedIn('/dashboard', () => {
        res.render("ui/pages/login/index.js", {
          layout: "ui/layouts/app/index.js",
        });
      });
    },
    "/recover-password": (req, res) => {
      req.context.ifLoggedIn('/dashboard', () => {
        res.render("ui/pages/recoverPassword/index.js", {
          layout: "ui/layouts/app/index.js",
        });
      });
    },
    "/reset-password/:token": (req, res) => {
      req.context.ifLoggedIn('/dashboard', () => {
        res.render("ui/pages/resetPassword/index.js", {
          layout: "ui/layouts/app/index.js",
        });
      });
    },
    "*": (req, res) => {
      res.render("ui/pages/error/index.js", {
        layout: "ui/layouts/app/index.js",
        props: {
          statusCode: 404,
        },
      });
    },
  },
});

Back inside of our index.server.js file and looking at our routes, we can see these two functions at play. They're designed to read like a sentence.

"If the user is not logged in, go to this route, otherwise run this function," or "if the user is logged in, go to this route, otherwise run this function." The idea here being that some routes in our app will require a user and other's will not. This serves a dual purpose: tightening the security of our app and improving the user experience (UX).

For example, if getting to the /dashboard route requires a user (perhaps because in our app the dashboard loads private data), we don't want the user to be able to access that route if they're not logged in. Looking at that through a UX-lens, by redirecting the user away from what they're trying to access, we communicate that they haven't met the necessary requirements for viewing that page (and hopefully, the redirect to a page like /login communicates that they need to log in to get there).

Conversely, when it comes to .ifLoggedIn(), we want to communicate to a logged in user that they can't go back to pages only intended for logged out users. This is less about security and more about UX and avoiding complex bugs from arising.

That should do it. Let's give this a test drive and see how everything works.

Wrapping up

In this tutorial, we learned how to tap into Joystick's built-in accounts system to wire up an accounts flow for our app. We learned how to sign up new users, log in existing users, and implement a password recovery workflow for existing users who forget their password. We also learned how to create "protected" routes using Joystick's built-in .ifLoggedIn() and .ifNotLoggedIn() functions defined on the HTTP request to help us improve security and user experience.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode