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',
}
);
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.
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,
};
}
}
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);
Then we write our handleSubmit function:
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:
- "Your new password must be different than your current password" when our old password equals the new one.
- "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 });
}
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;
}
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();
}
Some final touches
Testing this out revealed that the Strapi token
inside the NextAuth token
was indeed updated. But there were some small issues:
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}>
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>
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 />
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>
);
}
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.