20/ Change password flow in Strapi and NextAuth CredentialsProvider

Peter Jacxsens - Apr 1 - - Dev Community

The code in this chapter is available on github (branch changepassword).

The last functionality in a Credentials auth flow that we are missing is for a signed in user to change their password.

Strapi does provides us with an endpoint for this:

const strapiResponse = await fetch(
  process.env.STRAPI_BACKEND_URL + '/api/auth/change-password',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: 'Bearer ' + session.strapiToken,
    },
    body: JSON.stringify({
      currentPassword,
      password: newPassword,
      passwordConfirmation,
    }),
    cache: 'no-cache',
  }
);
Enter fullscreen mode Exit fullscreen mode

So, it's a POST request to /api/auth/change-password and we need to pass in the strapiToken, the old password, the new password and the new password confirmation. On success, Strapi will return a user object and a new strapi token. This makes sense, we want to create a new Strapi session when the password got changed.

The user will be logged in. It's a guarded component. We will then have to update the NextAuth token with this new Strapi token. We know how to do this now because we just did it in the previous chapter. By using useSession.update. But, this also means that we will once again have to do the awkward server-side to client-side code thing.

To add this functionality to our app, we are simply adding a <ChangePassword /> component to the <Account /> component. <ChangePassword /> has a form with 3 input fields for the passwords.

Change password component

Server action

We will call the Strapi endpoint in a server action. Not because we need to call revalidateTag like we did in the previous chapter but simple because we want to hide our backend url from the user. An alternative to this would be to setup a route handler but since we do have a form, we'll use a server action. Also, again no useFormState.

Unlike the previous chapter where we attached our handleSubmit function to the form onSubmit attribute we will now attach it to the form action attribute. This means that we don't have to handle input state. Action provides us with FormData. The handleSubmit function is just a regular async function and we will call our server action inside it. This allows us to go from server code to client code as we saw in the previous chapter also. Finally, we will be using Zod to validate our input fields again.

Here is our changePasswordAction. It's quite long but should be clear.

// frontend/src/components/auth/password/changePasswordAction.ts

'use server';

import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/app/api/auth/[...nextauth]/authOptions';
import { z } from 'zod';
import { StrapiErrorT } from '@/types/strapi/StrapiError';

type InputErrorsT = {
  currentPassword?: string[];
  newPassword?: string[];
  passwordConfirmation?: string[];
};
type ActionErrorT = {
  error: true;
  message: string;
  inputErrors?: InputErrorsT;
};
type ActionSuccessT = {
  error: false;
  message: 'Success';
  data: {
    strapiToken: string;
  };
};
export type ChangePasswordActionT = ActionErrorT | ActionSuccessT;

const formSchema = z.object({
  currentPassword: z.string().min(6).max(25).trim(),
  newPassword: z.string().min(6).max(25).trim(),
  passwordConfirmation: z.string().min(6).max(25).trim(),
});

