From 329019b34e652bc3220859d0fddee1702e72b98e Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Mon, 20 May 2024 19:23:11 -0400 Subject: [PATCH] finalized password reset + code refactoring --- hooks/useInitialData.tsx | 2 + layouts/AuthRedirect.tsx | 97 +++++++++++++++-------------- lib/api/sendPasswordResetRequest.ts | 4 +- lib/api/sendVerificationRequest.ts | 20 ++++-- pages/_app.tsx | 12 ++++ pages/api/v1/auth/[...nextauth].ts | 68 ++++++++++++++++++-- pages/api/v1/auth/reset-password.ts | 2 +- pages/auth/reset-password.tsx | 85 +++++++++++++------------ pages/confirmation.tsx | 44 ++++++++++--- pages/forgot.tsx | 8 +-- pages/login.tsx | 6 +- pages/register.tsx | 9 ++- 12 files changed, 239 insertions(+), 118 deletions(-) diff --git a/hooks/useInitialData.tsx b/hooks/useInitialData.tsx index 775f8c5..4b0dd17 100644 --- a/hooks/useInitialData.tsx +++ b/hooks/useInitialData.tsx @@ -29,4 +29,6 @@ export default function useInitialData() { // setLinks(); } }, [account]); + + return status; } diff --git a/layouts/AuthRedirect.tsx b/layouts/AuthRedirect.tsx index 9c35f6e..7f88b45 100644 --- a/layouts/AuthRedirect.tsx +++ b/layouts/AuthRedirect.tsx @@ -1,8 +1,7 @@ -import { ReactNode } from "react"; +import { ReactNode, useEffect, useState } from "react"; +import { useRouter } from "next/router"; import { useSession } from "next-auth/react"; import Loader from "../components/Loader"; -import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; import useInitialData from "@/hooks/useInitialData"; import useAccountStore from "@/store/account"; @@ -10,62 +9,68 @@ interface Props { children: ReactNode; } +const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"; +const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true"; + export default function AuthRedirect({ children }: Props) { const router = useRouter(); - const { status, data } = useSession(); - const [redirect, setRedirect] = useState(true); + const { status } = useSession(); + const [shouldRenderChildren, setShouldRenderChildren] = useState(false); const { account } = useAccountStore(); - const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"; - const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true"; - useInitialData(); useEffect(() => { - if (!router.pathname.startsWith("/public")) { - if ( - status === "authenticated" && - account.id && - !account.subscription?.active && - stripeEnabled - ) { - router.push("/subscribe").then(() => { - setRedirect(false); - }); + const isLoggedIn = status === "authenticated"; + const isUnauthenticated = status === "unauthenticated"; + const isPublicPage = router.pathname.startsWith("/public"); + const hasInactiveSubscription = + account.id && !account.subscription?.active && stripeEnabled; + const routes = [ + { path: "/login", isProtected: false }, + { path: "/register", isProtected: false }, + { path: "/confirmation", isProtected: false }, + { path: "/forgot", isProtected: false }, + { path: "/auth/reset-password", isProtected: false }, + { path: "/", isProtected: false }, + { path: "/subscribe", isProtected: true }, + { path: "/dashboard", isProtected: true }, + { path: "/settings", isProtected: true }, + { path: "/collections", isProtected: true }, + { path: "/links", isProtected: true }, + { path: "/tags", isProtected: true }, + ]; + + if (isPublicPage) { + setShouldRenderChildren(true); + } else { + if (isLoggedIn && hasInactiveSubscription) { + redirectTo("/subscribe"); } else if ( - status === "authenticated" && - account.id && - (router.pathname === "/login" || - router.pathname === "/register" || - router.pathname === "/confirmation" || - router.pathname === "/subscribe" || - router.pathname === "/choose-username" || - router.pathname === "/forgot" || - router.pathname === "/") + isLoggedIn && + !routes.some((e) => router.pathname.startsWith(e.path) && e.isProtected) ) { - router.push("/dashboard").then(() => { - setRedirect(false); - }); + redirectTo("/dashboard"); } else if ( - status === "unauthenticated" && - !( - router.pathname === "/login" || - router.pathname === "/register" || - router.pathname === "/confirmation" || - router.pathname === "/forgot" + isUnauthenticated && + !routes.some( + (e) => router.pathname.startsWith(e.path) && !e.isProtected ) ) { - router.push("/login").then(() => { - setRedirect(false); - }); - } else if (status === "loading") setRedirect(true); - else setRedirect(false); - } else { - setRedirect(false); + redirectTo("/login"); + } else { + setShouldRenderChildren(true); + } } }, [status, account, router.pathname]); - if (status !== "loading" && !redirect) return <>{children}; - else return <>; - // return <>{children}; + function redirectTo(destination: string) { + router.push(destination).then(() => setShouldRenderChildren(true)); + } + + if (status !== "loading" && shouldRenderChildren) { + return <>{children}; + } else { + return <>; + } } diff --git a/lib/api/sendPasswordResetRequest.ts b/lib/api/sendPasswordResetRequest.ts index ce29e1b..b94ccef 100644 --- a/lib/api/sendPasswordResetRequest.ts +++ b/lib/api/sendPasswordResetRequest.ts @@ -34,11 +34,11 @@ export default async function sendPasswordResetRequest( address: process.env.EMAIL_FROM as string, }, to: email, - subject: "Verify your new Linkwarden email address", + subject: "Linkwarden: Reset password instructions", html: emailTemplate({ user, baseUrl: process.env.BASE_URL, - url: `${process.env.BASE_URL}/auth/password-reset?token=${token}`, + url: `${process.env.BASE_URL}/auth/reset-password?token=${token}`, }), }); } diff --git a/lib/api/sendVerificationRequest.ts b/lib/api/sendVerificationRequest.ts index 98e0277..eb19a88 100644 --- a/lib/api/sendVerificationRequest.ts +++ b/lib/api/sendVerificationRequest.ts @@ -1,12 +1,21 @@ import { readFileSync } from "fs"; -import { SendVerificationRequestParams } from "next-auth/providers"; import path from "path"; import Handlebars from "handlebars"; import transporter from "./transporter"; -export default async function sendVerificationRequest( - params: SendVerificationRequestParams -) { +type Params = { + identifier: string; + url: string; + from: string; + token: string; +}; + +export default async function sendVerificationRequest({ + identifier, + url, + from, + token, +}: Params) { const emailsDir = path.resolve(process.cwd(), "templates"); const templateFile = readFileSync( @@ -16,13 +25,12 @@ export default async function sendVerificationRequest( const emailTemplate = Handlebars.compile(templateFile); - const { identifier, url, provider, token } = params; const { host } = new URL(url); const result = await transporter.sendMail({ to: identifier, from: { name: "Linkwarden", - address: provider.from as string, + address: from as string, }, subject: `Please verify your email address`, text: text({ url, host }), diff --git a/pages/_app.tsx b/pages/_app.tsx index 0ece19d..9c6b527 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -9,6 +9,7 @@ import toast from "react-hot-toast"; import { Toaster, ToastBar } from "react-hot-toast"; import { Session } from "next-auth"; import { isPWA } from "@/lib/client/utils"; +import useInitialData from "@/hooks/useInitialData"; export default function App({ Component, @@ -55,6 +56,7 @@ export default function App({ + {/* */} + {/* */} ); } + +// function GetData({ children }: { children: React.ReactNode }) { +// const status = useInitialData(); +// return typeof window !== "undefined" && status !== "loading" ? ( +// children +// ) : ( +// <> +// ); +// } diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index c59a38a..a67bdf1 100644 --- a/pages/api/v1/auth/[...nextauth].ts +++ b/pages/api/v1/auth/[...nextauth].ts @@ -65,6 +65,7 @@ import ZohoProvider from "next-auth/providers/zoho"; import ZoomProvider from "next-auth/providers/zoom"; import * as process from "process"; import type { NextApiRequest, NextApiResponse } from "next"; +import { randomBytes } from "crypto"; const emailEnabled = process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; @@ -105,13 +106,54 @@ if ( email: username?.toLowerCase(), }, ], - emailVerified: { not: null }, } : { username: username.toLowerCase(), }, }); + if (!user) throw Error("Invalid credentials."); + else if (!user?.emailVerified && emailEnabled) { + const identifier = user?.email as string; + const token = randomBytes(32).toString("hex"); + const url = `${ + process.env.NEXTAUTH_URL + }/callback/email?token=${token}&email=${encodeURIComponent( + identifier + )}`; + const from = process.env.EMAIL_FROM as string; + + const recentVerificationRequestsCount = + await prisma.verificationToken.count({ + where: { + identifier, + createdAt: { + gt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes + }, + }, + }); + + if (recentVerificationRequestsCount >= 4) + throw Error("Too many requests. Please try again later."); + + sendVerificationRequest({ + identifier, + url, + from, + token, + }); + + await prisma.verificationToken.create({ + data: { + identifier, + token, + expires: new Date(Date.now() + 24 * 3600 * 1000), // 1 day + }, + }); + + throw Error("Email not verified. Verification email sent."); + } + let passwordMatches: boolean = false; if (user?.password) { @@ -120,7 +162,7 @@ if ( if (passwordMatches && user?.password) { return { id: user?.id }; - } else return null as any; + } else throw Error("Invalid credentials."); }, }) ); @@ -132,8 +174,26 @@ if (emailEnabled) { server: process.env.EMAIL_SERVER, from: process.env.EMAIL_FROM, maxAge: 1200, - sendVerificationRequest(params) { - sendVerificationRequest(params); + async sendVerificationRequest({ identifier, url, provider, token }) { + const recentVerificationRequestsCount = + await prisma.verificationToken.count({ + where: { + identifier, + createdAt: { + gt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes + }, + }, + }); + + if (recentVerificationRequestsCount >= 4) + throw Error("Too many requests. Please try again later."); + + sendVerificationRequest({ + identifier, + url, + from: provider.from as string, + token, + }); }, }) ); diff --git a/pages/api/v1/auth/reset-password.ts b/pages/api/v1/auth/reset-password.ts index 06ebb79..14287f3 100644 --- a/pages/api/v1/auth/reset-password.ts +++ b/pages/api/v1/auth/reset-password.ts @@ -74,7 +74,7 @@ export default async function resetPassword( }); return res.status(200).json({ - response: "Password reset successfully.", + response: "Password has been reset successfully.", }); } } diff --git a/pages/auth/reset-password.tsx b/pages/auth/reset-password.tsx index 4615ecd..138d6ff 100644 --- a/pages/auth/reset-password.tsx +++ b/pages/auth/reset-password.tsx @@ -2,52 +2,56 @@ import AccentSubmitButton from "@/components/AccentSubmitButton"; import TextInput from "@/components/TextInput"; import CenteredForm from "@/layouts/CenteredForm"; import Link from "next/link"; +import { useRouter } from "next/router"; import { FormEvent, useState } from "react"; import { toast } from "react-hot-toast"; interface FormData { password: string; - passwordConfirmation: string; + token: string; } export default function ResetPassword() { const [submitLoader, setSubmitLoader] = useState(false); + const router = useRouter(); + const [form, setForm] = useState({ password: "", - passwordConfirmation: "", + token: router.query.token as string, }); - const [isEmailSent, setIsEmailSent] = useState(false); + const [requestSent, setRequestSent] = useState(false); - async function submitRequest() { - const response = await fetch("/api/v1/auth/forgot-password", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(form), - }); - - const data = await response.json(); - - if (response.ok) { - toast.success(data.response); - setIsEmailSent(true); - } else { - toast.error(data.response); - } - } - - async function sendConfirmation(event: FormEvent) { + async function submit(event: FormEvent) { event.preventDefault(); - if (form.password !== "") { + if ( + form.password !== "" && + form.token !== "" && + !requestSent && + !submitLoader + ) { setSubmitLoader(true); const load = toast.loading("Sending password recovery link..."); - await submitRequest(); + const response = await fetch("/api/v1/auth/reset-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(form), + }); + + const data = await response.json(); + + if (response.ok) { + toast.success(data.response); + setRequestSent(true); + } else { + toast.error(data.response); + } toast.dismiss(load); @@ -59,15 +63,15 @@ export default function ResetPassword() { return ( -
+

- {isEmailSent ? "Email Sent!" : "Forgot Password?"} + {requestSent ? "Password Updated!" : "Reset Password"}

- {!isEmailSent ? ( + {!requestSent ? ( <>

@@ -76,12 +80,12 @@ export default function ResetPassword() {

-

Email

+

New Password

@@ -92,23 +96,22 @@ export default function ResetPassword() { ) : ( -

- Check your email for a link to reset your password. If it doesn’t - appear within a few minutes, check your spam folder. -

- )} + <> +

Your password has been successfully updated.

-
- - Go back - -
+
+ + Back to Login + +
+ + )}
diff --git a/pages/confirmation.tsx b/pages/confirmation.tsx index 1fee8d2..f8f99b6 100644 --- a/pages/confirmation.tsx +++ b/pages/confirmation.tsx @@ -1,8 +1,33 @@ import CenteredForm from "@/layouts/CenteredForm"; +import { signIn } from "next-auth/react"; import Link from "next/link"; -import React from "react"; +import { useRouter } from "next/router"; +import React, { useState } from "react"; +import toast from "react-hot-toast"; export default function EmailConfirmaion() { + const router = useRouter(); + + const [submitLoader, setSubmitLoader] = useState(false); + + const resend = async () => { + setSubmitLoader(true); + + const load = toast.loading("Authenticating..."); + + const res = await signIn("email", { + email: decodeURIComponent(router.query.email as string), + callbackUrl: "/", + redirect: false, + }); + + toast.dismiss(load); + + setSubmitLoader(false); + + toast.success("Verification email sent."); + }; + return (
@@ -12,15 +37,16 @@ export default function EmailConfirmaion() {
-

A sign in link has been sent to your email address.

- -

- Didn't see the email? Check your spam folder or visit the{" "} - - Password Recovery - {" "} - page to resend the link. +

+ A sign in link has been sent to your email address. If you don't see + the email, check your spam folder.

+ +
+
+ Resend Email +
+
); diff --git a/pages/forgot.tsx b/pages/forgot.tsx index f216776..70dc8af 100644 --- a/pages/forgot.tsx +++ b/pages/forgot.tsx @@ -94,15 +94,15 @@ export default function Forgot() { /> ) : ( -

+

Check your email for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder.

)} -
- - Go back +
+ + Back to Login
diff --git a/pages/login.tsx b/pages/login.tsx index 1824afe..90d26d1 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -47,7 +47,7 @@ export default function Login({ setSubmitLoader(false); if (!res?.ok) { - toast.error("Invalid login."); + toast.error(res?.error || "Invalid credentials."); } } else { toast.error("Please fill out all the fields."); @@ -108,7 +108,7 @@ export default function Login({
Forgot Password? @@ -158,7 +158,7 @@ export default function Login({

New here?

Sign Up diff --git a/pages/register.tsx b/pages/register.tsx index eb5c602..c3d63b1 100644 --- a/pages/register.tsx +++ b/pages/register.tsx @@ -76,12 +76,17 @@ export default function Register() { setSubmitLoader(false); if (response.ok) { - if (form.email && emailEnabled) + if (form.email && emailEnabled) { await signIn("email", { email: form.email, callbackUrl: "/", + redirect: false, }); - else if (!emailEnabled) router.push("/login"); + + router.push( + "/confirmation?email=" + encodeURIComponent(form.email) + ); + } else if (!emailEnabled) router.push("/login"); toast.success("User Created!"); } else {