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:
- Swapping the
/
route with a/dashboard
route and rendering back a page defined as a Joystick component at/ui/pages/dashboard/index.js
. - For each of the pages we outlined above, defining a route under the
routes
object passed to the options fornode.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 underroutes
is added as an HTTP GET route. - 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:
required
which marks the input as requiring a value.email
which marks the input as requiring a valid email address.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 theemailAddress
field on our form, accessed viaevent.target.emailAddress.value
whereevent.target
is our form,emailAddress
is the input with aname
attribute equal toemailAddress
, andvalue
is the current value of that input.password
set to the value of thepassword
field on our form, following the same logic asemailAddress
.metadata
set to an object of miscellaneous values that we want to assign to the user record, here, aname
for the user set to an object containing afirst
andlast
property with values from the correspondingfirstName
andlastName
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:
- The type of JavaScript DOM event we're listening for (e.g.,
submit
,click
,keyup
, etc). - 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.