232 lines
		
	
	
	
		
			7.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			232 lines
		
	
	
	
		
			7.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { ResetPasswordRequest } from "@prisma/client";
 | |
| import dayjs from "dayjs";
 | |
| import debounce from "lodash/debounce";
 | |
| import { GetServerSidePropsContext } from "next";
 | |
| import { getCsrfToken } from "next-auth/react";
 | |
| import Link from "next/link";
 | |
| import React, { useMemo } from "react";
 | |
| 
 | |
| import { useLocale } from "@lib/hooks/useLocale";
 | |
| import prisma from "@lib/prisma";
 | |
| 
 | |
| import { HeadSeo } from "@components/seo/head-seo";
 | |
| 
 | |
| type Props = {
 | |
|   id: string;
 | |
|   resetPasswordRequest: ResetPasswordRequest;
 | |
|   csrfToken: string;
 | |
| };
 | |
| 
 | |
| export default function Page({ resetPasswordRequest, csrfToken }: Props) {
 | |
|   const { t } = useLocale();
 | |
|   const [loading, setLoading] = React.useState(false);
 | |
|   const [error, setError] = React.useState<{ message: string } | null>(null);
 | |
|   const [success, setSuccess] = React.useState(false);
 | |
| 
 | |
|   const [password, setPassword] = React.useState("");
 | |
| 
 | |
|   const submitChangePassword = async ({ password, requestId }: { password: string; requestId: string }) => {
 | |
|     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: t("unexpected_error_try_again") });
 | |
|     } finally {
 | |
|       setLoading(false);
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   const debouncedChangePassword = debounce(submitChangePassword, 250);
 | |
| 
 | |
|   const Success = () => {
 | |
|     return (
 | |
|       <>
 | |
|         <div className="space-y-6">
 | |
|           <div>
 | |
|             <h2 className="font-cal mt-6 text-center text-3xl font-extrabold text-gray-900">
 | |
|               {t("success")}
 | |
|             </h2>
 | |
|           </div>
 | |
|           <p>{t("password_has_been_reset_login")}</p>
 | |
|           <Link href="/auth/login">
 | |
|             <button
 | |
|               type="button"
 | |
|               className="flex w-full justify-center px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
 | |
|               {t("login")}
 | |
|             </button>
 | |
|           </Link>
 | |
|         </div>
 | |
|       </>
 | |
|     );
 | |
|   };
 | |
| 
 | |
|   const Expired = () => {
 | |
|     return (
 | |
|       <>
 | |
|         <div className="space-y-6">
 | |
|           <div>
 | |
|             <h2 className="font-cal mt-6 text-center text-3xl font-extrabold text-gray-900">{t("whoops")}</h2>
 | |
|             <h2 className="text-center text-3xl font-extrabold text-gray-900">{t("request_is_expired")}</h2>
 | |
|           </div>
 | |
|           <p>{t("request_is_expired_instructions")}</p>
 | |
|           <Link href="/auth/forgot-password">
 | |
|             <button
 | |
|               type="button"
 | |
|               className="flex w-full justify-center px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
 | |
|               {t("try_again")}
 | |
|             </button>
 | |
|           </Link>
 | |
|         </div>
 | |
|       </>
 | |
|     );
 | |
|   };
 | |
| 
 | |
|   const isRequestExpired = useMemo(() => {
 | |
|     const now = dayjs();
 | |
|     return dayjs(resetPasswordRequest.expires).isBefore(now);
 | |
|   }, [resetPasswordRequest]);
 | |
| 
 | |
|   return (
 | |
|     <div className="flex min-h-screen flex-col justify-center bg-gray-50 py-12 sm:px-6 lg:px-8">
 | |
|       <HeadSeo title={t("reset_password")} description={t("change_your_password")} />
 | |
|       <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
 | |
|         <div className="mx-2 space-y-6 rounded-lg bg-white px-4 py-8 shadow sm:px-10">
 | |
|           {isRequestExpired && <Expired />}
 | |
|           {!isRequestExpired && !success && (
 | |
|             <>
 | |
|               <div className="space-y-6">
 | |
|                 <h2 className="font-cal mt-6 text-center text-3xl font-extrabold text-gray-900">
 | |
|                   {t("reset_password")}
 | |
|                 </h2>
 | |
|                 <p>{t("enter_new_password")}</p>
 | |
|                 {error && <p className="text-red-600">{error.message}</p>}
 | |
|               </div>
 | |
|               <form
 | |
|                 className="space-y-6"
 | |
|                 onSubmit={async (e) => {
 | |
|                   e.preventDefault();
 | |
| 
 | |
|                   if (!password) {
 | |
|                     return;
 | |
|                   }
 | |
| 
 | |
|                   if (loading) {
 | |
|                     return;
 | |
|                   }
 | |
| 
 | |
|                   setLoading(true);
 | |
|                   setError(null);
 | |
|                   setSuccess(false);
 | |
| 
 | |
|                   await debouncedChangePassword({ password, requestId: resetPasswordRequest.id });
 | |
|                 }}
 | |
|                 action="#">
 | |
|                 <input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
 | |
|                 <div>
 | |
|                   <label htmlFor="email" className="block text-sm font-medium text-gray-700">
 | |
|                     {t("new_password")}
 | |
|                   </label>
 | |
|                   <div className="mt-1">
 | |
|                     <input
 | |
|                       onChange={(e) => {
 | |
|                         setPassword(e.target.value);
 | |
|                       }}
 | |
|                       id="password"
 | |
|                       name="password"
 | |
|                       type="password"
 | |
|                       autoComplete="password"
 | |
|                       required
 | |
|                       className="focus:border-brand block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:outline-none focus:ring-black sm:text-sm"
 | |
|                     />
 | |
|                   </div>
 | |
|                 </div>
 | |
| 
 | |
|                 <div>
 | |
|                   <button
 | |
|                     type="submit"
 | |
|                     disabled={loading}
 | |
|                     className={`flex w-full justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 ${
 | |
|                       loading ? "cursor-not-allowed" : ""
 | |
|                     }`}>
 | |
|                     {loading && (
 | |
|                       <svg
 | |
|                         className="mr-3 -ml-1 h-5 w-5 animate-spin 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>
 | |
|                     )}
 | |
|                     {t("submit")}
 | |
|                   </button>
 | |
|                 </div>
 | |
|               </form>
 | |
|             </>
 | |
|           )}
 | |
|           {!isRequestExpired && success && (
 | |
|             <>
 | |
|               <Success />
 | |
|             </>
 | |
|           )}
 | |
|         </div>
 | |
|       </div>
 | |
|     </div>
 | |
|   );
 | |
| }
 | |
| 
 | |
| export async function getServerSideProps(context: GetServerSidePropsContext) {
 | |
|   const id = context.params?.id as string;
 | |
| 
 | |
|   try {
 | |
|     const resetPasswordRequest = await prisma.resetPasswordRequest.findUnique({
 | |
|       rejectOnNotFound: true,
 | |
|       where: {
 | |
|         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,
 | |
|     };
 | |
|   }
 | |
| }
 | 
