What do we need to do?
- Make a page where the user requests a password reset. The user should enter the email.
- Add link to this page.
- Send an email to the email address with a token.
- Make a page where the user sets a new password.
Note: we again won't be using NextAuth
because we don't need it.
Note 2: we will ensure that the user can only request a password reset when the user is signed out.
The code for this chapter is available on github, branch forgotpassword.
1. Request password reset
This is very similar to the request email confirmation page that we created in the previous chapter. This is the Strapi
endpoint:
const strapiResponse: any = await fetch(
process.env.STRAPI_BACKEND_URL + '/api/auth/forgot-password',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
cache: 'no-cache',
}
);
On a side note. Where do all these endpoints come from? Strapi
is open source. We can read the source code. All these endpoint come from the Users and permissions plugin. So, if we go to Strapi
on github and browse around the files a bit eventually you will find the auth.js file that contains all of the routes. You can also find the Strapi controllers in there if you're interested.
Let's create a page, a component and a server action:
// frontend/scr/app/(auth)/password/requestreset/page.tsx
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
import RequestPasswordReset from '@/components/auth/password/RequestPasswordReset';
import { authOptions } from '@/app/api/auth/[...nextauth]/authOptions';
export default async function RequestResetPage() {
const session = await getServerSession(authOptions);
if (session) redirect('/account');
return <RequestPasswordReset />;
}
Note that we guard this page. If the user is logged in, we redirect him to /account
. We will be building this page later on. Why do we do this at page level? Because I got some kind of error when I tried to do it in the actual component: Warning: Cannot update a component ('Router') while rendering a different component
.
In our server action, we validate the formData with Zod
, make the request to strapi
and handle errors. In this case, we won't redirect on success but return a success object: { error: false, message: 'Success' }
. We will handle this success in our form components. This is our requestPasswordResetAction
:
// frontend/src/components/auth/password/requestPasswordResetAction.ts
'use server';
import { z } from 'zod';
import { RequestPasswordResetFormStateT } from './RequestPasswordReset';
const formSchema = z.object({
email: z.string().email('Enter a valid email.').trim(),
});
export default async function requestPasswordResetAction(
prevState: RequestPasswordResetFormStateT,
formData: FormData
) {
const validatedFields = formSchema.safeParse({
email: formData.get('email'),
});
if (!validatedFields.success) {
return {
error: true,
message: 'Please verify your data.',
fieldErrors: validatedFields.error.flatten().fieldErrors,
};
}
const { email } = validatedFields.data;
try {
const strapiResponse: any = await fetch(
process.env.STRAPI_BACKEND_URL + '/api/auth/forgot-password',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
cache: 'no-cache',
}
);
const data = await strapiResponse.json();
// handle strapi error
if (!strapiResponse.ok) {
const response = {
error: true,
message: '',
};
// check if response in json-able
const contentType = strapiResponse.headers.get('content-type');
if (contentType === 'application/json; charset=utf-8') {
const data = await strapiResponse.json();
response.message = data.error.message;
} else {
response.message = strapiResponse.statusText;
}
return response;
}
// we do handle success here, we do not use a redirect!!
return {
error: false,
message: 'Success',
};
} catch (error: any) {
// network error or something
return {
error: true,
message: 'message' in error ? error.message : error.statusText,
};
}
}
Finally, in our actual form component, we listen for success return in our useFormState
state and display a success message. Else, we return the form. Also note that we updated our Types to account for the possible success object. The rest should be clear.
'use client';
import { useFormState } from 'react-dom';
import PendingSubmitButton from '../PendingSubmitButton';
import requestPasswordResetAction from './requestPasswordResetAction';
type InputErrorsT = {
email?: string[];
};
type NoErrorFormStateT = {
error: false;
message?: string;
};
type ErrorFormStateT = {
error: true;
message: string;
inputErrors?: InputErrorsT;
};
export type RequestPasswordResetFormStateT =
| NoErrorFormStateT
| ErrorFormStateT;
const initialState: NoErrorFormStateT = {
error: false,
};
export default function ForgotPassword() {
const [state, formAction] = useFormState<
RequestPasswordResetFormStateT,
FormData
>(requestPasswordResetAction, initialState);
if (!state.error && state.message === 'Success') {
return (
<div className='bg-zinc-100 rounded-sm px-4 py-8 mb-8'>
<h2 className='font-bold text-lg mb-4'>Check your email</h2>
<p>
We sent you an email with a link. Open this link to reset your
password. Careful, expires ...
</p>
</div>
);
}
return (
<div className='mx-auto my-8 p-8 max-w-lg bg-zinc-100 rounded-sm'>
<h2 className='text-center text-2xl text-blue-400 mb-8 font-bold'>
Request a password reset
</h2>
<p className='mb-4'>
Forgot your password? Enter your account email here and we will send you
a link you can use to reset your password.
</p>
<form action={formAction} className='my-8'>
<div className='mb-3'>
<label htmlFor='email' className='block mb-1'>
Email *
</label>
<input
type='email'
id='email'
name='email'
required
className='bg-white border border-zinc-300 w-full rounded-sm p-2'
/>
{state.error && state?.inputErrors?.email ? (
<div className='text-red-700' aria-live='polite'>
{state.inputErrors.email[0]}
</div>
) : null}
</div>
<div className='mb-3'>
<PendingSubmitButton />
</div>
{state.error && state.message ? (
<div className='text-red-700' aria-live='polite'>
{state.message}
</div>
) : null}
</form>
</div>
);
}
2. Add a link to request password reset
Our signed out user needs to be able to go to the page that we just created. We keep it simple and add a forgot password link next to the submit button on the sign in form.
3. Send a forgot password email from Strapi
To send our mail, we first have to add a setting
Settings > Users & Permissions plugin > Advanced settings > Reset password page
We set this field to http://localhost:3000/password/reset
.
Then, we need to update the Strapi
email template:
Settings > Users & Permissions plugin > Email templates > Reset password
You should alter the shipper name, email and subject. The body of the mail looks like this by default
<p>We heard that you lost your password. Sorry about that!</p>
<p>But don’t worry! You can use the following link to reset your password:</p>
<p><%= URL %>?code=<%= TOKEN %></p>
<p>Thanks.</p>
Where <%= URL %>?code=<%= TOKEN %>
resolves to http://localhost:3000/password/reset?code=***somecode***
, as we would expect. We only need to update this line so it actually becomes a link:
<p><a href="<%= URL %>?code=<%= TOKEN %>">Reset password link</a></p>
And save.
4. Build the password reset page
Now we need to actually build this page. To reset a password, we need a form with 2 fields: password and confirm password. But, the Strapi
endpoint requires 3 values: password, confirm password + the token (code searchParam). On success, Strapi
will then return a user object + a new Strapi
token. We will deal with this later. Let's first build the page:
// frontend/src/app/(auth)/password/reset/page.tsx
import { authOptions } from '@/app/api/auth/[...nextauth]/authOptions';
import { getServerSession } from 'next-auth';
import ResetPassword from '@/components/auth/password/ResetPassword';
import { redirect } from 'next/navigation';
type Props = {
searchParams: {
code?: string,
},
};
export default async function page({ searchParams }: Props) {
// if the user is logged in, redirect to account where password change is possible
const session = await getServerSession(authOptions);
if (session) redirect('/account');
return <ResetPassword code={searchParams.code} />;
}
Note that we don't let signed in users access this page and that we pass the code (a reset password token) to the actual component.
We will be using a server action that returns a success or error object but does not redirect. But, this leads to an immediate problem. How do we pass the code prop from our form component to our server action? This is easy to solve. We just put it into our initial useFormState
state:
const initialState{
error: false,
code: code || '',
};
const [state, formAction] = useFormState(
resetPasswordAction,
initialState
);
Our server action, resetPasswordAction
then has access to this via it's prevState argument:
export default async function resetPasswordAction(prevState, formData) {
// make strapi request passing prevState.code
}
But, this leads to another problem. Suppose the user mistypes and enters a wrong confirm your password value. Strapi
will detect this an return an error object. We catch this error in our server action (!strapiResponse.ok
) and then from our server action return an error object to our form.
The useFormState
state equals the return value from the server action. Where at this point is our code value? It is gone, unless we return it from our server action. If we return this from our server action:
return {
error: true,
message: 'something is wrong',
code: prevState.code,
};
Then the state that lives in our form will be reset with this code value.
Imagine we didn't return code from our server action. Our user just got the error message: passwords don't match. He fixes this error and resubmits the form. The form calls formAction. useFormState
catches this call and calls resetPasswordAction
with it's state and the formData. What is the state at that point: { error: true, message: 'something is wrong' }
. (no code property)
Our server action goes to work and tries to call Strapi
. From the formData we take password + confirm password. But, Strapi
also expects the code but we can't give it. It no longer is inside state! And Strapi
will error out: need the code.
So, we need to pass the code back from our server action whenever we return an error. When there is no error, the form won't be called again so we don't need it anymore. But, we do need it on every error we return! Including f.e. the Zod
errors. Hopefully this makes sense.
On to the form component:
// frontend/src/components/auth/password/resetPassword.tsx
'use client';
import { useFormState } from 'react-dom';
import resetPasswordAction from './resetPasswordAction';
import Link from 'next/link';
import PendingSubmitButton from '../PendingSubmitButton';
type Props = {
code: string | undefined;
};
type InputErrorsT = {
password?: string[];
passwordConfirmation?: string[];
};
export type ResetPasswordFormStateT = {
error: boolean;
message?: string;
inputErrors?: InputErrorsT;
code?: string;
};
export default function ResetPassword({ code }: Props) {
const initialState: ResetPasswordFormStateT = {
error: false,
code: code || '',
};
const [state, formAction] = useFormState<ResetPasswordFormStateT, FormData>(
resetPasswordAction,
initialState
);
if (!code) return <p>Error, please use the link we mailed you.</p>;
if (!state.error && 'message' in state && state.message === 'Success') {
return (
<div className='bg-zinc-100 rounded-sm px-4 py-8 mb-8'>
<h2 className='font-bold text-lg mb-4'>Password was reset</h2>
<p>
Your password was reset. You can now{' '}
<Link href='/signin' className='underline'>
sign in
</Link>{' '}
with your new password.
</p>
</div>
);
}
return (
<div className='mx-auto my-8 p-8 max-w-lg bg-zinc-100 rounded-sm'>
<h2 className='text-center text-2xl text-blue-400 mb-8 font-bold'>
Reset your password
</h2>
<p className='mb-4'>
To reset your password, enter your new password, confirm it by entering
it again and then click send.
</p>
<form action={formAction} className='my-8'>
<div className='mb-3'>
<label htmlFor='password' className='block mb-1'>
Password *
</label>
<input
type='password'
id='password'
name='password'
required
className='bg-white border border-zinc-300 w-full rounded-sm p-2'
/>
{state.error && state?.inputErrors?.password ? (
<div className='text-red-700' aria-live='polite'>
{state.inputErrors.password[0]}
</div>
) : null}
</div>
<div className='mb-3'>
<label htmlFor='passwordConfirmation' className='block mb-1'>
confirm your password *
</label>
<input
type='password'
id='passwordConfirmation'
name='passwordConfirmation'
required
className='bg-white border border-zinc-300 w-full rounded-sm p-2'
/>
{state.error && state?.inputErrors?.passwordConfirmation ? (
<div className='text-red-700' aria-live='polite'>
{state.inputErrors.passwordConfirmation[0]}
</div>
) : null}
</div>
<div className='mb-3'>
<PendingSubmitButton />
</div>
{state.error && state.message ? (
<div className='text-red-700' aria-live='polite'>
Error: {state.message}
</div>
) : null}
</form>
</div>
);
}
There is a couple of things to note. We check it there is a code (the token) and if state holds a success message. If not, we display the form. The only other thing that differs here from earlier forms is that we didn't use a discriminated union Type this time. It proved difficult with the code property. So we opted for a simpler Type where most properties are optional. This is works and is correct just not as specific as it could be.
Our server action:
// frontend/src/component/auth/password/resetPasswordAction.ts
'use server';
import { z } from 'zod';
import { ResetPasswordFormStateT } from './ResetPassword';
import { StrapiErrorT } from '@/types/strapi/StrapiError';
const formSchema = z.object({
password: z.string().min(6).max(30).trim(),
passwordConfirmation: z.string().min(6).max(30).trim(),
});
export default async function resetPasswordAction(
prevState: ResetPasswordFormStateT,
formData: FormData
) {
const validatedFields = formSchema.safeParse({
password: formData.get('password'),
passwordConfirmation: formData.get('passwordConfirmation'),
});
if (!validatedFields.success) {
return {
error: true,
message: 'Please verify your data.',
inputErrors: validatedFields.error.flatten().fieldErrors,
code: prevState.code,
};
}
const { password, passwordConfirmation } = validatedFields.data;
try {
const strapiResponse: any = await fetch(
process.env.STRAPI_BACKEND_URL + '/api/auth/reset-password',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
password,
passwordConfirmation,
code: prevState.code,
}),
cache: 'no-cache',
}
);
// handle strapi error
if (!strapiResponse.ok) {
const response = {
error: true,
message: '',
code: prevState.code,
};
// check if response in json-able
const contentType = strapiResponse.headers.get('content-type');
if (contentType === 'application/json; charset=utf-8') {
const data: StrapiErrorT = await strapiResponse.json();
response.message = data.error.message;
} else {
response.message = strapiResponse.statusText;
}
return response;
}
// success
// no need to pass code anymore
return {
error: false,
message: 'Success',
};
} catch (error: any) {
return {
error: true,
message: 'message' in error ? error.message : error.statusText,
code: prevState.code,
};
}
}
This should all make sense, we already explained the code property in the return object.
strapiResponse.ok
There is a thing though. On success, we don't actually use the strapiResponse. What is a successful strapiResponse? A user + a Strapi
jwt token. Oh, can we automatically sign in then? Maybe. But there are some problems:
- We left out
NextAuth
. So, we would have to incorporateNextAuth
. - How do we sign in? Using
signIn
fromNextAuth
. ButsignIn
is a client-side only function. We have our user, but inside a server-side server action. So how do we get our user from the server to the client?
We will come back to this in the last chapter.
Right now, on success, we just ask the user to sign in using a success message. This pattern is maybe not optimal but also not unheard of. On the upside, it works!
Summary
We just setup the forgot password flow using only Strapi
and leaving out NextAuth
. We added a request a password reset page, we handled sending an email and finished by creating the actual reset the password page.
Some minor problems were easily handled and mostly this was straightforward. There are 3 more things we have to handle:
- Create a user account page.
- Enable the user to change their password inside this page.
- Enable the user to edit their data inside this page.
If you want to support my writing, you can donate with paypal.