Pete Corey Writing Work Contact

Phoenix Todos - Finishing Authentication

This post is written as a set of Literate Commits. The goal of this style is to show you how this program came together from beginning to end. Each commit in the project is represented by a section of the article. Click each section’s header to see the commit on Github, or check out the repository and follow along.

Client-side Validation Bug

You may have noticed that with our previous solution, only server-side errors would show on the sign-up form. Client-side validation was taking place in our onSubmit handler, but errors were never propagating to the UI!

This was happening because we were storing client-side validation errors in the JoinPage component’s state:


this.setState({ errors });

However, our render function was pulling errors out of the props passed into the component by Redux.

Our component didn’t have a single source of truth for the errors array.

The fix to this issue is fairly elegant. We can pull the validation checks out of the onSubmit handler and move them into our signUp action. If we detect any validation issues, we’ll return them to the JoinPage component by dispatching a SIGN_UP_FAILURE action:


if (errors.length) {
  return dispatch(signUpFailure(errors));
}

From our component’s perspective, all errors are seen as server-side errors and render correctly.

web/static/js/actions/index.js

... dispatch(signUpRequest()); + + let errors = []; + if (!email) { + errors.push({ email: "Email required" }); + } + if (!password) { + errors.push({ password: "Password required" }); + } + if (password_confirm !== password) { + errors.push({ password_confirm: "Please confirm your password" }); + } + if (errors.length) { + return Promise.resolve(dispatch(signUpFailure(errors))); + } + return fetch("/api/users", {

web/static/js/pages/AuthPageJoin.jsx

... const password_confirm = this.refs.password_confirm.value; - const errors = {}; - - if (!email) { - errors.email = 'Email required'; - } - if (!password) { - errors.password = 'Password required'; - } - if (password_confirm !== password) { - errors.password_confirm = 'Please confirm your password'; - } - - this.setState({ errors }); - if (Object.keys(errors).length) { - return; - }

Sign-out Actions

Now that we’ve established the pattern our Redux actions and reducers will follow, we can start implementing our other authentication features.

To give users the ability to sign out, we’ll start by creating three new actions: SIGN_OUT_REQUEST, SIGN_OUT_SUCCESS, and SIGN_OUT_FAILURE.

Along with the action creators for each of these actions, we’ll also create an asynchronous action function called signOut which accepts the current user’s JWT as an argument. This function makes a DELETE request to our /api/sessions endpoint, sending the jwt in the "Authorization" header:


return fetch("/api/sessions", {
  method: "delete",
  headers: {
    "Accept": "application/json",
    "Content-Type": "application/json",
    "Authorization": jwt
  }
})

Our SIGN_OUT_SUCCESS reducer clears the user and jwt fields in our application state:


case SIGN_OUT_SUCCESS:
  return Object.assign({}, state, {
    user: undefined,
    jwt: undefined
  });

And the SIGN_OUT_FAILURE resolver will save any errors from the server into errors.

Now that our sign-out actions and resolvers are set, we can wire our App component up to our Redux store with a call to connect, and replace our old Meteor.logout() code with a call to our signOut thunk:


this.props.signOut(this.props.jwt)

With that, authenticated users have the ability to sign out of our application!

web/static/js/actions/index.js

... +export const SIGN_OUT_REQUEST = "SIGN_OUT_REQUEST"; +export const SIGN_OUT_SUCCESS = "SIGN_OUT_SUCCESS"; +export const SIGN_OUT_FAILURE = "SIGN_OUT_FAILURE"; + export function signUpRequest() { ... +export function signOutRequest() { + return { type: SIGN_OUT_REQUEST }; +} + +export function signOutSuccess() { + return { type: SIGN_OUT_SUCCESS }; +} + +export function signOutFailure(errors) { + return { type: SIGN_OUT_FAILURE, errors }; +} + export function signUp(email, password, password_confirm) { ... } + +export function signOut(jwt) { + return (dispatch) => { + dispatch(signOutRequest()); + return fetch("/api/sessions", { + method: "delete", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": jwt + } + }) + .then((res) => res.json()) + .then((res) => { + if (res.errors) { + dispatch(signOutFailure(res.errors)); + return false; + } + else { + dispatch(signOutSuccess()); + return true; + } + }); + } +}

web/static/js/layouts/App.jsx

... import Loading from '../components/Loading.jsx'; +import { connect } from "react-redux"; +import { signOut } from "../actions"; ... -export default class App extends React.Component { +class App extends React.Component { constructor(props) { ... logout() { - Meteor.logout(); - - // if we are on a private list, we'll need to go to a public one - if (this.props.params.id) { - const list = Lists.findOne(this.props.params.id); - if (list.userId) { - const publicList = Lists.findOne({ userId: { $exists: false } }); - this.context.router.push(`/lists/${ publicList._id }`{:.language-javascript}); - } - } + this.props.signOut(this.props.jwt) + .then((success) => { + if (success) { + // if we are on a private list, we'll need to go to a public one + if (this.props.params.id) { + const list = Lists.findOne(this.props.params.id); + if (list.userId) { + const publicList = Lists.findOne({ userId: { $exists: false } }); + this.context.router.push(`/lists/${ publicList._id }`{:.language-javascript}); + } + } + } + }); } ... }; + +export default connect( + (state) => state, + (dispatch) => ({ + signOut: (jwt) => { + return dispatch(signOut(jwt)); + } + }) +)(App);

web/static/js/reducers/index.js

... SIGN_UP_FAILURE, + SIGN_OUT_REQUEST, + SIGN_OUT_SUCCESS, + SIGN_OUT_FAILURE, } from "../actions"; ... }); + + case SIGN_OUT_REQUEST: + return state; + case SIGN_OUT_SUCCESS: + return Object.assign({}, state, { + user: undefined, + jwt: undefined + }); + case SIGN_OUT_FAILURE: + return Object.assign({}, state, { + errors: action.errors + }); default:

Persisting Users

Unfortunately, if a user refreshes the page after signing up, they’ll lose their authenticated status. This means a user would have to sign-in every time they load the application.

This issue is caused by the fact that we’re saving the user and jwt objects exclusively in our in-memory application state. When we reload the page, that state is reset.

Thankfully, we can fix this issue fairly quickly.

In our signUp thunk, once we recieve a successful response from the server, we can store the user and jwt objects into local storage.


localStorage.setItem("user", JSON.stringify(res.user));
localStorage.setItem("jwt", res.jwt);

Similarly, when a user signs out we’ll clear these local storage entries:


localStorage.removeItem("user");
localStorage.removeItem("jwt");

Now we can popoulate our initialState with these user and jwt values, if they exist in local storage:


const user = localStorage.getItem("user");
const jwt = localStorage.getItem("jwt");

const initialState = {
  user: user ? JSON.parse(user) : user,
  jwt,
  ...

And now when a authenticated user refreshes the page, they’ll stay authenticated.

web/static/js/actions/index.js

... else { + localStorage.setItem("user", JSON.stringify(res.user)); + localStorage.setItem("jwt", res.jwt); dispatch(signUpSuccess(res.user, res.jwt)); ... else { + localStorage.removeItem("user"); + localStorage.removeItem("jwt"); dispatch(signOutSuccess());

web/static/js/reducers/index.js

... +const user = localStorage.getItem("user"); +const jwt = localStorage.getItem("jwt"); + const initialState = { - user: undefined, - jwt: undefined, + user: user ? JSON.parse(user) : user, + jwt, loading: false,

Sign In Front-end

Finally, we can continue the same pattern we’ve been following and implement our sign-in functionality.

We’ll start by copying over the SignInPage component from our Meteor application. Next, we’ll make three new actions: SIGN_IN_REQUEST, SIGN_IN_SUCCESS, and SIGN_IN_FAILURE.

In addition to our actions, we’ll make an asynchronous action creator that sends a POST request to /api/sessions to initiate a sign-in.

The reducers for our new actions will be identical to our sign-up reducers, so we’ll save some typing and re-use them:


case SIGN_IN_SUCCESS:
case SIGN_UP_SUCCESS:
  return Object.assign({}, state, {
    user: action.user,
    jwt: action.jwt
  });
...

Lastly, we can replace the call to Meteor.loginWithPassword with a call to our signIn helper. If this call is successful, we’ll redirect to /:


this.state.signIn(email, password)
  .then((success) => {
    if (success) {
      this.context.router.push('/');
    }
  });

Otherwise, we’ll render any errors we find in this.props.errors:


const errors = (this.props.errors || []).reduce((errors, error) => {
  return Object.assign(errors, error);
}, {});

And with those changes, a user can now sign up, log out, and sign into our application!

web/static/js/actions/index.js

... +export const SIGN_IN_REQUEST = "SIGN_IN_REQUEST"; +export const SIGN_IN_SUCCESS = "SIGN_IN_SUCCESS"; +export const SIGN_IN_FAILURE = "SIGN_IN_FAILURE"; + export function signUpRequest() { ... +export function signInRequest() { + return { type: SIGN_IN_REQUEST }; +} + +export function signInSuccess() { + return { type: SIGN_IN_SUCCESS }; +} + +export function signInFailure(errors) { + return { type: SIGN_IN_FAILURE, errors }; +} + export function signUp(email, password, password_confirm) { ... } + +export function signIn(email, password) { + return (dispatch) => { + dispatch(signInRequest()); + + let errors = []; + if (!email) { + errors.push({ email: "Email required" }); + } + if (!password) { + errors.push({ password: "Password required" }); + } + if (errors.length) { + return Promise.resolve(dispatch(signInFailure(errors))); + } + + return fetch("/api/sessions", { + method: "post", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }) + }) + .then((res) => res.json()) + .then((res) => { + if (res.errors) { + dispatch(signInFailure(res.errors)); + return false; + } + else { + localStorage.setItem("user", JSON.stringify(res.user)); + localStorage.setItem("jwt", res.jwt); + dispatch(signInSuccess(res.user, res.jwt)); + return true; + } + }); + } +}

web/static/js/pages/AuthPageSignIn.jsx

+import React from 'react'; +import AuthPage from './AuthPage.jsx'; +import { Link } from 'react-router'; +import { connect } from "react-redux"; +import { signIn } from "../actions"; + +class SignInPage extends React.Component { + constructor(props) { + super(props); + this.state = { + signIn: props.signIn + }; + this.onSubmit = this.onSubmit.bind(this); + } + + onSubmit(event) { + event.preventDefault(); + const email = this.refs.email.value; + const password = this.refs.password.value; + + this.state.signIn(email, password) + .then((success) => { + if (success) { + this.context.router.push('/'); + } + }); + } + + render() { + const errors = (this.props.errors || []).reduce((errors, error) => { + return Object.assign(errors, error); + }, {}); + const errorMessages = Object.keys(errors).map(key => errors[key]); + const errorClass = key => errors[key] && 'error'; + + const content = ( +
+

Sign In.

+

Signing in allows you to view private lists

+ <form onSubmit={this.onSubmit}> +
+ {errorMessages.map(msg => ( + <div className="list-item" key={msg}>{msg}
+ ))} +
+ <div className={`input-symbol ${errorClass('email')}`{:.language-javascript}}> + + + </div> + <div className={`input-symbol ${errorClass('password')}`{:.language-javascript}}> + + + </div> + + </form> + </div> + ); + + const link = Need an account? Join Now.</Link>; + + return <AuthPage content={content} link={link}/>; + } +} + +SignInPage.contextTypes = { + router: React.PropTypes.object, +}; + +export default connect( + (state) => { + return { + errors: state.errors + } + }, + (dispatch) => { + return { + signIn: (email, password) => { + return dispatch(signIn(email, password)); + } + }; + } +)(SignInPage);

web/static/js/reducers/index.js

... SIGN_OUT_FAILURE, + SIGN_IN_REQUEST, + SIGN_IN_SUCCESS, + SIGN_IN_FAILURE, } from "../actions"; ... switch (action.type) { + case SIGN_IN_REQUEST: case SIGN_UP_REQUEST: return state; + case SIGN_IN_SUCCESS: case SIGN_UP_SUCCESS: ... }); + case SIGN_IN_FAILURE: case SIGN_UP_FAILURE:

web/static/js/routes.jsx

... import AppContainer from './containers/AppContainer.jsx'; +import AuthPageSignIn from './pages/AuthPageSignIn.jsx'; import AuthPageJoin from './pages/AuthPageJoin.jsx'; ... <Route path="/" component={AppContainer}> + <Route path="signin" component={AuthPageSignIn}/> <Route path="join" component={AuthPageJoin}/>

Final Thoughts

Now that we’re getting more comfortable with React, it’s becoming more and more enjoyable to use.

The concept of a single application state, while undeniably weird at first, really simplifies a lot of complexities that can show up in more complicated applications. For example, having a single, canonical errors array that holds any error messages that might currently exist is amazing!

Coming from Blaze, the incredibly explicit data flow in a Redux-style application is comforting. It’s completely clear where each action is initiated and how it effects the application’s state.

Gone are the days of racking your brain trying to conceptualize a tree of reactive updates that brought your application into its current state.

Now that the authentication piece is finished (finally), next week we’ll move onto implementing the meat of our application!

This article was published on September 28, 2016 under the AuthenticationElixirLiterate CommitsPhoenixPhoenix Todos tags. For more articles, visit the archives. Also check out the work I do, and reach out if you’re interested in working together.

– Transactions are an incredibly undervalued tool in a developer's toolbox. They're often not missed until they're desperately needed. By then, it may be too late.

– Meteor's Accounts system is one of Meteor's most killer features, and one of the reasons I find it difficult to leave the framework.