export default async function resetPasswordAction(
  formData: FormData
): Promise<ChangePasswordActionT> {
  // get session and validate
  const session = await getServerSession(authOptions);
  if (!session) {
    return {
      error: true,
      message: 'You need to be logged in.',
    };
  }

  // validate inputs
  const validatedFields = formSchema.safeParse({
    currentPassword: formData.get('currentPassword'),
    newPassword: formData.get('newPassword'),
    passwordConfirmation: formData.get('passwordConfirmation'),
  });
  if (!validatedFields.success) {
    return {
      error: true,
      message: 'Please verify your data.',
      inputErrors: validatedFields.error.flatten().fieldErrors,
    };
  }
  const { currentPassword, newPassword, passwordConfirmation } =
    validatedFields.data;

  // call Strapi
  try {
    const strapiResponse = await fetch(
      process.env.STRAPI_BACKEND_URL + '/api/auth/change-password',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: 'Bearer ' + session.strapiToken,
        },
        body: JSON.stringify({
          currentPassword,
          password: newPassword,
          passwordConfirmation,
        }),
        cache: 'no-cache',
      }
    );

    // handle strapi error
    if (!strapiResponse.ok) {
      const response: ActionErrorT = {
        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: StrapiErrorT = await strapiResponse.json();
        response.message = data.error.message;
      } else {
        response.message = strapiResponse.statusText;
      }
      return response;
    }

    // handle Strapi success
    // Strapi returns a user + token, we return the token as data.strapiToken
    const data: { jwt: string } = await strapiResponse.json();
    return {
      error: false,
      message: 'Success',
      data: {
        strapiToken: data.jwt,
      },
    };
  } catch (error: any) {
    return {
      error: true,
      message: 'message' in error ? error.message : error.statusText,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

State and handleSubmit

We start by setting 2 pieces of state in our <ChangePassword /> component, actionState and loading:

// frontend/src/components/auth/password/ChangePassword.tsx

const [actionState, setActionState] =
  useState <
  InitialFormStateT >
  {
    error: false,
  };
const [loading, setLoading] = useState(false);
Enter fullscreen mode Exit fullscreen mode

Then we write our handleSubmit function:

async function handleSubmit(formData: FormData) {
  setLoading(true);
  const actionRes = await changePasswordAction(formData);
  setActionState(actionRes);
  setLoading(false);
}
Enter fullscreen mode Exit fullscreen mode

At this point we called the server action and it either return an error or success object that we saved into actionState. In our form jsx we check actionState and display form and input errors and give user feedback. We're not done but we can already test it out. We get errors when:

  • Password is too short. (Zod)
  • Passwords don't match. (Strapi)
  • Current password is incorrect. (Strapi)

Interestingly, we get 2 new errors:

  1. "Your new password must be different than your current password" when our old password equals the new one.
  2. "Too many requests, please try again later" when we spam the submit button.

Great but let's finish our handler.

Update NextAuth token

As we just called the server action we now have either a success or error object in our actionState. We can listen for this and on success, update the token.

if (
  !actionRes.error &&
  'message' in actionRes &&
  actionRes.message === 'Success'
) {
  // update next auth
  update({ strapiToken: actionRes.data.strapiToken });
}
Enter fullscreen mode Exit fullscreen mode

After calling update we catch it inside our jwt callback in authOptions:

// frontend/src/app/api/[...nextauth]/authOptions.ts

// change password update
if (trigger === 'update' && session?.strapiToken) {
  token.strapiToken = session.strapiToken;
}
Enter fullscreen mode Exit fullscreen mode

This will update the token and useSession but not getServerSession. So we need to await the update function and finally add router.refresh():

if (
  !actionRes.error &&
  'message' in actionRes &&
  actionRes.message === 'Success'
) {
  // update next auth
  await update({ strapiToken: actionRes.data.strapiToken });
  // after update we should do a router.refresh to refresh the server session
  router.refresh();
}
Enter fullscreen mode Exit fullscreen mode

Some final touches

Testing this out revealed that the Strapi token inside the NextAuth token was indeed updated. But there were some small issues:

change password

In the image above, we did a password change but the form fields are still populated. On a successful password change, we would like to reset them. We add a useRef hook, link it to our form and on success clear the form:

const formRef = useRef < HTMLFormElement > null;
// ...

if (
  !actionRes.error &&
  'message' in actionRes &&
  actionRes.message === 'Success'
) {
  // reset formfields with useRef
  // https://sabin.dev/blog/how-to-clear-your-forms-when-using-server-actions-in-nextjs
  formRef.current?.reset();
  // update next auth
  await update({ strapiToken: actionRes.data.strapiToken });
  // after update we should do a router.refresh to refresh the server session
  router.refresh();
}

// <form ref={formRef}>
Enter fullscreen mode Exit fullscreen mode

Second problem: the loading state on our submit button doesn't work:

<button
  type='submit'
  className='bg-blue-400 px-4 py-2 rounded-md disabled:bg-sky-200 disabled:text-gray-400 disabled:cursor-wait'
  disabled={loading}
  aria-disabled={loading}
>
  change password
</button>
Enter fullscreen mode Exit fullscreen mode

This left me baffled for a bit but then I tried something out. Since we are using the action attribute on the form, perhaps maybe our submit button that uses useFormStatus will work?

<PendingSubmitButton />
Enter fullscreen mode Exit fullscreen mode

It does! This surprised me a bit. I thought this hook was tied to useFormState. It seems it's not. So we flip out our manual button with the <PendingSubmitButton /> we used before, and remove the loading state and done!

This is our final <ChangePassword /> component:

// frontend/src/components/auth/password/ChangePassword.tsx

'use client';

import { useRef, useState } from 'react';
import changePasswordAction from './changePasswordAction';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import PendingSubmitButton from '../PendingSubmitButton';

type InputErrorsT = {
  currentPassword?: string[],
  newPassword?: string[],
  passwordConfirmation?: string[],
};
export type ErrorActionStateT = {
  error: true,
  message: string,
  inputErrors?: InputErrorsT,
};
type NoErrorActionStateT = {
  error: false,
  message: 'Success',
  data: {
    strapiToken: string,
  },
};
type InitialActionStateT = {
  error: false,
};
export type ChangePasswordActionStateT =
  | ErrorActionStateT
  | NoErrorActionStateT
  | InitialActionStateT;

export default function ChangePassword() {
  const [actionState, setActionState] =
    useState <
    ChangePasswordActionStateT >
    {
      error: false,
    };
  const router = useRouter();
  const formRef = useRef < HTMLFormElement > null;
  const { update } = useSession();

  async function handleSubmit(formData: FormData) {
    const actionRes = await changePasswordAction(formData);
    setActionState(actionRes);

    // if actionRes was successful (password was updated)
    // the actionRes returns us a new jwt strapiToken
    // we use this token to update next auth token and session
    if (
      !actionRes.error &&
      'message' in actionRes &&
      actionRes.message === 'Success'
    ) {
      // reset formfields with useRef
      // https://sabin.dev/blog/how-to-clear-your-forms-when-using-server-actions-in-nextjs
      formRef.current?.reset();
      // update next auth
      await update({ strapiToken: actionRes.data.strapiToken });
      // after update we should do a router.refresh to refresh the server session
      router.refresh();
    }
  }

  return (
    <form className='my-8 max-w-sm' action={handleSubmit} ref={formRef}>
      <div className='mb-3'>
        <label htmlFor='currentPassword' className='block mb-1'>
          Enter your old password *
        </label>
        <input
          type='password'
          id='currentPassword'
          name='currentPassword'
          required
          className='bg-white border border-zinc-300 w-full rounded-sm p-2'
        />
        {actionState.error && actionState?.inputErrors?.currentPassword ? (
          <div className='text-red-700' aria-live='polite'>
            {actionState.inputErrors.currentPassword[0]}
          </div>
        ) : null}
      </div>
      <div className='mb-3'>
        <label htmlFor='newPassword' className='block mb-1'>
          Enter your new password *
        </label>
        <input
          type='password'
          id='newPassword'
          name='newPassword'
          required
          className='bg-white border border-zinc-300 w-full rounded-sm p-2'
        />
        {actionState.error && actionState?.inputErrors?.newPassword ? (
          <div className='text-red-700' aria-live='polite'>
            {actionState.inputErrors.newPassword[0]}
          </div>
        ) : null}
      </div>
      <div className='mb-3'>
        <label htmlFor='passwordConfirmation' className='block mb-1'>
          Confirm your new password *
        </label>
        <input
          type='password'
          id='passwordConfirmation'
          name='passwordConfirmation'
          required
          className='bg-white border border-zinc-300 w-full rounded-sm p-2'
        />
        {actionState.error && actionState?.inputErrors?.passwordConfirmation ? (
          <div className='text-red-700' aria-live='polite'>
            {actionState.inputErrors.passwordConfirmation[0]}
          </div>
        ) : null}
      </div>
      <div className='mb-3'>
        <PendingSubmitButton />
      </div>
      {actionState.error && actionState.message ? (
        <div className='text-red-700' aria-live='polite'>
          {actionState.message}
        </div>
      ) : null}
      {!actionState.error &&
      'message' in actionState &&
      actionState.message === 'Success' ? (
        <div className='text-green-700' aria-live='polite'>
          Your password was updated.
        </div>
      ) : null}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

This component seemed more manageable then our previous <ChangeUsername /> component. This is partly true. Not having our form value inside state + not having to worry about loading state made the component simpler. But, we had already learned some of the more complex behavior of this component before in the <ChangeUsername /> component. In the end, it is still a pretty complex component. This is mainly caused by the fact that we cannot handle the auth process server side.

This almost concludes our series. We do have some loose threads to tie up which we will be doing in the final chapter.


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

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .