DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on • Updated on

16/ Forgot password flow with Strapi and NextAuth

What do we need to do?

  1. Make a page where the user requests a password reset. The user should enter the email.
  2. Add link to this page.
  3. Send an email to the email address with a token.
  4. 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',
  }
);
Enter fullscreen mode Exit fullscreen mode

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 />;
}
Enter fullscreen mode Exit fullscreen mode

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,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

request password reset

'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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

link to forgot password page

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. We left out NextAuth. So, we would have to incorporate NextAuth.
  2. How do we sign in? Using signIn from NextAuth. But signIn 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:

  1. Create a user account page.
  2. Enable the user to change their password inside this page.
  3. Enable the user to edit their data inside this page.

If you want to support my writing, you can donate with paypal.

Top comments (0)