Customizing the AWS Amplify Authentication UI with Your Own React Components

Kyle Galbraith - Mar 31 '20 - - Dev Community

I have written before about customizing the authentication UI that AWS Amplify gives you out of the box. But since writing that post I have received lots of questions around more robust ways to do this.

In my latest project parler.io users can quickly convert written content into audio. Underneath the hood, parler makes use of a lot of Amplify functionality. Authentication being one.

In this post, we are going to leverage AWS Amplify authentication while still building the UI we want.

Prerequisites

Seeing as this is a post about AWS and AWS Amplify, you should be set up with both of those. Don't have an AWS account yet? You can set one up here.

To interact with AWS Amplify you need to install the CLI via npm.

$ yarn global add @aws-amplify/cli
Enter fullscreen mode Exit fullscreen mode

Setting up our project

Before we can show how to build a custom UI using Amplify, we first need a project to work from. Let's use create-react-app to get a React app going.

$ npx create-react-app amplify-demo
$ cd amplify-demo
Enter fullscreen mode Exit fullscreen mode

With our boilerplate project created we can now add the Amplify libraries we are going to need to it.

$ yarn add aws-amplify aws-amplify-react
Enter fullscreen mode Exit fullscreen mode

Now we need to initialize Amplify and add authentication to our application. From the root of our new amplify-demo application, run the following commands with the following answers to each question.

$ amplify init
Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project amplify-demo
? Enter a name for the environment prod
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building: javascript
? What javascript framework are you using react
? Source Directory Path:  src
? Distribution Directory Path: build
? Build Command:  npm run-script build
? Start Command: npm run-script start
Enter fullscreen mode Exit fullscreen mode
$ amplify add auth
Using service: Cognito, provided by: awscloudformation

 The current configured provider is Amazon Cognito. 

 Do you want to use the default authentication and security configuration? Default configuration
 Warning: you will not be able to edit these selections. 
 How do you want users to be able to sign in? Username
 Do you want to configure advanced settings? No, I am done.
Successfully added resource amplifydemobc1364f5 locally
Enter fullscreen mode Exit fullscreen mode

Now that we have the default authentication via Amplify added to our application we can add the default login. To do that go ahead and update your App component located at src/App.js to have the following code.

import React from "react";
import logo from "./logo.svg";
import "./App.css";
import { withAuthenticator } from "aws-amplify-react";
import Amplify from "aws-amplify";
import awsconfig from "./aws-exports";

Amplify.configure(awsconfig);

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>Internal Application behind Login</p>
      </header>
    </div>
  );
}

export default withAuthenticator(App);
Enter fullscreen mode Exit fullscreen mode

The default Amplify authentication above leverages the higher-order component, withAuthenticator. We should now be able to see that our App component is behind a login. Go ahead and start the app up in development mode by running yarn start. We should see something like below.

Default Amplify Authentication

Customizing The Amplify Authentication UI

Now that we have the default authentication wired up it's time to customize it. In the previous blog post we essentially inherited from the internal Amplify components like SignIn. This allowed us to leverage the functions already defined in that component.

But, this felt like the wrong abstraction and a bit of a hack for the long term. It was/is a valid way to get something working. But it required knowing quite a few of the implementation details implemented in the parent component.

Things like knowing how handleInputChange and _validAuthStates were getting used in SignIn were critical to making the brute force version below work as expected.

import React from "react";
import { SignIn } from "aws-amplify-react";

export class CustomSignIn extends SignIn {
  constructor(props) {
    super(props);
    this._validAuthStates = ["signIn", "signedOut", "signedUp"];
  }

