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
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
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
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
$ 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
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);
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.
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>
);
}
}
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.
- 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. - We are going to change our
App
component to make use of anAuthWrapper
component that we will write. This is the component that can manage the various states of authentication we can be in. - 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;
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;
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;
}
}
}
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>
);
}
}
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.
- Run
yarn add tailwindcss --dev
in the root of your project. - Run
./node_modules/.bin/tailwind init tailwind.js
to initialize Tailwind in the root of your project. - Create a CSS directory
mkdir src/css
. - Add a tailwind source CSS file at
src/css/tailwind.src.css
with the following inside of it.
@tailwind base;
@tailwind components;
@tailwind utilities;
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"
}
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.