Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on • Edited 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 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',});
Enter fullscreen modeExit 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 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/>;}
Enter fullscreen modeExit 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 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,};}}
Enter fullscreen modeExit fullscreen mode

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.

request password reset

'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>);}
Enter fullscreen modeExit 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 aforgot password link next to the submit button on thesign 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 modeExit fullscreen mode

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

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>
Enter fullscreen modeExit 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, 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}/>;}
Enter fullscreen modeExit 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 initialuseFormState state:

constinitialState{error:false,code:code||'',};const[state,formAction]=useFormState(resetPasswordAction,initialState);
Enter fullscreen modeExit fullscreen mode

Our server action,resetPasswordAction then has access to this via it's prevState argument:

exportdefaultasyncfunctionresetPasswordAction(prevState,formData){// make strapi request passing prevState.code}
Enter fullscreen modeExit 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.

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,};
Enter fullscreen modeExit 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 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>);}
Enter fullscreen modeExit 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';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,};}}
Enter fullscreen modeExit 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 + aStrapi jwt token. Oh, can we automatically sign in then? Maybe. But there are some problems:

  1. We left outNextAuth. So, we would have to incorporateNextAuth.
  2. How do we sign in? UsingsignIn 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:

  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 candonate with paypal.

Top comments(0)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

Front-end developer
  • Location
    Belgium
  • Education
    self taught
  • Joined

More fromPeter Jacxsens

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp