20/ Change password flow in Strapi and NextAuth CredentialsProvider
Tags: nextauth, strapi, nextjs
Published: 2024-04-01 16:36:59

The code in this chapter is available on [github (branch changepassword)](https://github.com/peterlidee/NNAS/tree/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:

```jsx
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',
  }
);
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3bco5ti0o7svf8c2c3d9.png)

## 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.

```ts
// 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,
    };
  }
}
```

## State and handleSubmit

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

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

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

Then we write our handleSubmit function:

```jsx
async function handleSubmit(formData: FormData) {
  setLoading(true);
  const actionRes = await changePasswordAction(formData);
  setActionState(actionRes);
  setLoading(false);
}
```

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.

```jsx
if (
  !actionRes.error &&
  'message' in actionRes &&
  actionRes.message === 'Success'
) {
  // update next auth
  update({ strapiToken: actionRes.data.strapiToken });
}
```

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

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

// change password update
if (trigger === 'update' && session?.strapiToken) {
  token.strapiToken = session.strapiToken;
}
```

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

```jsx
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();
}
```

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9ws4en4dj5e512rveqsq.png)

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:

```jsx
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}>
```

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

```jsx
<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>
```

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?

```jsx
<PendingSubmitButton />
```

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:

```jsx
// 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>
  );
}
```

## 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](https://www.paypal.com/donate/?hosted_button_id=4D78YQU4V5NEJ).