TW

BlogShopAboutUses

Getting Started With AWS Amplify - Part Three - Adding Auth to our App

July 27, 2020

If you missed the previous post, you can find it here

From the root directory of our project run the following command to get started with Auth.

amplify add auth

add auth terminal output

amplify push
Are you sure? Y

add auth terminal output

After we have added auth, we need to update our API to let it know about our new auth rules.

amplify update api

This will run us through all the same choices we had before, so we want to select the same ones, and then configure an additional auth of Amazon Cognito User Pool. amplify update api terminal output

Run amplify push again to update our API in the cloud.

Update the GraphQL Schema

Once that is pushed up, we have to update our Schema.

amplify/backend/api/ama/schema.graphql
graphql
type Question
@model
@auth(
rules: [
{ allow: public, operations: [read, create] }
{ allow: private, operations: [read, create, update, delete] }
]
)
@key(
name: "byDate"
fields: ["type", "createdAt"]
queryField: "questionsByDate"
) {
id: ID!
type: PostType!
content: String
createdAt: AWSDateTime!
answer: Answer @connection(fields: ["id"])
}
type Answer
@model
@key(fields: ["questionID"])
@auth(
rules: [
{ allow: owner }
{ allow: public, operations: [read] }
{ allow: private, operations: [read] }
]
) {
id: ID!
questionID: ID!
content: String!
createdAt: AWSDateTime!
}
enum PostType {
QUESTION
}

The @auth directive on the Question type, has the rules set up so that anyone can read and create a question, and anyone with a JWT token (logged in) can create, read, update and delete a question.

The @auth on the Answer type, allows the Owner (the one who creates the Answer) to be able to create, read, update and delete an answer. The next two rules, allow our users to read the answer, but nothing else.

Once we have our schema updated with the auth we want, we can save the file, go back to the terminal and run amplify push

It will show the current status and show an Update for our API, type Y or press enter to continue.

It will ask you again if you want to update the code for the new schema, say yes to all those questions.

Auth related components

Now we need to create a few more components that deal with auth.

In the components directory, add the following files:

Confirm.js
Login.js
NavBar.js
Signup.js

NavBar.js

src/components/NavBar.js
jsx
import React from "react";
import { Link } from "react-router-dom";
import { logout } from "../auth";
const NavBar = ({ user }) => {
return (
<div className="navbar">
{user ? (
<button onClick={logout}>log out</button>
) : (
<>
<Link to="/login">
<button>login</button>
</Link>
<Link to="/signup">
<button>sign up</button>
</Link>
</>
)}
</div>
);
};
export default NavBar;

Pretty straight forward. We get a user passed in from App.js and use that to determine what buttons to show. We are importing a logout function which we will make a bit later.

Signup.js

src/components/Signup.js
jsx
import React, { useState } from "react";
import { useHistory } from "react-router-dom";
import { Auth } from "aws-amplify";
import BackHome from "./BackHome";
const initialState = {
email: "",
password: "",
};
const Signup = () => {
const [formState, setFormState] = useState(initialState);
const [loading, setLoading] = useState(false);
const history = useHistory();
function onChangeText(e) {
e.persist();
setFormState((currentState) => ({
...currentState,
[e.target.name]: e.target.value,
}));
}
async function handleSignup(e) {
e.preventDefault();
const { email, password } = formState;
if (!email || !password) return;
setLoading(true);
try {
await Auth.signUp({
username: email,
email,
password,
attributes: {
email,
},
});
setLoading(false);
history.push("/confirm", { email });
} catch (error) {
setLoading(false);
console.log("error signing up:", error);
}
}
return (
<>
<div className="card">
{loading ? (
<p>signing up....</p>
) : (
<form onSubmit={handleSignup}>
<h1 className="heading">signup</h1>
<div>
<input
placeholder="email (ron@pawnee.gov)"
name="email"
type="email"
onChange={onChangeText}
className="ask"
/>
<input
placeholder="password"
name="password"
type="password"
onChange={onChangeText}
className="ask"
/>
</div>
<div className="flex-center">
<button type="submit">signup</button>
</div>
</form>
)}
</div>
<BackHome />
</>
);
};
export default Signup;

This component includes a very simple form with a sign up button.

We import Auth from aws-amplify which makes handling auth super easy. We call Auth.signUp and pass it the email and password, and for some extra metadata we can pass over the email in the attributes property.

If that comes back successfully, we change routes to /confirm passing the email in as state, since we will need it in the confirm form.

At this point, the user will be sent an email with an auth code. They will then enter that into the confirm form, and then they can login to our app.

Confirm.js

src/components/Confirm.js
jsx
import React, { useState, useEffect } from "react";
import { useHistory, useLocation } from "react-router-dom";
import { Auth } from "aws-amplify";
const initState = {
email: "",
code: "",
};
const Confirm = () => {
const history = useHistory();
const location = useLocation();
const [formState, setFormState] = useState(initState);
useEffect(() => {
if (location.state && location.state.email) {
setFormState((currentState) => ({
...currentState,
email: location.state.email,
}));
}
}, []);
function onChangeText(e) {
e.persist();
setFormState((currentState) => ({
...currentState,
[e.target.name]: e.target.value,
}));
}
async function confirmSignUp(e) {
e.preventDefault();
const { email, code } = formState;
if (!code || !email) return;
try {
await Auth.confirmSignUp(email, code);
history.push("/login", { email });
} catch (error) {
console.log("error confirming sign up", error);
}
}
return (
<div className="card">
<form onSubmit={confirmSignUp}>
<p style={{ marginBottom: "1rem" }}>
please check your email for a confirmation code
</p>
<input
placeholder="email"
name="email"
type="email"
onChange={onChangeText}
className="ask"
value={formState.email}
/>
<input
placeholder="confirmation code"
name="code"
type="text"
onChange={onChangeText}
className="ask"
/>
<div className="flex-center">
<button type="submit">confirm</button>
</div>
</form>
</div>
);
};
export default Confirm;

If the user came directly from /signup we will be able to populate the email field with their email, then the user can enter the confirmation code from their inbox and if successful, will be sent to /login once again passing the email for convenience.

Login.js

src/components/Login.js
jsx
import React, { useState, useEffect } from "react";
import { useHistory, useLocation } from "react-router-dom";
import { Auth } from "aws-amplify";
import BackHome from "./BackHome";
const initialState = {
email: "",
password: "",
};
const Login = () => {
const [formState, setFormState] = useState(initialState);
const [loading, setLoading] = useState(false);
const history = useHistory();
const location = useLocation();
useEffect(() => {
const { state: { email } = {} } = location;
if (email) {
setFormState((currentState) => ({
...currentState,
email,
}));
}
// eslint-disable-next-line
}, []);
function onChangeText(e) {
e.persist();
setFormState((currentState) => ({
...currentState,
[e.target.name]: e.target.value,
}));
}
async function handleLogin(e) {
e.preventDefault();
const { email, password } = formState;
if (!email || !password) return;
setLoading(true);
try {
await Auth.signIn({
username: email,
email,
password,
});
setLoading(false);
history.push("/");
} catch (error) {
setLoading(false);
console.log("error signing up:", error);
}
}
return (
<>
<div className="card">
{loading ? (
<p>logging in...</p>
) : (
<form onSubmit={handleLogin}>
<h1 className="heading">login</h1>
<div>
<input
placeholder="email (ron@pawnee.gov)"
name="email"
type="email"
onChange={onChangeText}
value={formState.email}
className="ask"
/>
<input
placeholder="password"
name="password"
type="password"
onChange={onChangeText}
className="ask"
/>
</div>
<div className="flex-center">
<button type="submit">login</button>
</div>
</form>
)}
</div>
<BackHome />
</>
);
};
export default Login;

This one is very similar to the Signup component, but instead of calling Auth.signUp we are calling Auth.signIn Upon success we go back to the home page.

App.js

We have update our App.js file to include some of the new Auth related stuff. In our import statements, we now import our newly created auth components.

src/App.js
jsx
import NavBar from "./components/NavBar";
import Login from "./components/Login";
import Signup from "./components/Signup";
import Confirm from "./components/Confirm";

Just below that, we can import a helper function (which we will make in a bit).

src/App.js
jsx
import { fetchUser } from "./auth";

Now that we have the fetchUser function, lets make a new function called checkUser

src/App.js
jsx
async function checkUser() {
const user = await fetchUser();
setUser(user);
}

In the function above, we are setting the user with setUser, so let’s make that right now. Below our submittingAnswer useState add the following:

src/App.js
jsx
const [user, setUser] = useState(null);

Since we also want to know if we have a user when the app loads, we should make a useEffect function that calls the checkUser function.

src/App.js
jsx
useEffect(() => {
checkUser();
}, []);

It would be nice to know when a user signs in or signs out, so we can update our UI accordingly. Luckily AWS has something called Hub that we can use. We can just add Hub to the import statement we are using to pull in API like so:

App.js
jsx
import { API, Hub } from "aws-amplify";

Once we have Hub imported, we can listen for different types of events. For our use case, we can listen to the “auth” changes. If a user signs out, we set our user to null and if they sign in, then we set our user to the user data that we get.

src/App.js
jsx
// listen for auth changes and act accordingly
Hub.listen("auth", (data) => {
const { payload } = data;
if (payload.event === "signOut") {
setUser(null);
} else if (payload.event === "signIn") {
setUser(payload.data);
}
});

The last thing we need to update is the render. We need to add a couple more routes to our app. Here is what the return should look like in App.js

