Introduction
I am going to assume you have your application up and running, if you have not set it up yet I suggest just following along with their docs and then coming back to here. A great thing about Next.js is that you can get started very quickly.
For the login portion of this I will assume you have that handled, however I will probably write another post about handling that with this stack soon and link it here in case anyone struggles with it. It is also important to note that I will include examples and explanations of my backend code and although I understand that you may not be using this exact stack, it will be useful for explaining the logic behind my decisions.
Some of the main packages I am using are mikro-orm, type-graphql, urql, graphQL, Apollo server express, nodemailer, redis and uuid. I will again point this out in my code snippets as we go along.
Below are the steps we will take when a user wants to change their password.
Steps
User selects forgot password on the website
We check if the email is valid and in use
We generate a token for this user
We email the user a link to change the password with this token in the url
The user submits the change password form
We handle this password change on our backend and delete the token
Now let’s get started!
Backend Logic
When developing out certain features I like to have the backend logic roughly completed first and I then implement the frontend for it and making any necessary adjustments. As my backend uses graphQL the first step is to create my function which handles the user requesting an email to change their password.
My Context
I just want to place here my context, which is accessible in all my resolvers. The request and response objects are pretty standard and I got their types simply from hovering over them in VSCode. What important to note here is the em
and redis
objects. The em
object is the ORM which is configured to connect to my database and the redis
object is used to access my redis instance, which is where user sessions are stored.
// my context
context: ({ req, res }: MyContext) => ({
em: orm.em,
req,
res,
redis,
}),
//...
// types.ts
export type MyContext = {
em: EntityManager<any> & EntityManager<IDatabaseDriver<Connection>>;
req: Request & { session: Express.Session };
res: Response;
redis: Redis;
};
Forgot Password Mutation
This mutation takes an email
parameter and will return a boolean depending on whether the email address was valid and if the link could be sent. Below you will see the definition of this function and a first look at the use of type-graphql
.
@Mutation(() => Boolean)
async forgotPassword(@Arg("email") email: string, @Ctx() { em, redis }: MyContext) {
// ...
// ...
}
The reason for using type-graphql
is because it allows you to define schemas using only their decorators. It then allows us to inject dependencies into our resolvers and put auth guards in place, all while cutting down on code redundancy.
So the function takes an email
parameter and accesses the em
and redis
objects (see here for clarification). The first thing we will do is check if the email address is in the database and return false if it is not present.
// ...
const person = await em.findOne(User, { email });
if (!person) {
return false;
}
// ...
}
If the user is present we will generate a token using uuid
's v4
function. This token is stored with the forgot-password:
prefix and the key is the user's id
field. The token will expire 3 days after the user makes the request.
// ...
const token = v4()
redis.set(
`${FORGET_PASSWORD_PREFIX}${token}`,
person.id,
"ex",
1000 * 60 * 60 * 24 * 3
) // 3 days
// ...
Once the token is set and stored we will send the user the email with the link. This link will include the token and we use this to identify the user.
//..
await sendEmail(
email,
`<a href="http:localhost:3000/change-password/${token}">reset password</a>`
);
return true;
}
The contents of the sendEmail
function are taken directly from the example given in the Nodemailer docs. For clarity I will include it below.
let testAccount = await nodemailer.createTestAccount()
console.log("test account: ", testAccount)
let transporter = nodemailer.createTransport({
host: "smtp.ethereal.email",
port: 587,
secure: false, // true for 465, false for other ports
auth: {
user: testAccount.user, // generated ethereal user
pass: testAccount.pass, // generated ethereal password
},
})
let info = await transporter.sendMail({
from: '"Sample Person" <foo@example.com>', // sender address
to: to, // list of receivers
subject: "Change Password", // Subject line
html,
})
console.log("Message sent: %s", info.messageId)
console.log("Preview URL: %s", nodemailer.getTestMessageUrl(info))
Forget Password Page
Now in our Next.js application, in the ./src/pages/
folder, we will create a change-password
folder. In this folder we create a [token].tsx
file.
(So the full path will be ./src/pages/change-password/[token].tsx
)
Dynamic Routing
In Next.js the [param]
file syntax is used for dynamic routes. This parameter will be sent as a query parameter to this page.
The next step is you must then decide when you will need to access this on the page via the props
. This can be accomplished a handful of functions given to us by Next.js, however the use case will decide what function.
The three options too us are:
getServerSideProps
getStaticProps
I choose getServerSideProps
as the data must be fetched at request time. We do not have a list of possible token
's at build time.
The docs for getStaticProps
states that we should only be using this function if:
The data required to render the page is available at build time ahead of a user’s request.
So in our [token].tsx
file we start with the following scaffolding:
import { GetServerSideProps, NextPage } from "next";
const ChangePassword: NextPage<{token: string}> = ({token}) => {
return (
//..
// form goes here
//..
)
};
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
return {
props: {
token: params.token,
},
};
};
export default ChangePassword;
As we use dynamic routing, params
contains this dynamic data. The reason we use params.token
is because we named our file [token].tsx
. If we were to name it [user-id]
then the props passed would be token: params.user-id
.
I then use Formik
and urql
to handle form state and sending the data to the server. Once the form is submitted without any errors back from the server the user is logged back in with the new password and redirected to the home page. This will now take us back to the backend for handling this data submission.
Handling the password change
Once we are back in our resolvers we create the changePassword
resolver and it is important to take the time to define the type for the response to this. We can then make use of this type then when we generate our types in the frontend with the graphql-codegen
package.
The UserResponse
object will return an array of errors (each with a field
and message
field) and the user, with both having the option of being null. I choose an array of objects because I have a helper function for the frontend which will map the errors to the appropriate formik
field and display them accordingly (I got this function from a Ben Awad video and I will include this below).
// toErrorMap.tsx
import { FieldError } from "../generated/graphql";
// map errors accordingly
// taken from Ben Awad video
export const toErrorMap = (errors: FieldError[]) => {
const errorMap: Record<string, string> = {};
errors.forEach(({ field, message }) => {
errorMap[field] = message;
});
return errorMap;
};
// form.tsx
// usage example in a formik form
const form = () => {
const handleSubmit = (values, {setErrors}) => {
// send data via graphql
const response = sendDataViaGraphl(values);
if (response.data?.errors) {
// if there’s errors
setErrors(toErrorMap(response.data.errors))
}
}
return (
// form down here
)
}
Below is the schema typings I described above for the data returned from the mutation.
@ObjectType()
class FieldError {
@Field()
field: string
@Field()
message: string
}
@ObjectType()
class UserResponse {
@Field(() => [FieldError], { nullable: true })
errors?: FieldError[]
@Field(() => User, { nullable: true })
user?: User
}
Now onto the changePassword
function itself! It takes 2 arguments, token
and newPassword
. From our context again we take the redis
, em
and req
objects. We also state our response type as the previously defined UserResponse
type.
@Mutation(() => UserResponse)
async changePassword(
@Arg("token") token: string,
@Arg("newPassword") newPassword: string,
@Ctx() { redis, em, req }: MyContext
): Promise<UserResponse> {
// ...
// ...
};
The first thing we will check is the password length, it is just a basic security measure. Again make sure to note that this return matches the errors
type we defined above.
// ...
{
if (newPassword.length <= 5) {
return {
errors: [
{
field: "newPassword",
message: "password is not long enough",
},
],
}
}
}
// ..
Next we move onto checking the redis database for the users id. Remember, we are accessing the redis
object via context.
// ..
const key = FORGET_PASSWORD_PREFIX + token
const userId = await redis.get(key)
// ..
Now we apply some more checks to see if the user exists both the redis and user database and if either fails we return the appropriate errors (and their corresponding messages).
// ..
if (!userId) {
return {
errors: [{ field: "token", message: "token expired" }],
}
}
const user = await em.findOne(User, { id: parseInt(userId) })
if (!user) {
return {
errors: [{ field: "token", message: "token expired" }],
}
}
// ..
If there is no issues with finding the user, we then hash the password taken as a function argument and update the database.
As a security measure we delete the key from redis so the user (or someone else) cannot go back and use the same token again.
Finally we login the user using the req
object via the use of a session and return the user
.
// ..
user.password = await argon2.hash(newPassword);
em.persistAndFlush(user);
await redis.del(key);
req.session.userId = user.id;
return { user };
};
And that's it! The user will be logged in on the frontend when they end up back on the home page.
Final Notes
Thanks for taking the time to read this. Should you have any feedback or questions please feel free to reach out and let me know!
Top comments (0)