From c7f1b89203a11cfec92534366e87bf60efe83aea Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Tue, 10 Mar 2026 09:28:19 +0530 Subject: [PATCH] feat: Centralize authentication redirects to middleware, remove client-side redirect logic, and refine logout process. --- src/app/(agent)/layout.tsx | 9 +++-- src/app/(auth)/layout.tsx | 40 ++----------------- src/app/(auth)/login/page.tsx | 6 ++- src/app/(user)/layout.tsx | 24 +++-------- src/app/logout/actions.ts | 18 ++++++++- src/app/logout/page.tsx | 27 +++++++++---- src/components/providers/session-provider.tsx | 5 ++- src/middleware.ts | 12 ++++++ src/services/api.ts | 6 +++ 9 files changed, 77 insertions(+), 70 deletions(-) diff --git a/src/app/(agent)/layout.tsx b/src/app/(agent)/layout.tsx index 51fb4e6..c9f7c55 100644 --- a/src/app/(agent)/layout.tsx +++ b/src/app/(agent)/layout.tsx @@ -18,10 +18,11 @@ export default function AgentLayout({ useEffect(() => { if (status === 'loading') return; - if (!session) { - router.replace('/login'); - return; - } + // Don't redirect to /login here — the middleware handles that. + // Doing it here causes race conditions during logout (layout detects + // session loss and redirects to /login via client-side navigation, + // which can conflict with the server-side logout redirect). + if (!session) return; // Redirect non-agents to user dashboard const userRole = (session.user as any)?.role; diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 5f8b993..780185e 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -1,49 +1,18 @@ 'use client'; import { useSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; export default function AuthLayout({ children, }: { children: React.ReactNode; }) { - const { data: session, status } = useSession(); - const router = useRouter(); - const [hasTokens, setHasTokens] = useState(false); + const { status } = useSession(); - // Fast synchronous check for existing tokens on mount - // If tokens exist, the user is likely authenticated and will be redirected - useEffect(() => { - setHasTokens(!!localStorage.getItem('accessToken')); - }, []); + // Middleware handles redirecting logged-in users away from auth pages. + // Do NOT add client-side redirect logic here — it races with logout + // and causes login/logout loops. - useEffect(() => { - if (status === 'loading') return; - - // Redirect authenticated users away from auth pages - if (session) { - const userRole = (session.user as any)?.role; - if (userRole === 'AGENT') { - router.replace('/agent/dashboard'); - } else { - router.replace('/user/dashboard'); - } - } - }, [session, status, router]); - - // If user has tokens in localStorage, they are likely authenticated - // Show a blank white screen to prevent flash of login page during redirect - if (hasTokens || session) { - return ( -
-
-
- ); - } - - // Show loading spinner while checking authentication (no tokens = genuinely unauthenticated) if (status === 'loading') { return (
@@ -59,7 +28,6 @@ export default function AuthLayout({ ); } - // Only render auth pages for unauthenticated users (no tokens, no session) return (
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 5d953b3..018216e 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { signIn } from 'next-auth/react'; import Link from 'next/link'; +import { resetLogoutState } from '@/services/api'; export default function LoginPage() { const router = useRouter(); @@ -13,9 +14,12 @@ export default function LoginPage() { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); - // Safety net: clear logout flag when login page loads + // Safety net: clear logout flags when login page loads. + // This resets both the localStorage flag and the module-level flag in api.ts + // so the API interceptor stops blocking requests. if (typeof window !== 'undefined') { localStorage.removeItem('isLoggingOut'); + resetLogoutState(); } const handleSubmit = async (e: React.FormEvent) => { diff --git a/src/app/(user)/layout.tsx b/src/app/(user)/layout.tsx index 349b8a8..6cb7728 100644 --- a/src/app/(user)/layout.tsx +++ b/src/app/(user)/layout.tsx @@ -2,7 +2,7 @@ import { useSession } from 'next-auth/react'; import { useRouter, usePathname } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { Footer } from '@/components/layout/Footer'; import { CommonHeader } from '@/components/layout/CommonHeader'; import { PresenceProvider } from '@/components/providers/presence-provider'; @@ -27,16 +27,9 @@ export default function UserLayout({ const { data: session, status } = useSession(); const router = useRouter(); const pathname = usePathname(); - const [hasTokens, setHasTokens] = useState(false); - const isDashboard = pathname === '/user/dashboard'; const isPublic = isPublicPath(pathname); - // Fast check for existing tokens on mount - useEffect(() => { - setHasTokens(!!localStorage.getItem('accessToken')); - }, []); - useEffect(() => { if (status === 'loading') return; @@ -52,16 +45,11 @@ export default function UserLayout({ return; } - // Protected pages require login - if (!session) { - // Check localStorage - if tokens exist, session might still be resolving - // Don't redirect to /login prematurely (prevents login page flash) - const tokensExist = !!localStorage.getItem('accessToken'); - if (!tokensExist) { - router.replace('/login'); - } - return; - } + // Don't redirect to /login here for protected pages — the middleware handles that. + // Doing it here causes race conditions during logout (layout detects session loss + // and redirects to /login via client-side navigation, conflicting with server-side + // logout redirect). + if (!session) return; // Redirect agents to agent dashboard for protected user pages const userRole = (session.user as any)?.role; diff --git a/src/app/logout/actions.ts b/src/app/logout/actions.ts index 06510c5..bc09ca2 100644 --- a/src/app/logout/actions.ts +++ b/src/app/logout/actions.ts @@ -1,7 +1,21 @@ 'use server'; -import { signOut } from '@/auth'; +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; export async function logoutAction() { - await signOut({ redirectTo: '/login' }); + // Force-delete all next-auth session cookies directly. + // signOut() from next-auth was NOT clearing the httpOnly cookie reliably, + // so we delete them explicitly using the cookies() API. + const cookieStore = await cookies(); + + // Delete all possible next-auth v5 cookie names + cookieStore.delete('authjs.session-token'); + cookieStore.delete('authjs.csrf-token'); + cookieStore.delete('authjs.callback-url'); + cookieStore.delete('__Secure-authjs.session-token'); + cookieStore.delete('__Secure-authjs.csrf-token'); + cookieStore.delete('__Secure-authjs.callback-url'); + + redirect('/login'); } diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx index 10cc0c3..28d8bb2 100644 --- a/src/app/logout/page.tsx +++ b/src/app/logout/page.tsx @@ -40,8 +40,7 @@ export default function LogoutPage() { localStorage.removeItem('refreshToken'); localStorage.removeItem('user'); - // Manually clear next-auth cookies as fallback - // next-auth v5 uses these cookie names: + // Manually clear non-httpOnly next-auth cookies as fallback const cookieNames = [ 'authjs.session-token', 'authjs.callback-url', @@ -59,13 +58,27 @@ export default function LogoutPage() { document.cookie = `${name}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure`; }); - // Use server action to properly clear session (redirects to /login) + // Use server action to properly clear the httpOnly session cookie. + // signOut() internally calls redirect() which throws NEXT_REDIRECT — + // Next.js handles this automatically. We should NOT catch that error. + // Only use fallback if the action truly fails (not a redirect throw). try { await logoutAction(); - } catch { - // Server action redirect throws in Next.js — this is expected. - // If it truly fails, the manual cookie clearing above ensures logout. - // Fallback redirect: + } catch (error: unknown) { + // In Next.js App Router, redirect() throws an error with digest containing "NEXT_REDIRECT". + // This is normal behavior — let it propagate so Next.js handles the redirect. + const isRedirectError = + error instanceof Error && + 'digest' in error && + typeof (error as { digest?: string }).digest === 'string' && + (error as { digest: string }).digest.includes('NEXT_REDIRECT'); + + if (isRedirectError) { + // Re-throw so Next.js handles the redirect properly + throw error; + } + + // Genuine error — use full page navigation as fallback window.location.href = '/login'; } }; diff --git a/src/components/providers/session-provider.tsx b/src/components/providers/session-provider.tsx index d5b1c26..572740f 100644 --- a/src/components/providers/session-provider.tsx +++ b/src/components/providers/session-provider.tsx @@ -9,10 +9,11 @@ function TokenSync() { const hasInitialized = useRef(false); useEffect(() => { - // Never restore tokens if a logout is in progress + // Never restore tokens if a logout is in progress or we're on the logout page const loggingOut = localStorage.getItem("isLoggingOut") === "true"; + const onLogoutPage = window.location.pathname === "/logout"; - if (status === "authenticated" && session?.user && !loggingOut) { + if (status === "authenticated" && session?.user && !loggingOut && !onLogoutPage) { const user = session.user as { accessToken?: string; refreshToken?: string }; // Only sync tokens from NextAuth to localStorage if: diff --git a/src/middleware.ts b/src/middleware.ts index 39552d6..62954b8 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -7,6 +7,9 @@ const authRoutes = ["/login", "/signup", "/forgot-password", "/reset-password", // Public routes - accessible to everyone (logged in or not) const publicRoutes = ["/", "/contact", "/about", "/faq", "/education", "/logout", "/user/dashboard", "/user/profiles", "/user/profile"]; +// Routes that should NEVER be redirected away from (even if logged in) +const noRedirectRoutes = ["/logout"]; + export default auth((req) => { const { nextUrl } = req; const isLoggedIn = !!req.auth; @@ -28,11 +31,20 @@ export default auth((req) => { nextUrl.pathname.startsWith("/assets") || nextUrl.pathname.includes("."); + const isNoRedirectRoute = noRedirectRoutes.some( + (route) => nextUrl.pathname === route || nextUrl.pathname.startsWith(route + "/") + ); + // Skip middleware for API routes and static files if (isApiRoute || isStaticRoute) { return NextResponse.next(); } + // Never redirect away from no-redirect routes (e.g., /logout must always be accessible) + if (isNoRedirectRoute) { + return NextResponse.next(); + } + // Allow access to public routes for everyone if (isPublicRoute) { return NextResponse.next(); diff --git a/src/services/api.ts b/src/services/api.ts index 64cde76..6637e01 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -28,6 +28,12 @@ let failedQueue: Array<{ config: InternalAxiosRequestConfig; }> = []; +// Reset the logging-out state. Call this when starting a new login session +// to ensure the API interceptor is no longer blocking requests. +export const resetLogoutState = () => { + isLoggingOut = false; +}; + // Force logout: clear all tokens and redirect to logout page const forceLogout = () => { if (isLoggingOut) return; // Prevent multiple redirects