232 lines
		
	
	
	
		
			7.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			232 lines
		
	
	
	
		
			7.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|   | import { getCsrfToken } from "next-auth/client"; | ||
|  | import prisma from "../../../lib/prisma"; | ||
|  | 
 | ||
|  | import Head from "next/head"; | ||
|  | import React from "react"; | ||
|  | import debounce from "lodash.debounce"; | ||
|  | import dayjs from "dayjs"; | ||
|  | import { ResetPasswordRequest } from "@prisma/client"; | ||
|  | import { useMemo } from "react"; | ||
|  | import Link from "next/link"; | ||
|  | import { GetServerSidePropsContext } from "next"; | ||
|  | 
 | ||
|  | type Props = { | ||
|  |   id: string; | ||
|  |   resetPasswordRequest: ResetPasswordRequest; | ||
|  |   csrfToken: string; | ||
|  | }; | ||
|  | 
 | ||
|  | export default function Page({ resetPasswordRequest, csrfToken }: Props) { | ||
|  |   const [loading, setLoading] = React.useState(false); | ||
|  |   const [error, setError] = React.useState(null); | ||
|  |   const [success, setSuccess] = React.useState(false); | ||
|  | 
 | ||
|  |   const [password, setPassword] = React.useState(""); | ||
|  |   const handleChange = (e) => { | ||
|  |     setPassword(e.target.value); | ||
|  |   }; | ||
|  | 
 | ||
|  |   const submitChangePassword = async ({ password, requestId }) => { | ||
|  |     try { | ||
|  |       const res = await fetch("/api/auth/reset-password", { | ||
|  |         method: "POST", | ||
|  |         body: JSON.stringify({ requestId: requestId, password: password }), | ||
|  |         headers: { | ||
|  |           "Content-Type": "application/json", | ||
|  |         }, | ||
|  |       }); | ||
|  | 
 | ||
|  |       const json = await res.json(); | ||
|  | 
 | ||
|  |       if (!res.ok) { | ||
|  |         setError(json); | ||
|  |       } else { | ||
|  |         setSuccess(true); | ||
|  |       } | ||
|  | 
 | ||
|  |       return json; | ||
|  |     } catch (reason) { | ||
|  |       setError({ message: "An unexpected error occurred. Try again." }); | ||
|  |     } finally { | ||
|  |       setLoading(false); | ||
|  |     } | ||
|  |   }; | ||
|  | 
 | ||
|  |   const debouncedChangePassword = debounce(submitChangePassword, 250); | ||
|  | 
 | ||
|  |   const handleSubmit = async (e) => { | ||
|  |     e.preventDefault(); | ||
|  | 
 | ||
|  |     if (!password) { | ||
|  |       return; | ||
|  |     } | ||
|  | 
 | ||
|  |     if (loading) { | ||
|  |       return; | ||
|  |     } | ||
|  | 
 | ||
|  |     setLoading(true); | ||
|  |     setError(null); | ||
|  |     setSuccess(false); | ||
|  | 
 | ||
|  |     await debouncedChangePassword({ password, requestId: resetPasswordRequest.id }); | ||
|  |   }; | ||
|  | 
 | ||
|  |   const Success = () => { | ||
|  |     return ( | ||
|  |       <> | ||
|  |         <div className="space-y-6"> | ||
|  |           <div> | ||
|  |             <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Success</h2> | ||
|  |           </div> | ||
|  |           <p>Your password has been reset. You can now login with your newly created password.</p> | ||
|  |           <Link href="/auth/login"> | ||
|  |             <button | ||
|  |               type="button" | ||
|  |               className="w-full flex justify-center py-2 px-4 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> | ||
|  |               Login | ||
|  |             </button> | ||
|  |           </Link> | ||
|  |         </div> | ||
|  |       </> | ||
|  |     ); | ||
|  |   }; | ||
|  | 
 | ||
|  |   const Expired = () => { | ||
|  |     return ( | ||
|  |       <> | ||
|  |         <div className="space-y-6"> | ||
|  |           <div> | ||
|  |             <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Whoops</h2> | ||
|  |             <h2 className="text-center text-3xl font-extrabold text-gray-900">That Request is Expired.</h2> | ||
|  |           </div> | ||
|  |           <p> | ||
|  |             That request is expired. You can back and enter the email associated with your account and we will | ||
|  |             you another link to reset your password. | ||
|  |           </p> | ||
|  |           <Link href="/auth/forgot-password"> | ||
|  |             <button | ||
|  |               type="button" | ||
|  |               className="w-full flex justify-center py-2 px-4 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> | ||
|  |               Try Again | ||
|  |             </button> | ||
|  |           </Link> | ||
|  |         </div> | ||
|  |       </> | ||
|  |     ); | ||
|  |   }; | ||
|  | 
 | ||
|  |   const isRequestExpired = useMemo(() => { | ||
|  |     const now = dayjs(); | ||
|  |     return dayjs(resetPasswordRequest.expires).isBefore(now); | ||
|  |   }, [resetPasswordRequest]); | ||
|  | 
 | ||
|  |   return ( | ||
|  |     <div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8"> | ||
|  |       <Head> | ||
|  |         <title>Reset Password</title> | ||
|  |         <link rel="icon" href="/favicon.ico" /> | ||
|  |       </Head> | ||
|  |       <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> | ||
|  |         <div className="bg-white py-8 px-4 mx-2 shadow rounded-lg sm:px-10 space-y-6"> | ||
|  |           {isRequestExpired && <Expired />} | ||
|  |           {!isRequestExpired && !success && ( | ||
|  |             <> | ||
|  |               <div className="space-y-6"> | ||
|  |                 <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Reset Password</h2> | ||
|  |                 <p>Enter the new password you'd like for your account.</p> | ||
|  |                 {error && <p className="text-red-600">{error.message}</p>} | ||
|  |               </div> | ||
|  |               <form className="space-y-6" onSubmit={handleSubmit} action="#"> | ||
|  |                 <input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden /> | ||
|  |                 <div> | ||
|  |                   <label htmlFor="email" className="block text-sm font-medium text-gray-700"> | ||
|  |                     New Password | ||
|  |                   </label> | ||
|  |                   <div className="mt-1"> | ||
|  |                     <input | ||
|  |                       onChange={handleChange} | ||
|  |                       id="password" | ||
|  |                       name="password" | ||
|  |                       type="password" | ||
|  |                       autoComplete="password" | ||
|  |                       required | ||
|  |                       className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" | ||
|  |                     /> | ||
|  |                   </div> | ||
|  |                 </div> | ||
|  | 
 | ||
|  |                 <div> | ||
|  |                   <button | ||
|  |                     type="submit" | ||
|  |                     disabled={loading} | ||
|  |                     className={`w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${ | ||
|  |                       loading ? "cursor-not-allowed" : "" | ||
|  |                     }`}>
 | ||
|  |                     {loading && ( | ||
|  |                       <svg | ||
|  |                         className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" | ||
|  |                         xmlns="http://www.w3.org/2000/svg" | ||
|  |                         fill="none" | ||
|  |                         viewBox="0 0 24 24"> | ||
|  |                         <circle | ||
|  |                           className="opacity-25" | ||
|  |                           cx="12" | ||
|  |                           cy="12" | ||
|  |                           r="10" | ||
|  |                           stroke="currentColor" | ||
|  |                           strokeWidth="4"></circle> | ||
|  |                         <path | ||
|  |                           className="opacity-75" | ||
|  |                           fill="currentColor" | ||
|  |                           d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> | ||
|  |                       </svg> | ||
|  |                     )} | ||
|  |                     Submit | ||
|  |                   </button> | ||
|  |                 </div> | ||
|  |               </form> | ||
|  |             </> | ||
|  |           )} | ||
|  |           {!isRequestExpired && success && ( | ||
|  |             <> | ||
|  |               <Success /> | ||
|  |             </> | ||
|  |           )} | ||
|  |         </div> | ||
|  |       </div> | ||
|  |     </div> | ||
|  |   ); | ||
|  | } | ||
|  | 
 | ||
|  | export async function getServerSideProps(context: GetServerSidePropsContext) { | ||
|  |   const id = context.params.id; | ||
|  | 
 | ||
|  |   try { | ||
|  |     const resetPasswordRequest = await prisma.resetPasswordRequest.findUnique({ | ||
|  |       where: { | ||
|  |         id: id, | ||
|  |       }, | ||
|  |       select: { | ||
|  |         id: true, | ||
|  |         expires: true, | ||
|  |       }, | ||
|  |     }); | ||
|  | 
 | ||
|  |     return { | ||
|  |       props: { | ||
|  |         resetPasswordRequest: { | ||
|  |           ...resetPasswordRequest, | ||
|  |           expires: resetPasswordRequest.expires.toString(), | ||
|  |         }, | ||
|  |         id, | ||
|  |         csrfToken: await getCsrfToken({ req: context.req }), | ||
|  |       }, | ||
|  |     }; | ||
|  |   } catch (reason) { | ||
|  |     return { | ||
|  |       notFound: true, | ||
|  |     }; | ||
|  |   } | ||
|  | } |