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 usingNextAuth
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 ongithub, 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 theStrapi
endpoint:
conststrapiResponse:any=awaitfetch(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 toStrapi
on github and browse around the files a bit eventually you will find theauth.js file that contains all of the routes. You can also find theStrapi 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.tsximport{getServerSession}from'next-auth';import{redirect}from'next/navigation';importRequestPasswordResetfrom'@/components/auth/password/RequestPasswordReset';import{authOptions}from'@/app/api/auth/[...nextauth]/authOptions';exportdefaultasyncfunctionRequestResetPage(){constsession=awaitgetServerSession(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 withZod
, make the request tostrapi
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 ourrequestPasswordResetAction
:
// frontend/src/components/auth/password/requestPasswordResetAction.ts'use server';import{z}from'zod';import{RequestPasswordResetFormStateT}from'./RequestPasswordReset';constformSchema=z.object({email:z.string().email('Enter a valid email.').trim(),});exportdefaultasyncfunctionrequestPasswordResetAction(prevState:RequestPasswordResetFormStateT,formData:FormData){constvalidatedFields=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{conststrapiResponse:any=awaitfetch(process.env.STRAPI_BACKEND_URL+'/api/auth/forgot-password',{method:'POST',headers:{'Content-Type':'application/json',},body:JSON.stringify({email}),cache:'no-cache',});constdata=awaitstrapiResponse.json();// handle strapi errorif(!strapiResponse.ok){constresponse={error:true,message:'',};// check if response in json-ableconstcontentType=strapiResponse.headers.get('content-type');if(contentType==='application/json; charset=utf-8'){constdata=awaitstrapiResponse.json();response.message=data.error.message;}else{response.message=strapiResponse.statusText;}returnresponse;}// we do handle success here, we do not use a redirect!!return{error:false,message:'Success',};}catch(error:any){// network error or somethingreturn{error:true,message:'message'inerror?error.message:error.statusText,};}}
Finally, in our actual form component, we listen for success return in ouruseFormState
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';importPendingSubmitButtonfrom'../PendingSubmitButton';importrequestPasswordResetActionfrom'./requestPasswordResetAction';typeInputErrorsT={email?:string[];};typeNoErrorFormStateT={error:false;message?:string;};typeErrorFormStateT={error:true;message:string;inputErrors?:InputErrorsT;};exporttypeRequestPasswordResetFormStateT=|NoErrorFormStateT|ErrorFormStateT;constinitialState:NoErrorFormStateT={error:false,};exportdefaultfunctionForgotPassword(){const[state,formAction]=useFormState<RequestPasswordResetFormStateT,FormData>(requestPasswordResetAction,initialState);if(!state.error&&state.message==='Success'){return(<divclassName='bg-zinc-100 rounded-sm px-4 py-8 mb-8'><h2className='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(<divclassName='mx-auto my-8 p-8 max-w-lg bg-zinc-100 rounded-sm'><h2className='text-center text-2xl text-blue-400 mb-8 font-bold'> Request a password reset</h2><pclassName='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><formaction={formAction}className='my-8'><divclassName='mb-3'><labelhtmlFor='email'className='block mb-1'> Email *</label><inputtype='email'id='email'name='email'requiredclassName='bg-white border border-zinc-300 w-full rounded-sm p-2'/>{state.error&&state?.inputErrors?.email?(<divclassName='text-red-700'aria-live='polite'>{state.inputErrors.email[0]}</div>):null}</div><divclassName='mb-3'><PendingSubmitButton/></div>{state.error&&state.message?(<divclassName='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 aforgot password link next to the submit button on thesign 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 tohttp://localhost:3000/password/reset
.
Then, we need to update theStrapi
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 tohttp://localhost:3000/password/reset?code=***somecode***
, as we would expect. We only need to update this line so it actually becomes a link:
<p><ahref="<%= 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, theStrapi
endpoint requires 3 values: password, confirm password + the token (code searchParam). On success,Strapi
will then return a user object + a newStrapi
token. We will deal with this later. Let's first build the page:
// frontend/src/app/(auth)/password/reset/page.tsximport{authOptions}from'@/app/api/auth/[...nextauth]/authOptions';import{getServerSession}from'next-auth';importResetPasswordfrom'@/components/auth/password/ResetPassword';import{redirect}from'next/navigation';typeProps={searchParams:{code?:string,},};exportdefaultasyncfunctionpage({searchParams}:Props){// if the user is logged in, redirect to account where password change is possibleconstsession=awaitgetServerSession(authOptions);if(session)redirect('/account');return<ResetPasswordcode={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 initialuseFormState
state:
constinitialState{error:false,code:code||'',};const[state,formAction]=useFormState(resetPasswordAction,initialState);
Our server action,resetPasswordAction
then has access to this via it's prevState argument:
exportdefaultasyncfunctionresetPasswordAction(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.
TheuseFormState
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 callsresetPasswordAction
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 callStrapi
. 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! AndStrapi
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 onevery error we return! Including f.e. theZod
errors. Hopefully this makes sense.
On to the form component:
// frontend/src/components/auth/password/resetPassword.tsx'use client';import{useFormState}from'react-dom';importresetPasswordActionfrom'./resetPasswordAction';importLinkfrom'next/link';importPendingSubmitButtonfrom'../PendingSubmitButton';typeProps={code:string|undefined;};typeInputErrorsT={password?:string[];passwordConfirmation?:string[];};exporttypeResetPasswordFormStateT={error:boolean;message?:string;inputErrors?:InputErrorsT;code?:string;};exportdefaultfunctionResetPassword({code}:Props){constinitialState: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'instate&&state.message==='Success'){return(<divclassName='bg-zinc-100 rounded-sm px-4 py-8 mb-8'><h2className='font-bold text-lg mb-4'>Password was reset</h2><p> Your password was reset. You can now{''}<Linkhref='/signin'className='underline'> sign in</Link>{''} with your new password.</p></div>);}return(<divclassName='mx-auto my-8 p-8 max-w-lg bg-zinc-100 rounded-sm'><h2className='text-center text-2xl text-blue-400 mb-8 font-bold'> Reset your password</h2><pclassName='mb-4'> To reset your password, enter your new password, confirm it by entering it again and then click send.</p><formaction={formAction}className='my-8'><divclassName='mb-3'><labelhtmlFor='password'className='block mb-1'> Password *</label><inputtype='password'id='password'name='password'requiredclassName='bg-white border border-zinc-300 w-full rounded-sm p-2'/>{state.error&&state?.inputErrors?.password?(<divclassName='text-red-700'aria-live='polite'>{state.inputErrors.password[0]}</div>):null}</div><divclassName='mb-3'><labelhtmlFor='passwordConfirmation'className='block mb-1'> confirm your password *</label><inputtype='password'id='passwordConfirmation'name='passwordConfirmation'requiredclassName='bg-white border border-zinc-300 w-full rounded-sm p-2'/>{state.error&&state?.inputErrors?.passwordConfirmation?(<divclassName='text-red-700'aria-live='polite'>{state.inputErrors.passwordConfirmation[0]}</div>):null}</div><divclassName='mb-3'><PendingSubmitButton/></div>{state.error&&state.message?(<divclassName='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';constformSchema=z.object({password:z.string().min(6).max(30).trim(),passwordConfirmation:z.string().min(6).max(30).trim(),});exportdefaultasyncfunctionresetPasswordAction(prevState:ResetPasswordFormStateT,formData:FormData){constvalidatedFields=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{conststrapiResponse:any=awaitfetch(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 errorif(!strapiResponse.ok){constresponse={error:true,message:'',code:prevState.code,};// check if response in json-ableconstcontentType=strapiResponse.headers.get('content-type');if(contentType==='application/json; charset=utf-8'){constdata:StrapiErrorT=awaitstrapiResponse.json();response.message=data.error.message;}else{response.message=strapiResponse.statusText;}returnresponse;}// success// no need to pass code anymorereturn{error:false,message:'Success',};}catch(error:any){return{error:true,message:'message'inerror?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 + aStrapi
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 aclient-side only function. We have our user, but inside aserver-side server action. So how do we get our user from the server to the client?
We will come back to this in thelast 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 onlyStrapi
and leaving outNextAuth
. 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 candonate with paypal.
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse