From f8d5c6cd5675e2f4daaa6c89120a73cb6f047081 Mon Sep 17 00:00:00 2001 From: Rudra Patel <85089368+RudraPatel2003@users.noreply.github.com> Date: Thu, 26 Dec 2024 17:39:24 -0600 Subject: [PATCH] feat: Fix sign in callbackUrl bug and prevent signOut when verifying email (#81) --- next.config.mjs | 15 ++++++- src/app/api/auth/[...nextauth]/options.ts | 9 +++- src/app/test/client/page.tsx | 4 +- src/app/test/server/page.tsx | 4 +- src/components/LoginForm/index.tsx | 38 ++++++++++++----- .../VerifyEmail/VerifyEmailSuccess.tsx | 41 +++++++++++++------ 6 files changed, 81 insertions(+), 30 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index ed9d5ee..26c10c7 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,7 +1,20 @@ import withBundleAnalyzer from "@next/bundle-analyzer"; /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + /** + * The router cache is responsible for caching pages. + * This causes issues with your authentication state changes (like when you sign in). + * To fix this, we disable the router cache for dynamic routes. + * https://github.com/vercel/next.js/discussions/65487 + * https://nextjs.org/docs/14/app/api-reference/next-config-js/staleTimes + */ + experimental: { + staleTimes: { + dynamic: 0, + }, + }, +}; const bundleAnalyzerConfig = withBundleAnalyzer({ enabled: process.env.ANALYZE === "true", diff --git a/src/app/api/auth/[...nextauth]/options.ts b/src/app/api/auth/[...nextauth]/options.ts index cc674fd..608b2a2 100644 --- a/src/app/api/auth/[...nextauth]/options.ts +++ b/src/app/api/auth/[...nextauth]/options.ts @@ -43,12 +43,18 @@ const authOptions: NextAuthOptions = { }), ], callbacks: { - async jwt({ token, user }) { + async jwt({ token, user, trigger, session }) { // the token gets the User object from the CredentialsProvider's authorize method // but it only gets it the first time; every time after it is undefined if (user) { token.user = user; } + + // if you call the "update" function, refresh the user to reflect changes server-side + if (trigger === "update") { + token.user = session.user; + } + return token; }, async session({ session, token }) { @@ -60,7 +66,6 @@ const authOptions: NextAuthOptions = { }, pages: { signIn: "/auth/login", - error: "/auth/login", // The login page will parse the error query parameter passed in }, secret: process.env.NEXTAUTH_SECRET, }; diff --git a/src/app/test/client/page.tsx b/src/app/test/client/page.tsx index a20b3f6..a7acb6a 100644 --- a/src/app/test/client/page.tsx +++ b/src/app/test/client/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Box } from "@mui/material"; +import { Box, Typography } from "@mui/material"; export default function ClientComponentTestPage() { return ( @@ -14,7 +14,7 @@ export default function ClientComponentTestPage() { width: "100vw", }} > -

Client component test page

+ Client component test page ); } diff --git a/src/app/test/server/page.tsx b/src/app/test/server/page.tsx index d71c80a..ad9797d 100644 --- a/src/app/test/server/page.tsx +++ b/src/app/test/server/page.tsx @@ -1,4 +1,4 @@ -import { Box } from "@mui/material"; +import { Box, Typography } from "@mui/material"; export default function ServerComponentTestPage() { return ( @@ -11,7 +11,7 @@ export default function ServerComponentTestPage() { alignItems: "center", }} > -

Server component test page

+ Server component test page ); } diff --git a/src/components/LoginForm/index.tsx b/src/components/LoginForm/index.tsx index a9e4abe..9eb97f7 100644 --- a/src/components/LoginForm/index.tsx +++ b/src/components/LoginForm/index.tsx @@ -4,9 +4,9 @@ import { zodResolver } from "@hookform/resolvers/zod"; import LoadingButton from "@mui/lab/LoadingButton"; import { Box, Skeleton, Typography } from "@mui/material"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { signIn } from "next-auth/react"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -28,6 +28,8 @@ type LoginFormValues = z.infer; function LoginFormFields() { const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + const { control, handleSubmit, @@ -40,17 +42,33 @@ function LoginFormFields() { const searchParams = useSearchParams(); - useEffect(() => { - const error = searchParams.get("error"); - if (error) { - setError("root", { message: error }); - } - }, [searchParams, setError]); - const onSubmit = async (data: LoginFormValues) => { setIsLoading(true); setError("root", { message: "" }); - await signIn("credentials", { ...data, callbackUrl: "/dashboard" }); + + const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; + + // redirect must be false, otherwise the callbackUrl will be lost + // if the user puts it invalid credentials + const signInResponse = await signIn("credentials", { + ...data, + callbackUrl: callbackUrl, + redirect: false, + }); + + if (!signInResponse) { + setError("root", { message: "An unknown error occurred" }); + setIsLoading(false); + return; + } + + if (signInResponse.error) { + setError("root", { message: signInResponse.error }); + setIsLoading(false); + return; + } + + router.push(callbackUrl); }; return ( diff --git a/src/components/VerifyEmail/VerifyEmailSuccess.tsx b/src/components/VerifyEmail/VerifyEmailSuccess.tsx index 560f49f..15b3340 100644 --- a/src/components/VerifyEmail/VerifyEmailSuccess.tsx +++ b/src/components/VerifyEmail/VerifyEmailSuccess.tsx @@ -3,8 +3,8 @@ import { Snackbar, Typography } from "@mui/material"; import { useRouter } from "next/navigation"; -import { signOut } from "next-auth/react"; -import { useEffect, useState } from "react"; +import { useSession } from "next-auth/react"; +import { useState } from "react"; import { verifyEmailWithToken } from "@/server/api/users/public-mutations"; import { EmailVerificationToken } from "@/types"; @@ -18,19 +18,34 @@ export default function VerifyEmailSuccess({ }: VerifyEmailSuccessProps) { const [snackbarOpen, setSnackbarOpen] = useState(false); const router = useRouter(); + const { data: session, update } = useSession(); - useEffect(() => { - const verifyEmail = async () => { - await verifyEmailWithToken(emailVerificationToken.token); - setSnackbarOpen(true); - setTimeout(async () => { - await signOut({ redirect: false, callbackUrl: "/" }); - router.push("/"); - }, 1000); - }; + const verifyEmail = async () => { + if (!session || session.user.isEmailVerified) { + return; + } + await verifyEmailWithToken(emailVerificationToken.token); + setSnackbarOpen(true); + + // Update session to reflect database changes + await update({ + ...session, + user: { + ...session?.user, + isEmailVerified: true, + }, + }); + + setTimeout(() => { + router.push("/"); + }, 2000); + }; + + // Call verifyEmail directly when session becomes available + if (session) { void verifyEmail(); - }, []); + } return ( <> @@ -40,7 +55,7 @@ export default function VerifyEmailSuccess({ onClose={() => setSnackbarOpen(false)} message="Email verified" /> - Verifying email...; + Verifying email... ); }