feat: Centralize authentication redirects to middleware, remove client-side redirect logic, and refine logout process.
This commit is contained in:
@@ -18,10 +18,11 @@ export default function AgentLayout({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'loading') return;
|
if (status === 'loading') return;
|
||||||
|
|
||||||
if (!session) {
|
// Don't redirect to /login here — the middleware handles that.
|
||||||
router.replace('/login');
|
// Doing it here causes race conditions during logout (layout detects
|
||||||
return;
|
// 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
|
// Redirect non-agents to user dashboard
|
||||||
const userRole = (session.user as any)?.role;
|
const userRole = (session.user as any)?.role;
|
||||||
|
|||||||
@@ -1,49 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export default function AuthLayout({
|
export default function AuthLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { data: session, status } = useSession();
|
const { status } = useSession();
|
||||||
const router = useRouter();
|
|
||||||
const [hasTokens, setHasTokens] = useState(false);
|
|
||||||
|
|
||||||
// Fast synchronous check for existing tokens on mount
|
// Middleware handles redirecting logged-in users away from auth pages.
|
||||||
// If tokens exist, the user is likely authenticated and will be redirected
|
// Do NOT add client-side redirect logic here — it races with logout
|
||||||
useEffect(() => {
|
// and causes login/logout loops.
|
||||||
setHasTokens(!!localStorage.getItem('accessToken'));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#00293d]"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading spinner while checking authentication (no tokens = genuinely unauthenticated)
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-[#c4d9d4] to-[#f0f5fc]">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-[#c4d9d4] to-[#f0f5fc]">
|
||||||
@@ -59,7 +28,6 @@ export default function AuthLayout({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only render auth pages for unauthenticated users (no tokens, no session)
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-[#c4d9d4] to-[#f0f5fc] py-12 px-4">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-[#c4d9d4] to-[#f0f5fc] py-12 px-4">
|
||||||
<div className="w-full max-w-[510px]">
|
<div className="w-full max-w-[510px]">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { resetLogoutState } from '@/services/api';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -13,9 +14,12 @@ export default function LoginPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
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') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem('isLoggingOut');
|
localStorage.removeItem('isLoggingOut');
|
||||||
|
resetLogoutState();
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Footer } from '@/components/layout/Footer';
|
import { Footer } from '@/components/layout/Footer';
|
||||||
import { CommonHeader } from '@/components/layout/CommonHeader';
|
import { CommonHeader } from '@/components/layout/CommonHeader';
|
||||||
import { PresenceProvider } from '@/components/providers/presence-provider';
|
import { PresenceProvider } from '@/components/providers/presence-provider';
|
||||||
@@ -27,16 +27,9 @@ export default function UserLayout({
|
|||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [hasTokens, setHasTokens] = useState(false);
|
|
||||||
|
|
||||||
const isDashboard = pathname === '/user/dashboard';
|
const isDashboard = pathname === '/user/dashboard';
|
||||||
const isPublic = isPublicPath(pathname);
|
const isPublic = isPublicPath(pathname);
|
||||||
|
|
||||||
// Fast check for existing tokens on mount
|
|
||||||
useEffect(() => {
|
|
||||||
setHasTokens(!!localStorage.getItem('accessToken'));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'loading') return;
|
if (status === 'loading') return;
|
||||||
|
|
||||||
@@ -52,16 +45,11 @@ export default function UserLayout({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected pages require login
|
// Don't redirect to /login here for protected pages — the middleware handles that.
|
||||||
if (!session) {
|
// Doing it here causes race conditions during logout (layout detects session loss
|
||||||
// Check localStorage - if tokens exist, session might still be resolving
|
// and redirects to /login via client-side navigation, conflicting with server-side
|
||||||
// Don't redirect to /login prematurely (prevents login page flash)
|
// logout redirect).
|
||||||
const tokensExist = !!localStorage.getItem('accessToken');
|
if (!session) return;
|
||||||
if (!tokensExist) {
|
|
||||||
router.replace('/login');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect agents to agent dashboard for protected user pages
|
// Redirect agents to agent dashboard for protected user pages
|
||||||
const userRole = (session.user as any)?.role;
|
const userRole = (session.user as any)?.role;
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { signOut } from '@/auth';
|
import { cookies } from 'next/headers';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export async function logoutAction() {
|
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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,7 @@ export default function LogoutPage() {
|
|||||||
localStorage.removeItem('refreshToken');
|
localStorage.removeItem('refreshToken');
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
|
|
||||||
// Manually clear next-auth cookies as fallback
|
// Manually clear non-httpOnly next-auth cookies as fallback
|
||||||
// next-auth v5 uses these cookie names:
|
|
||||||
const cookieNames = [
|
const cookieNames = [
|
||||||
'authjs.session-token',
|
'authjs.session-token',
|
||||||
'authjs.callback-url',
|
'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`;
|
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 {
|
try {
|
||||||
await logoutAction();
|
await logoutAction();
|
||||||
} catch {
|
} catch (error: unknown) {
|
||||||
// Server action redirect throws in Next.js — this is expected.
|
// In Next.js App Router, redirect() throws an error with digest containing "NEXT_REDIRECT".
|
||||||
// If it truly fails, the manual cookie clearing above ensures logout.
|
// This is normal behavior — let it propagate so Next.js handles the redirect.
|
||||||
// Fallback 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';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ function TokenSync() {
|
|||||||
const hasInitialized = useRef(false);
|
const hasInitialized = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
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 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 };
|
const user = session.user as { accessToken?: string; refreshToken?: string };
|
||||||
|
|
||||||
// Only sync tokens from NextAuth to localStorage if:
|
// Only sync tokens from NextAuth to localStorage if:
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const authRoutes = ["/login", "/signup", "/forgot-password", "/reset-password",
|
|||||||
// Public routes - accessible to everyone (logged in or not)
|
// Public routes - accessible to everyone (logged in or not)
|
||||||
const publicRoutes = ["/", "/contact", "/about", "/faq", "/education", "/logout", "/user/dashboard", "/user/profiles", "/user/profile"];
|
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) => {
|
export default auth((req) => {
|
||||||
const { nextUrl } = req;
|
const { nextUrl } = req;
|
||||||
const isLoggedIn = !!req.auth;
|
const isLoggedIn = !!req.auth;
|
||||||
@@ -28,11 +31,20 @@ export default auth((req) => {
|
|||||||
nextUrl.pathname.startsWith("/assets") ||
|
nextUrl.pathname.startsWith("/assets") ||
|
||||||
nextUrl.pathname.includes(".");
|
nextUrl.pathname.includes(".");
|
||||||
|
|
||||||
|
const isNoRedirectRoute = noRedirectRoutes.some(
|
||||||
|
(route) => nextUrl.pathname === route || nextUrl.pathname.startsWith(route + "/")
|
||||||
|
);
|
||||||
|
|
||||||
// Skip middleware for API routes and static files
|
// Skip middleware for API routes and static files
|
||||||
if (isApiRoute || isStaticRoute) {
|
if (isApiRoute || isStaticRoute) {
|
||||||
return NextResponse.next();
|
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
|
// Allow access to public routes for everyone
|
||||||
if (isPublicRoute) {
|
if (isPublicRoute) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ let failedQueue: Array<{
|
|||||||
config: InternalAxiosRequestConfig;
|
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
|
// Force logout: clear all tokens and redirect to logout page
|
||||||
const forceLogout = () => {
|
const forceLogout = () => {
|
||||||
if (isLoggingOut) return; // Prevent multiple redirects
|
if (isLoggingOut) return; // Prevent multiple redirects
|
||||||
|
|||||||
Reference in New Issue
Block a user