We added the NavBar and the authentication routes.

src/App.js
jsx
<Router>
<NavBar user={user} />
<div className="container">
<Switch>
<Route path="/ask">
<Ask setSubmittingQuestion={setSubmittingQuestion} />
</Route>
<Route path="/question/:id">
<Question user={user} setSubmittingAnswer={setSubmittingAnswer} />
</Route>
<Route path="/login">
<Login />
</Route>
<Route path="/signup">
<Signup />
</Route>
<Route path="/confirm">
<Confirm />
</Route>
<Route path="/">
<Hero />
<div className="card">
{allQuestions.map((question) => (
<QuestionItem questionPost={question} key={question.id} />
))}
</div>
</Route>
</Switch>
</div>
</Router>

Notice that we are passing in user to the NavBar component and the Question component. Both of these rely on knowing if the user is logged in or not.

Question.js

Since we don’t want the users answering the questions, we need to see if the user is logged in or not. If they are, we can show them the answer form and allow them to post an answer.

First thing we need to add, is pulling user from props, like so:

src/components/Question.js
jsx
const Question = ({ user, setSubmittingAnswer }) => {
...
}

We need to update the UI like so:

jsx
<>
<div className="card">
{!loading && (
<>
<h1 className="heading">{question.content}</h1>
<p className="sub-text">
{(question.answer && question.answer.content) ||
"This question has not been answered yet"}
</p>
{user && question.answer === null && (
<div className="answer-section">
<h3 className="sub-heading">add answer</h3>
<form
style={{ display: "flex", alignItems: "stretch" }}
onSubmit={handleAddAnswer}
>
<input
placeholder="super helpful answer"
onChange={onChangeAnswer}
value={answerInput}
style={{ flex: 1 }}
/>
<button type="submit">add answer</button>
</form>
</div>
)}
</>
)}
</div>
<BackHome />
</>

The main new thing above is that we are checking if there is a user, and if there is, render the answer form.

Lastly, we need to update our handleAddAnswer function to take an authMode property like so:

src/components/Question.js
jsx
async function handleAddAnswer(e) {
e.preventDefault();
const answerInfo = {
questionID: question.id,
content: answerInput,
};
setSubmittingAnswer(true);
try {
await API.graphql({
query: createAnswer,
authMode: "AMAZON_COGNITO_USER_POOLS",
variables: {
input: answerInfo,
},
});
setSubmittingAnswer(false);
setAnswerInput("");
fetchQuestion();
} catch (err) {
setSubmittingAnswer(false);
console.log("ERROR ", err);
}
}

We pass the authMode a string of “AMAZON_COGNITO_USER_POOLS” in order to hook up the right authentication and allow a signed in user to post an answer.

auth.js

Since we use auth in a couple different places, it makes sense to keep it in one place and export those functions.

In the src directory, make a new file called auth.js and add the following code.

src/auth.js
js
import { Auth } from "aws-amplify";
export function fetchUser() {
return new Promise((resolve, reject) => {
Auth.currentAuthenticatedUser()
.then((user) => {
if (user) {
resolve(user);
} else {
resolve(null);
}
})
.catch((err) => {
console.log(err);
resolve(null);
});
});
}
export async function logout() {
try {
await Auth.signOut();
} catch (error) {
console.log("error signing out: ", error);
}
}

What this is doing is using Auth from aws-amplify to get the current logged in user. If there is one, we get the user data back from the call, and return that, if not, we return null so other code in our app knows that we don’t have a logged in user.

Holy guac! I think we made it through!

To test it out, run either nom start or yarn start.

Our app should now be running with a login / signup button at the top. Go a head and create an account, confirm the code and login to your app. You should then be able to answer questions.

Hosting

Now that it’s look sweet and working great on our local environment, let’s get hosting set up with Amplify.

First we have to add the hosting

bash
amplify add hosting

Chose the first one (Hosting with Amplify Console) For now we can just do Manual deployment, but in the future for a better workflow, you might want to pick Continuous deployment. Now we are set to publish!

Publishing

Once the hosting is set up, we can easily publish our app.

bash
amplify publish

Boom, it will do its thing, and kick back a url that will have your app running on it. How awesome is that?!

While at the end of the day, this app is super simple, I think it sets us up for making something more robust and real in the future.

We did it!

ron swanson dancing gif

Bonus points

Here are a few ideas you could play around with adding to our app:

  • Ability to delete a question
  • Edit an answer
  • Ask for the users email address when they ask a question, and set up a notification to let them know when their question has been answered
  • Update the url to use a question slug instead of the UUID for better SEO
  • And more…

If you got lost, or need to look something up, here is the code for all three parts: Amplify Tutorial - Part Three

Tweet This

Get updates in your inbox 👇

TwitterInstagram

© 2021, Travis Werbelow

Have a great rest of your Sunday!