  showComponent(theme) {
    return (
      <div className="mx-auto w-full max-w-xs">
        <form className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
          <div className="mb-4">
            <label
              className="block text-grey-darker text-sm font-bold mb-2"
              htmlFor="username"
            >
              Username
            </label>
            <input
              className="shadow appearance-none border rounded w-full py-2 px-3 text-grey-darker leading-tight focus:outline-none focus:shadow-outline"
              id="username"
              key="username"
              name="username"
              onChange={this.handleInputChange}
              type="text"
              placeholder="Username"
            />
          </div>
          .....omitted.....
        </form>
      </div>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

But in running with this brute force approach for a bit I was able to form up a better way to customize the Amplify authentication UI. The approach, as we are going to see, boils down to three changes.

  1. Instead of using the higher-order component, withAuthenticator. We are going to instead use the <Authenticator> component instead. This is the component built into the framework that allows for more customization.
  2. We are going to change our App component to make use of an AuthWrapper component that we will write. This is the component that can manage the various states of authentication we can be in.
  3. Finally, we will write our own CustomSignIn component to have it's own UI and logic.

Let's go ahead and dive in with 1️⃣. Below is what our App component is going to look like now.

import React from "react";
import { Authenticator } from "aws-amplify-react";
import "./App.css";
import Amplify from "aws-amplify";
import awsconfig from "./aws-exports";
import AuthWrapper from "./AuthWrapper";

Amplify.configure(awsconfig);

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <Authenticator hideDefault={true} amplifyConfig={awsconfig}>
          <AuthWrapper />
        </Authenticator>
      </header>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Notice that our App component is now an entry point into our application. It uses the Authenticator component provided by Amplify instead of the higher-order component. We tell that component to hide all the default authentication UI, we are going to create our own. Then inside of that, we make use of a new component we are going to create called AuthWrapper.

This new component is going to act as our router for the different authentication pieces we want to have. For this blog post, we are just going to implement the login workflow. But the idea is transferrable to other things like signing up and forgot password. Here is what AuthWrapper ends up looking like.

import React, { Component } from "react";
import { InternalApp } from "./InternalApp";
import { CustomSignIn } from "./SignIn";

class AuthWrapper extends Component {
  constructor(props) {
    super(props);
    this.state = {
      username: ""
    };
    this.updateUsername = this.updateUsername.bind(this);
  }

  updateUsername(newUsername) {
    this.setState({ username: newUsername });
  }

  render() {
    return (
      <div className="flex-1">
        <CustomSignIn
          authState={this.props.authState}
          updateUsername={this.updateUsername}
          onStateChange={this.props.onStateChange}
        />
        <InternalApp
          authState={this.props.authState}
          onStateChange={this.props.onStateChange}
        />
      </div>
    );
  }
}

export default AuthWrapper;
Enter fullscreen mode Exit fullscreen mode

Here we can see that AuthWrapper is a router for two other components. The first one is CustomSignIn, this is the custom login UI we can build-out. The second one is our InternalApp which is the application UI signed in users can access. Note that both components get the authState passed into them. Internally the components can use this state to determine what they should do.

Before taking a look at the CustomSignIn component, let's look at InternalApp to see how authState is leveraged.

import React, { Component } from "react";
import logo from "../src/logo.svg";

export class InternalApp extends Component {
  render() {
    if (this.props.authState === "signedIn") {
      return (
        <>
          <img src={logo} className="App-logo" alt="logo" />
          <p>Internal Application behind Login</p>
        </>
      );
    } else {
      return null;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice that we are checking that authState === "signedIn" to determine if we should render the application UI. This is a piece of state that is set by the authentication components defined in AuthWrapper.

Now let's see what our customized authentication for the login prompt looks like. Here is what CustomSignIn looks like.

import React, { Component } from "react";
import { Auth } from "aws-amplify";

export class CustomSignIn extends Component {
  constructor(props) {
    super(props);
    this._validAuthStates = ["signIn", "signedOut", "signedUp"];
    this.signIn = this.signIn.bind(this);
    this.handleInputChange = this.handleInputChange.bind(this);
    this.handleFormSubmission = this.handleFormSubmission.bind(this);
    this.state = {};
  }

  handleFormSubmission(evt) {
    evt.preventDefault();
    this.signIn();
  }

  async signIn() {
    const username = this.inputs.username;
    const password = this.inputs.password;
    try {
      await Auth.signIn(username, password);
      this.props.onStateChange("signedIn", {});
    } catch (err) {
      if (err.code === "UserNotConfirmedException") {
        this.props.updateUsername(username);
        await Auth.resendSignUp(username);
        this.props.onStateChange("confirmSignUp", {});
      } else if (err.code === "NotAuthorizedException") {
        // The error happens when the incorrect password is provided
        this.setState({ error: "Login failed." });
      } else if (err.code === "UserNotFoundException") {
        // The error happens when the supplied username/email does not exist in the Cognito user pool
        this.setState({ error: "Login failed." });
      } else {
        this.setState({ error: "An error has occurred." });
        console.error(err);
      }
    }
  }

  handleInputChange(evt) {
    this.inputs = this.inputs || {};
    const { name, value, type, checked } = evt.target;
    const check_type = ["radio", "checkbox"].includes(type);
    this.inputs[name] = check_type ? checked : value;
    this.inputs["checkedValue"] = check_type ? value : null;
    this.setState({ error: "" });
  }

  render() {
    return (
      <div className="mx-auto w-full max-w-xs">
        <div className="login-form">
          {this._validAuthStates.includes(this.props.authState) && (
            <form
              className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"
              onSubmit={this.handleFormSubmission}
            >
              <div className="mb-4">
                <label
                  className="block text-grey-darker text-sm font-bold mb-2"
                  htmlFor="username"
                >
                  Username
                </label>
                <input
                  className="shadow appearance-none border rounded w-full py-2 px-3 text-grey-darker leading-tight focus:outline-none focus:shadow-outline"
                  id="username"
                  key="username"
                  name="username"
                  onChange={this.handleInputChange}
                  type="text"
                  placeholder="Username"
                />
              </div>
              <div className="mb-6">
                <label
                  className="block text-grey-darker text-sm font-bold mb-2"
                  htmlFor="password"
                >
                  Password
                </label>
                <input
                  className="shadow appearance-none border rounded w-full py-2 px-3 text-grey-darker mb-3 leading-tight focus:outline-none focus:shadow-outline"
                  id="password"
                  key="password"
                  name="password"
                  onChange={this.handleInputChange}
                  type="password"
                  placeholder="******************"
                />
              </div>
              <div className="flex items-center justify-between">
                <button
                  className="bg-indigo-400 text-white py-2 px-4 rounded focus:outline-none focus:shadow-outline"
                  type="submit"
                  onClick={this.handleFormSubmission}
                >
                  Login
                </button>
              </div>
            </form>
          )}
        </div>
      </div>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

What we have defined up above is a React component that is leveraging the Amplify Authentication API. If we take a look at signIn we see many calls to Auth to sign a user in or resend them a confirmation code. We also see that this._validAuthStates still exists. This internal parameter to determines whether we should show this component inside of the render function.

This is a lot cleaner and is not relying on knowing the implementation details of base components provided by Amplify. Making this not only more customizable but a lot less error-prone as well.

If you take a look at the class names inside of the markup you'll see that this component is also making use of TailwindCSS. Speaking as a non-designer, Tailwind is a lifesaver. It allows you to build out clean looking interfaces with utility first classes.

To add Tailwind into your own React project, complete these steps.

  1. Run yarn add tailwindcss --dev in the root of your project.
  2. Run ./node_modules/.bin/tailwind init tailwind.js to initialize Tailwind in the root of your project.
  3. Create a CSS directory mkdir src/css.
  4. Add a tailwind source CSS file at src/css/tailwind.src.css with the following inside of it.
@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

From there we need to update the scripts in our package.json to build our CSS before anything else.

"scripts": {
    "tailwind:css":"tailwind build src/css/tailwind.src.css -c  tailwind.js -o src/css/tailwind.css",
    "start": "yarn tailwind:css && react-scripts start",
    "build": "yarn tailwind:css && react-scripts build",
    "test": "yarn tailwind:css && react-scripts test",
    "eject": "yarn tailwind:css && react-scripts eject"
  }
Enter fullscreen mode Exit fullscreen mode

Then it is a matter of importing our new Tailwind CSS file, import "./css/tailwind.css"; into the root of our app which is App.js.

💥 We can now make use of Tailwind utility classes inside of our React components.

Conclusion

AWS Amplify is gaining a lot of traction and it's not hard to see why. They are making it easier and easier to integrate apps into the AWS ecosystem. By abstracting away things like authentication, hosting, etc, folks are able to get apps into AWS at lightning speed.

But, with abstractions can come guard rails. Frameworks walk a fine line between providing structure and compressing creativity. They need to provide a solid foundation to build upon. But at the same time, they need to provide avenues for customization.

As we saw in this post the default Amplify authentication works fine. But we probably don't want exactly that when it comes to deploying our own applications. With a bit of work and extending the framework into our application, we were able to add that customization.

Want to check out my other projects?

I am a huge fan of the DEV community. If you have any questions or want to chat about different ideas relating to refactoring, reach out on Twitter or drop a comment below.

Outside of blogging, I created a Learn AWS By Using It course. In the course, we focus on learning Amazon Web Services by actually using it to host, secure, and deliver static websites. It's a simple problem, with many solutions, but it's perfect for ramping up your understanding of AWS. I recently added two new bonus chapters to the course that focus on Infrastructure as Code and Continuous Deployment.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .