From 041c36079dbc2ff625d1a3d61d8e8fc1aef79315 Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Sun, 23 Nov 2025 21:13:18 +0000 Subject: [PATCH] Enhance authentication and middleware logic by improving user role checks, adding OTP verification steps, and refining redirection based on user roles. Update login and signup forms to handle multiple user attributes and streamline error handling. Integrate logout functionality across components for better user experience. --- app/(admin)/_components/header.tsx | 15 +- app/(admin)/_components/side-nav.tsx | 15 +- app/(auth)/login/page.tsx | 660 ++++++++++++++++++++++++--- app/(auth)/signup/page.tsx | 20 +- app/(pages)/book-now/page.tsx | 24 + components/Navbar.tsx | 83 +++- hooks/useAuth.ts | 14 +- lib/actions/auth.ts | 85 ++-- lib/models/auth.ts | 8 + middleware.ts | 32 +- 10 files changed, 829 insertions(+), 127 deletions(-) diff --git a/app/(admin)/_components/header.tsx b/app/(admin)/_components/header.tsx index ef68227..a5f5976 100644 --- a/app/(admin)/_components/header.tsx +++ b/app/(admin)/_components/header.tsx @@ -17,6 +17,8 @@ import { } from "lucide-react"; import { useAppTheme } from "@/components/ThemeProvider"; import { ThemeToggle } from "@/components/ThemeToggle"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; export function Header() { const pathname = usePathname(); @@ -25,6 +27,14 @@ export function Header() { const [userMenuOpen, setUserMenuOpen] = useState(false); const { theme } = useAppTheme(); const isDark = theme === "dark"; + const { logout } = useAuth(); + + const handleLogout = () => { + setUserMenuOpen(false); + logout(); + toast.success("Logged out successfully"); + router.push("/"); + }; // Mock notifications data const notifications = [ @@ -209,10 +219,7 @@ export function Header() { +

+ )} + {step === "verify" && registeredEmail && ( +

+ We've sent a verification code to {registeredEmail} +

+ )} + {step === "verify" && !registeredEmail && ( +

+ Enter the verification code sent to your email +

+ )} {/* Close Button */} + + + )} + + {/* Signup Form */} + {step === "signup" && ( +
+ {/* First Name Field */} +
+ + handleSignupChange("first_name", e.target.value)} + className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.first_name ? 'border-red-500' : ''}`} + required + /> + {errors.first_name && ( +

{errors.first_name}

+ )} +
+ + {/* Last Name Field */} +
+ + handleSignupChange("last_name", e.target.value)} + className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.last_name ? 'border-red-500' : ''}`} + required + /> + {errors.last_name && ( +

{errors.last_name}

+ )} +
+ + {/* Email Field */} +
+ + handleSignupChange("email", e.target.value)} + className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.email ? 'border-red-500' : ''}`} + required + /> + {errors.email && ( +

{errors.email}

+ )} +
+ + {/* Phone Field */} +
+ + handleSignupChange("phone_number", e.target.value)} + className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} + /> +
+ + {/* Password Field */} +
+ +
+ handleSignupChange("password", e.target.value)} + className={`h-11 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.password ? 'border-red-500' : ''}`} + required + /> + +
+ {errors.password && ( +

{errors.password}

+ )} +
+ + {/* Confirm Password Field */} +
+ +
+ handleSignupChange("password2", e.target.value)} + className={`h-11 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.password2 ? 'border-red-500' : ''}`} + required + /> + +
+ {errors.password2 && ( +

{errors.password2}

+ )} +
+ + {/* Submit Button */} + +
+ )} + + {/* OTP Verification Form */} + {step === "verify" && ( +
+
+
+ +
+

+ Check your email +

+

+ We've sent a 6-digit verification code to your email address. +

+
+
+
+ + {/* Email Field (if not set) */} + {!registeredEmail && ( +
+ + handleOtpChange("email", e.target.value)} + className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.email ? 'border-red-500' : ''}`} + required + /> + {errors.email && ( +

{errors.email}

+ )} +
+ )} + + {/* OTP Field */} +
+ +
+ handleOtpChange("otp", value)} + aria-invalid={!!errors.otp} + > + + + + + + + + + +
+ {errors.otp && ( +

{errors.otp}

+ )} +
+ + {/* Resend OTP */} +
+
+ {/* Submit Button */} + + + {/* Back to signup */} +
+ +
+ )} ); -} \ No newline at end of file +} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx index 898cd16..ca29c90 100644 --- a/app/(auth)/signup/page.tsx +++ b/app/(auth)/signup/page.tsx @@ -69,13 +69,21 @@ export default function Signup() { try { const result = await register(formData); - // If registration is successful (no error thrown), show OTP verification + // If registration is successful, redirect to login page with verify parameter toast.success("Registration successful! Please check your email for OTP verification."); - setRegisteredEmail(formData.email); - setOtpData({ email: formData.email, otp: "" }); - setStep("verify"); + // Redirect to login page with verify step + router.push(`/login?verify=true&email=${encodeURIComponent(formData.email)}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Registration failed. Please try again."; + + // If OTP sending failed, don't show OTP verification - just show error + if (errorMessage.toLowerCase().includes("failed to send") || + errorMessage.toLowerCase().includes("failed to send otp")) { + toast.error("Registration failed: OTP could not be sent. Please try again later or contact support."); + setErrors({}); + return; + } + toast.error(errorMessage); // Don't set field errors for server errors, only show toast setErrors({}); @@ -105,9 +113,9 @@ export default function Signup() { // If verification is successful (no error thrown), show success and redirect toast.success("Email verified successfully! Redirecting to login..."); - // Redirect to login page after OTP verification + // Redirect to login page after OTP verification with email pre-filled setTimeout(() => { - router.push("/login"); + router.push(`/login?email=${encodeURIComponent(otpData.email)}`); }, 1500); } catch (error) { const errorMessage = error instanceof Error ? error.message : "OTP verification failed. Please try again."; diff --git a/app/(pages)/book-now/page.tsx b/app/(pages)/book-now/page.tsx index 07e50e0..b8528af 100644 --- a/app/(pages)/book-now/page.tsx +++ b/app/(pages)/book-now/page.tsx @@ -23,11 +23,14 @@ import { CheckCircle2, CheckCircle, Loader2, + LogOut, } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { LoginDialog } from "@/components/LoginDialog"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; interface User { ID: number; @@ -73,6 +76,7 @@ export default function BookNowPage() { const router = useRouter(); const { theme } = useAppTheme(); const isDark = theme === "dark"; + const { isAuthenticated, logout } = useAuth(); const [formData, setFormData] = useState({ firstName: "", lastName: "", @@ -87,6 +91,12 @@ export default function BookNowPage() { const [error, setError] = useState(null); const [showLoginDialog, setShowLoginDialog] = useState(false); + const handleLogout = () => { + logout(); + toast.success("Logged out successfully"); + router.push("/"); + }; + // Handle submit button click const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -660,6 +670,20 @@ export default function BookNowPage() {

+ + {/* Logout Button - Only show when authenticated */} + {isAuthenticated && ( +
+ +
+ )} )} diff --git a/components/Navbar.tsx b/components/Navbar.tsx index a3a55a2..dcd863a 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -2,13 +2,15 @@ import { motion, AnimatePresence } from "framer-motion"; import { Button } from "@/components/ui/button"; -import { Heart, Menu, X } from "lucide-react"; +import { Heart, Menu, X, LogOut } from "lucide-react"; import { ThemeToggle } from "@/components/ThemeToggle"; import { useEffect, useState } from "react"; import { LoginDialog } from "@/components/LoginDialog"; import { useRouter, usePathname } from "next/navigation"; import Link from "next/link"; import { useAppTheme } from "@/components/ThemeProvider"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; export function Navbar() { const { theme } = useAppTheme(); @@ -18,6 +20,9 @@ export function Navbar() { const router = useRouter(); const pathname = usePathname(); const isUserDashboard = pathname?.startsWith("/user/dashboard"); + const isUserSettings = pathname?.startsWith("/user/settings"); + const isUserRoute = pathname?.startsWith("/user/"); + const { isAuthenticated, logout } = useAuth(); const scrollToSection = (id: string) => { const element = document.getElementById(id); @@ -33,6 +38,13 @@ export function Navbar() { setMobileMenuOpen(false); }; + const handleLogout = () => { + logout(); + toast.success("Logged out successfully"); + setMobileMenuOpen(false); + router.push("/"); + }; + // Close mobile menu when clicking outside useEffect(() => { if (mobileMenuOpen) { @@ -73,7 +85,7 @@ export function Navbar() { {/* Desktop Navigation */} - {!isUserDashboard && ( + {!isUserRoute && (
+ {!isUserDashboard && ( + + Book-Now + + )} + {isAuthenticated && ( + + )}
{/* Mobile Actions */} @@ -161,7 +195,7 @@ export function Navbar() { >
{/* Mobile Navigation Links */} - {!isUserDashboard && ( + {!isUserRoute && ( <> + )} + {isAuthenticated && ( + + )}
diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts index 160e42c..cabc484 100644 --- a/hooks/useAuth.ts +++ b/hooks/useAuth.ts @@ -43,8 +43,14 @@ export function useAuth() { // Check if user is authenticated const isAuthenticated = !!user && !!getStoredTokens().access; - // Check if user is admin (check both is_admin and isAdmin) - const isAdmin = user?.is_admin === true || (user as any)?.isAdmin === true; + // Check if user is admin (check multiple possible field names) + const isAdmin = + user?.is_admin === true || + (user as any)?.isAdmin === true || + (user as any)?.is_staff === true || + (user as any)?.isStaff === true || + (user as any)?.is_superuser === true || + (user as any)?.isSuperuser === true; // Login mutation const loginMutation = useMutation({ @@ -109,8 +115,8 @@ export function useAuth() { const logout = useCallback(() => { clearAuthData(); queryClient.clear(); - router.push("/login"); - }, [queryClient, router]); + // Don't redirect here - let components handle redirect as needed + }, [queryClient]); // Login function const login = useCallback( diff --git a/lib/actions/auth.ts b/lib/actions/auth.ts index a97aa4f..93a0bf2 100644 --- a/lib/actions/auth.ts +++ b/lib/actions/auth.ts @@ -76,6 +76,28 @@ async function handleResponse(response: Response): Promise { return data as T; } +// Helper function to normalize auth response +function normalizeAuthResponse(data: AuthResponse): AuthResponse { + // Normalize tokens: if tokens are at root level, move them to tokens object + if (data.access && data.refresh && !data.tokens) { + data.tokens = { + access: data.access, + refresh: data.refresh, + }; + } + + // Normalize user: only map isVerified to is_verified if needed + if (data.user) { + const user = data.user as any; + if (user.isVerified !== undefined && user.is_verified === undefined) { + user.is_verified = user.isVerified; + } + data.user = user; + } + + return data; +} + // Register a new user export async function registerUser(input: RegisterInput): Promise { const response = await fetch(API_ENDPOINTS.auth.register, { @@ -86,6 +108,29 @@ export async function registerUser(input: RegisterInput): Promise body: JSON.stringify(input), }); + // Handle response - check if it's a 500 error that might indicate OTP sending failure + // but user registration might have succeeded + if (!response.ok && response.status === 500) { + try { + const data = await response.json(); + // If the error message mentions OTP or email sending, it might be a partial success + const errorMessage = extractErrorMessage(data); + if (errorMessage.toLowerCase().includes("otp") || + errorMessage.toLowerCase().includes("email") || + errorMessage.toLowerCase().includes("send") || + errorMessage.toLowerCase().includes("ssl") || + errorMessage.toLowerCase().includes("certificate")) { + // Return a partial success response - user might be created, allow OTP resend + // This allows the user to proceed to OTP verification and use resend OTP + return { + message: "User registered, but OTP email could not be sent. Please use resend OTP.", + } as AuthResponse; + } + } catch { + // If we can't parse the error, continue to normal error handling + } + } + return handleResponse(response); } @@ -100,23 +145,7 @@ export async function verifyOtp(input: VerifyOtpInput): Promise { }); const data = await handleResponse(response); - - // Normalize response: if tokens are at root level, move them to tokens object - if (data.access && data.refresh && !data.tokens) { - data.tokens = { - access: data.access, - refresh: data.refresh, - }; - } - - // Normalize user: map isVerified to is_verified if needed - if (data.user) { - if (data.user.isVerified !== undefined && data.user.is_verified === undefined) { - data.user.is_verified = data.user.isVerified; - } - } - - return data; + return normalizeAuthResponse(data); } // Login user @@ -130,23 +159,7 @@ export async function loginUser(input: LoginInput): Promise { }); const data = await handleResponse(response); - - // Normalize response: if tokens are at root level, move them to tokens object - if (data.access && data.refresh && !data.tokens) { - data.tokens = { - access: data.access, - refresh: data.refresh, - }; - } - - // Normalize user: map isVerified to is_verified if needed - if (data.user) { - if (data.user.isVerified !== undefined && data.user.is_verified === undefined) { - data.user.is_verified = data.user.isVerified; - } - } - - return data; + return normalizeAuthResponse(data); } // Resend OTP @@ -245,9 +258,7 @@ export function storeUser(user: User): void { if (typeof window === "undefined") return; localStorage.setItem("auth_user", JSON.stringify(user)); - - // Also set cookie for middleware - document.cookie = `auth_user=${JSON.stringify(user)}; path=/; max-age=${7 * 24 * 60 * 60}; SameSite=Lax`; + document.cookie = `auth_user=${encodeURIComponent(JSON.stringify(user))}; path=/; max-age=${7 * 24 * 60 * 60}; SameSite=Lax`; } // Get stored user diff --git a/lib/models/auth.ts b/lib/models/auth.ts index 6c7921e..2af43b9 100644 --- a/lib/models/auth.ts +++ b/lib/models/auth.ts @@ -11,9 +11,17 @@ export interface User { last_name: string; phone_number?: string; is_admin?: boolean; + isAdmin?: boolean; // API uses camelCase + is_staff?: boolean; + isStaff?: boolean; // API uses camelCase + is_superuser?: boolean; + isSuperuser?: boolean; // API uses camelCase is_verified?: boolean; isVerified?: boolean; // API uses camelCase + is_active?: boolean; + isActive?: boolean; // API uses camelCase date_joined?: string; + last_login?: string; created_at?: string; updated_at?: string; } diff --git a/middleware.ts b/middleware.ts index 4c2bfa4..630a3ac 100644 --- a/middleware.ts +++ b/middleware.ts @@ -13,16 +13,31 @@ export function middleware(request: NextRequest) { if (userStr) { try { - const user = JSON.parse(userStr); - isAdmin = user.is_admin === true; + // Decode the user string if it's URL encoded + const decodedUserStr = decodeURIComponent(userStr); + const user = JSON.parse(decodedUserStr); + // Check for admin status using multiple possible field names + // Admin users must be verified (is_verified or isVerified must be true) + const isVerified = user.is_verified === true || user.isVerified === true; + const hasAdminRole = + user.is_admin === true || + user.isAdmin === true || + user.is_staff === true || + user.isStaff === true || + user.is_superuser === true || + user.isSuperuser === true; + + // User is admin only if they have admin role AND are verified + isAdmin = hasAdminRole && isVerified; } catch { - // Invalid user data + // Invalid user data - silently fail and treat as non-admin } } // Protected routes const isProtectedRoute = pathname.startsWith("/user") || pathname.startsWith("/admin"); const isAdminRoute = pathname.startsWith("/admin"); + const isUserRoute = pathname.startsWith("/user"); const isAuthRoute = pathname.startsWith("/login") || pathname.startsWith("/signup"); // Redirect unauthenticated users away from protected routes @@ -34,12 +49,19 @@ export function middleware(request: NextRequest) { // Redirect authenticated users away from auth routes if (isAuthRoute && isAuthenticated) { + // Redirect based on user role + const redirectPath = isAdmin ? "/admin/dashboard" : "/user/dashboard"; + return NextResponse.redirect(new URL(redirectPath, request.url)); + } + + // Redirect admin users away from user routes + if (isUserRoute && isAuthenticated && isAdmin) { return NextResponse.redirect(new URL("/admin/dashboard", request.url)); } // Redirect non-admin users away from admin routes - if (isAdminRoute && (!isAuthenticated || !isAdmin)) { - return NextResponse.redirect(new URL("/admin/dashboard", request.url)); + if (isAdminRoute && isAuthenticated && !isAdmin) { + return NextResponse.redirect(new URL("/user/dashboard", request.url)); } return NextResponse.next();