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.

This commit is contained in:
iamkiddy 2025-11-23 21:13:18 +00:00
parent 7b5f57ea89
commit 041c36079d
10 changed files with 829 additions and 127 deletions

View File

@ -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() {
</Button>
<Button
variant="ghost"
onClick={() => {
setUserMenuOpen(false);
router.push("/");
}}
onClick={handleLogout}
className={`w-full flex items-center gap-3 px-4 py-3 justify-start transition-colors cursor-pointer ${
isDark ? "hover:bg-gray-800" : "hover:bg-gray-50"
}`}

View File

@ -14,6 +14,8 @@ import {
Heart,
} from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
const navItems = [
{ label: "Dashboard", icon: LayoutGrid, href: "/admin/dashboard" },
@ -26,6 +28,14 @@ export default function SideNav() {
const router = useRouter();
const { theme } = useAppTheme();
const isDark = theme === "dark";
const { logout } = useAuth();
const handleLogout = () => {
setOpen(false);
logout();
toast.success("Logged out successfully");
router.push("/");
};
const getActiveIndex = () => {
return navItems.findIndex((item) => pathname?.includes(item.href)) ?? -1;
@ -176,10 +186,7 @@ export default function SideNav() {
</div>
<Button
variant="ghost"
onClick={() => {
setOpen(false);
router.push("/");
}}
onClick={handleLogout}
className={`group flex items-center gap-2 py-2.5 pl-3 md:pl-3 pr-3 md:pr-3 transition-colors duration-200 w-[90%] md:w-[90%] ml-1 md:ml-2 cursor-pointer justify-start rounded-lg ${
isDark
? "text-gray-300 hover:bg-gray-800 hover:text-rose-300"

View File

@ -3,48 +3,127 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Heart, Eye, EyeOff, X, Loader2 } from "lucide-react";
import Link from "next/link";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { Heart, Eye, EyeOff, X, Loader2, CheckCircle2 } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useAppTheme } from "@/components/ThemeProvider";
import { useAuth } from "@/hooks/useAuth";
import { loginSchema, type LoginInput } from "@/lib/schema/auth";
import {
loginSchema,
registerSchema,
verifyOtpSchema,
type LoginInput,
type RegisterInput,
type VerifyOtpInput
} from "@/lib/schema/auth";
import { toast } from "sonner";
type Step = "login" | "signup" | "verify";
export default function Login() {
const { theme } = useAppTheme();
const isDark = theme === "dark";
const [step, setStep] = useState<Step>("login");
const [showPassword, setShowPassword] = useState(false);
const [showPassword2, setShowPassword2] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [formData, setFormData] = useState<LoginInput>({
const [registeredEmail, setRegisteredEmail] = useState("");
// Login form data
const [loginData, setLoginData] = useState<LoginInput>({
email: "",
password: "",
});
const [errors, setErrors] = useState<Partial<Record<keyof LoginInput, string>>>({});
// Signup form data
const [signupData, setSignupData] = useState<RegisterInput>({
first_name: "",
last_name: "",
email: "",
phone_number: "",
password: "",
password2: "",
});
// OTP verification data
const [otpData, setOtpData] = useState<VerifyOtpInput>({
email: "",
otp: "",
});
const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
const router = useRouter();
const searchParams = useSearchParams();
const { login, isAuthenticated, loginMutation } = useAuth();
const {
login,
register,
verifyOtp,
isAuthenticated,
isAdmin,
loginMutation,
registerMutation,
verifyOtpMutation,
resendOtpMutation
} = useAuth();
// Check for verify step or email from query parameters
useEffect(() => {
const verifyEmail = searchParams.get("verify");
const emailParam = searchParams.get("email");
const errorParam = searchParams.get("error");
// Don't show verify step if there's an error indicating OTP sending failed
if (errorParam && errorParam.toLowerCase().includes("failed to send")) {
setStep("login");
return;
}
if (verifyEmail === "true" && emailParam) {
// Show verify step if verify=true
setStep("verify");
setRegisteredEmail(emailParam);
setOtpData({ email: emailParam, otp: "" });
} else if (emailParam && step === "login") {
// Pre-fill email in login form if email parameter is present
setLoginData(prev => ({ ...prev, email: emailParam }));
}
}, [searchParams, step]);
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
const redirect = searchParams.get("redirect") || "/admin/dashboard";
router.push(redirect);
}
}, [isAuthenticated, router, searchParams]);
// Use a small delay to ensure cookies are set and middleware has processed
const timer = setTimeout(() => {
// Always redirect based on user role, ignore redirect parameter if user is admin
const redirectParam = searchParams.get("redirect");
const defaultRedirect = isAdmin ? "/admin/dashboard" : "/user/dashboard";
const finalRedirect = isAdmin ? "/admin/dashboard" : (redirectParam || defaultRedirect);
const handleSubmit = async (e: React.FormEvent) => {
// Use window.location.href to ensure full page reload and cookie reading
window.location.href = finalRedirect;
}, 200);
return () => clearTimeout(timer);
}
}, [isAuthenticated, isAdmin, searchParams]);
// Handle login
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Validate form
const validation = loginSchema.safeParse(formData);
const validation = loginSchema.safeParse(loginData);
if (!validation.success) {
const fieldErrors: Partial<Record<keyof LoginInput, string>> = {};
const fieldErrors: Partial<Record<string, string>> = {};
validation.error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as keyof LoginInput] = err.message;
fieldErrors[err.path[0] as string] = err.message;
}
});
setErrors(fieldErrors);
@ -52,40 +131,214 @@ export default function Login() {
}
try {
const result = await login(formData);
const result = await login(loginData);
if (result.tokens && result.user) {
toast.success("Login successful!");
// Normalize user data
const user = result.user;
// Check for admin status - check multiple possible field names
const isAdmin =
// Wait a moment for cookies to be set, then redirect
// Check if user is admin/staff/superuser - check all possible field names
const user = result.user as any;
const userIsAdmin =
user.is_admin === true ||
(user as any)?.isAdmin === true ||
(user as any)?.is_staff === true ||
(user as any)?.isStaff === true;
user.isAdmin === true ||
user.is_staff === true ||
user.isStaff === true ||
user.is_superuser === true ||
user.isSuperuser === true;
// Redirect based on user role
const redirect = searchParams.get("redirect");
if (redirect) {
router.push(redirect);
} else {
// Default to admin dashboard
router.push("/admin/dashboard");
}
// Wait longer for cookies to be set and middleware to process
setTimeout(() => {
// Always redirect based on user role, ignore redirect parameter if user is admin
// This ensures admins always go to admin dashboard
const defaultRedirect = userIsAdmin ? "/admin/dashboard" : "/user/dashboard";
// Only use redirect parameter if user is NOT admin
const redirectParam = searchParams.get("redirect");
const finalRedirect = userIsAdmin ? "/admin/dashboard" : (redirectParam || defaultRedirect);
// Use window.location.href instead of router.push to ensure full page reload
// This ensures cookies are read correctly by middleware
window.location.href = finalRedirect;
}, 300);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Login failed. Please try again.";
toast.error(errorMessage);
// Don't set field errors for server errors, only show toast
setErrors({});
}
};
const handleChange = (field: keyof LoginInput, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear error when user starts typing
// Handle signup
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Validate form
const validation = registerSchema.safeParse(signupData);
if (!validation.success) {
const fieldErrors: Partial<Record<string, string>> = {};
validation.error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as string] = err.message;
}
});
setErrors(fieldErrors);
return;
}
try {
const result = await register(signupData);
// Check if registration was successful (user created)
// Even if OTP sending failed, we should allow user to proceed to verification
// and use resend OTP feature
if (result && result.message) {
// Registration successful - proceed to OTP verification
toast.success("Registration successful! Please check your email for OTP verification.");
setRegisteredEmail(signupData.email);
setOtpData({ email: signupData.email, otp: "" });
setStep("verify");
} else {
// If no message but no error, still proceed (some APIs might not return message)
toast.success("Registration successful! Please check your email for OTP verification.");
setRegisteredEmail(signupData.email);
setOtpData({ email: signupData.email, otp: "" });
setStep("verify");
}
} catch (error) {
// Handle different types of errors
let errorMessage = "Registration failed. Please try again.";
if (error instanceof Error) {
errorMessage = error.message;
// 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;
}
// Check if it's an OTP sending error but registration might have succeeded
if (errorMessage.toLowerCase().includes("otp") ||
errorMessage.toLowerCase().includes("email") ||
errorMessage.toLowerCase().includes("send")) {
// If OTP sending failed but user might be created, allow proceeding to verification
// User can use resend OTP
toast.warning("Registration completed, but OTP email could not be sent. You can request a new OTP on the next screen.");
setRegisteredEmail(signupData.email);
setOtpData({ email: signupData.email, otp: "" });
setStep("verify");
return;
}
}
toast.error(errorMessage);
setErrors({});
}
};
// Handle OTP verification
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Use registeredEmail if available, otherwise use otpData.email
const emailToVerify = registeredEmail || otpData.email;
if (!emailToVerify) {
setErrors({ email: "Email address is required" });
return;
}
// Prepare OTP data with email
const otpToVerify = {
email: emailToVerify,
otp: otpData.otp,
};
// Validate OTP
const validation = verifyOtpSchema.safeParse(otpToVerify);
if (!validation.success) {
const fieldErrors: Partial<Record<string, string>> = {};
validation.error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as string] = err.message;
}
});
setErrors(fieldErrors);
return;
}
try {
const result = await verifyOtp(otpToVerify);
// If verification is successful, redirect to login page
toast.success("Email verified successfully! Redirecting to login...");
// Redirect to login page with email pre-filled
setTimeout(() => {
router.push(`/login?email=${encodeURIComponent(emailToVerify)}`);
}, 1000);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "OTP verification failed. Please try again.";
toast.error(errorMessage);
setErrors({});
}
};
// Handle resend OTP
const handleResendOtp = async () => {
const emailToUse = registeredEmail || otpData.email;
if (!emailToUse) {
toast.error("Email address is required to resend OTP.");
return;
}
try {
await resendOtpMutation.mutateAsync({ email: emailToUse, context: "registration" });
toast.success("OTP resent successfully! Please check your email.");
// Update registeredEmail if it wasn't set
if (!registeredEmail) {
setRegisteredEmail(emailToUse);
}
} catch (error) {
let errorMessage = "Failed to resend OTP. Please try again.";
if (error instanceof Error) {
errorMessage = error.message;
// Provide more helpful error messages
if (errorMessage.toLowerCase().includes("ssl") ||
errorMessage.toLowerCase().includes("certificate")) {
errorMessage = "Email service is currently unavailable. Please contact support or try again later.";
} else if (errorMessage.toLowerCase().includes("not found") ||
errorMessage.toLowerCase().includes("does not exist")) {
errorMessage = "Email address not found. Please check your email or register again.";
}
}
toast.error(errorMessage);
}
};
// Handle form field changes
const handleLoginChange = (field: keyof LoginInput, value: string) => {
setLoginData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleSignupChange = (field: keyof RegisterInput, value: string) => {
setSignupData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleOtpChange = (field: keyof VerifyOtpInput, value: string) => {
setOtpData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
@ -113,31 +366,58 @@ export default function Login() {
<span className="text-white text-xl font-semibold">Attune Heart Therapy</span>
</div>
{/* Centered White Card - Login Form */}
{/* Centered White Card */}
<div className={`relative z-20 w-full max-w-md rounded-2xl shadow-2xl p-8 ${isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white'}`}>
{/* Header with Close Button */}
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
{/* Heading */}
<h1 className="text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent mb-2">
Welcome back
<h1 className="text-3xl font-bold bg-linear-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent mb-2">
{step === "login" && "Welcome back"}
{step === "signup" && "Create an account"}
{step === "verify" && "Verify your email"}
</h1>
{/* Sign Up Prompt */}
{/* Subtitle */}
{step === "login" && (
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
New to Attune Heart Therapy?{" "}
<Link href="/signup" className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}>
<Link
href="/signup"
className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
Sign up
</Link>
</p>
)}
{step === "signup" && (
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Already have an account?{" "}
<button
type="button"
onClick={() => setStep("login")}
className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
Log in
</button>
</p>
)}
{step === "verify" && registeredEmail && (
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
We've sent a verification code to <strong>{registeredEmail}</strong>
</p>
)}
{step === "verify" && !registeredEmail && (
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Enter the verification code sent to your email
</p>
)}
</div>
{/* Close Button */}
<Button
onClick={() => router.back()}
variant="ghost"
size="icon"
className={`flex-shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
className={`shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
aria-label="Close"
>
<X className="w-5 h-5" />
@ -145,7 +425,8 @@ export default function Login() {
</div>
{/* Login Form */}
<form className="space-y-6" onSubmit={handleSubmit}>
{step === "login" && (
<form className="space-y-6" onSubmit={handleLogin}>
{/* Email Field */}
<div className="space-y-2">
<label htmlFor="email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
@ -155,11 +436,14 @@ export default function Login() {
id="email"
type="email"
placeholder="Email address"
value={formData.email}
onChange={(e) => handleChange("email", e.target.value)}
value={loginData.email}
onChange={(e) => handleLoginChange("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 && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
{/* Password Field */}
@ -172,8 +456,8 @@ export default function Login() {
id="password"
type={showPassword ? "text" : "password"}
placeholder="Your password"
value={formData.password}
onChange={(e) => handleChange("password", e.target.value)}
value={loginData.password}
onChange={(e) => handleLoginChange("password", e.target.value)}
className={`h-12 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
/>
@ -201,7 +485,7 @@ export default function Login() {
<Button
type="submit"
disabled={loginMutation.isPending}
className="w-full h-12 text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full h-12 text-base font-semibold bg-linear-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loginMutation.isPending ? (
<>
@ -224,15 +508,287 @@ export default function Login() {
/>
<span className={isDark ? 'text-gray-300' : 'text-black'}>Remember me</span>
</label>
<Link
href="/forgot-password"
<button
type="button"
className={`font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
Forgot password?
</Link>
</button>
</div>
</form>
)}
{/* Signup Form */}
{step === "signup" && (
<form className="space-y-4" onSubmit={handleSignup}>
{/* First Name Field */}
<div className="space-y-2">
<label htmlFor="firstName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
First Name *
</label>
<Input
id="firstName"
type="text"
placeholder="John"
value={signupData.first_name}
onChange={(e) => 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 && (
<p className="text-sm text-red-500">{errors.first_name}</p>
)}
</div>
{/* Last Name Field */}
<div className="space-y-2">
<label htmlFor="lastName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Last Name *
</label>
<Input
id="lastName"
type="text"
placeholder="Doe"
value={signupData.last_name}
onChange={(e) => 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 && (
<p className="text-sm text-red-500">{errors.last_name}</p>
)}
</div>
{/* Email Field */}
<div className="space-y-2">
<label htmlFor="signup-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address *
</label>
<Input
id="signup-email"
type="email"
placeholder="Email address"
value={signupData.email}
onChange={(e) => 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 && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
{/* Phone Field */}
<div className="space-y-2">
<label htmlFor="phone" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Phone Number (Optional)
</label>
<Input
id="phone"
type="tel"
placeholder="+1 (555) 123-4567"
value={signupData.phone_number || ""}
onChange={(e) => 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'}`}
/>
</div>
{/* Password Field */}
<div className="space-y-2">
<label htmlFor="signup-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Password *
</label>
<div className="relative">
<Input
id="signup-password"
type={showPassword ? "text" : "password"}
placeholder="Password (min 8 characters)"
value={signupData.password}
onChange={(e) => 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
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowPassword(!showPassword)}
className={`absolute right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</Button>
</div>
{errors.password && (
<p className="text-sm text-red-500">{errors.password}</p>
)}
</div>
{/* Confirm Password Field */}
<div className="space-y-2">
<label htmlFor="signup-password2" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Confirm Password *
</label>
<div className="relative">
<Input
id="signup-password2"
type={showPassword2 ? "text" : "password"}
placeholder="Confirm password"
value={signupData.password2}
onChange={(e) => 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
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowPassword2(!showPassword2)}
className={`absolute right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
aria-label={showPassword2 ? "Hide password" : "Show password"}
>
{showPassword2 ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</Button>
</div>
{errors.password2 && (
<p className="text-sm text-red-500">{errors.password2}</p>
)}
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={registerMutation.isPending}
className="w-full h-12 text-base font-semibold bg-linear-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-6"
>
{registerMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating account...
</>
) : (
"Sign up"
)}
</Button>
</form>
)}
{/* OTP Verification Form */}
{step === "verify" && (
<form className="space-y-6" onSubmit={handleVerifyOtp}>
<div className={`p-4 rounded-lg border ${isDark ? 'bg-blue-900/20 border-blue-800' : 'bg-blue-50 border-blue-200'}`}>
<div className="flex items-start gap-3">
<CheckCircle2 className={`w-5 h-5 mt-0.5 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
<div>
<p className={`text-sm font-medium ${isDark ? 'text-blue-200' : 'text-blue-900'}`}>
Check your email
</p>
<p className={`text-sm mt-1 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
We've sent a 6-digit verification code to your email address.
</p>
</div>
</div>
</div>
{/* Email Field (if not set) */}
{!registeredEmail && (
<div className="space-y-2">
<label htmlFor="verify-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address *
</label>
<Input
id="verify-email"
type="email"
placeholder="Email address"
value={otpData.email}
onChange={(e) => 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 && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
)}
{/* OTP Field */}
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Verification Code *
</label>
<div className="flex justify-center">
<InputOTP
maxLength={6}
value={otpData.otp}
onChange={(value) => handleOtpChange("otp", value)}
aria-invalid={!!errors.otp}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
{errors.otp && (
<p className="text-sm text-red-500 text-center">{errors.otp}</p>
)}
</div>
{/* Resend OTP */}
<div className="text-center">
<button
type="button"
onClick={handleResendOtp}
disabled={resendOtpMutation.isPending}
className={`text-sm font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{resendOtpMutation.isPending ? "Sending..." : "Didn't receive the code? Resend"}
</button>
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={verifyOtpMutation.isPending}
className="w-full h-12 text-base font-semibold bg-linear-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{verifyOtpMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verifying...
</>
) : (
"Verify Email"
)}
</Button>
{/* Back to signup */}
<div className="text-center">
<button
type="button"
onClick={() => {
setStep("signup");
setOtpData({ email: "", otp: "" });
}}
className={`text-sm font-medium ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-600 hover:text-gray-700'}`}
>
Back to signup
</button>
</div>
</form>
)}
</div>
</div>
);

View File

@ -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.";

View File

@ -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<string | null>(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() {
</a>
</p>
</div>
{/* Logout Button - Only show when authenticated */}
{isAuthenticated && (
<div className="mt-6 flex justify-center">
<Button
onClick={handleLogout}
variant="outline"
className="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700"
>
<LogOut className="w-4 h-4 mr-2" />
Logout
</Button>
</div>
)}
</>
)}
</div>

View File

@ -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() {
</motion.div>
{/* Desktop Navigation */}
{!isUserDashboard && (
{!isUserRoute && (
<div className="hidden lg:flex items-center gap-4 xl:gap-6">
<button
onClick={() => scrollToSection("about")}
@ -98,7 +110,7 @@ export function Navbar() {
{/* Desktop Actions */}
<div className="hidden lg:flex items-center gap-2">
{!isUserDashboard && (
{!isAuthenticated && !isUserDashboard && (
<Button
size="sm"
variant="outline"
@ -109,9 +121,31 @@ export function Navbar() {
</Button>
)}
<ThemeToggle />
<Button size="sm" className="hover:opacity-90 hover:scale-105 transition-all text-xs sm:text-sm" asChild>
<a href="/book-now">Book Now</a>
{!isUserDashboard && (
<Link
href="/book-now"
className={`text-sm font-medium transition-colors cursor-pointer px-3 py-2 rounded-lg hover:opacity-90 ${isDark ? 'text-gray-300 hover:text-white' : 'text-gray-700 hover:text-rose-600'}`}
>
Book-Now
</Link>
)}
{isAuthenticated && (
<Button
size="sm"
variant="outline"
className={`hover:opacity-90 hover:scale-105 transition-all text-xs sm:text-sm ${
isUserRoute
? 'bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700'
: isDark
? 'border-gray-700 text-gray-300 hover:bg-gray-800'
: ''
}`}
onClick={handleLogout}
>
<LogOut className="w-4 h-4 mr-2" />
Logout
</Button>
)}
</div>
{/* Mobile Actions */}
@ -161,7 +195,7 @@ export function Navbar() {
>
<div className="flex flex-col p-4 sm:p-6 space-y-3 sm:space-y-4">
{/* Mobile Navigation Links */}
{!isUserDashboard && (
{!isUserRoute && (
<>
<button
onClick={() => scrollToSection("about")}
@ -185,7 +219,7 @@ export function Navbar() {
)}
<div className={`border-t pt-3 sm:pt-4 mt-3 sm:mt-4 space-y-2 sm:space-y-3 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
{!isUserDashboard && (
{!isAuthenticated && !isUserDashboard && (
<Button
variant="outline"
className={`w-full justify-start text-sm sm:text-base ${isDark ? 'border-gray-700 text-gray-300 hover:bg-gray-800' : ''}`}
@ -197,14 +231,33 @@ export function Navbar() {
Sign In
</Button>
)}
<Button
className="w-full justify-start bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white text-sm sm:text-base"
asChild
{!isUserDashboard && (
<Link
href="/book-now"
onClick={() => setMobileMenuOpen(false)}
className={`text-left text-sm sm:text-base font-medium py-2.5 sm:py-3 px-3 sm:px-4 rounded-lg transition-colors ${isDark ? 'text-gray-300 hover:bg-gray-800' : 'text-gray-700 hover:bg-gray-100'}`}
>
<Link href="/book-now" onClick={() => setMobileMenuOpen(false)}>
Book Now
Book-Now
</Link>
)}
{isAuthenticated && (
<Button
variant="outline"
className={`w-full justify-start text-sm sm:text-base ${
isUserRoute
? 'bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700'
: isDark
? 'border-gray-700 text-gray-300 hover:bg-gray-800'
: ''
}`}
onClick={() => {
handleLogout();
}}
>
<LogOut className="w-4 h-4 mr-2" />
Logout
</Button>
)}
</div>
</div>
</motion.div>

View File

@ -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(

View File

@ -76,6 +76,28 @@ async function handleResponse<T>(response: Response): Promise<T> {
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<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.register, {
@ -86,6 +108,29 @@ export async function registerUser(input: RegisterInput): Promise<AuthResponse>
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<AuthResponse>(response);
}
@ -100,23 +145,7 @@ export async function verifyOtp(input: VerifyOtpInput): Promise<AuthResponse> {
});
const data = await handleResponse<AuthResponse>(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<AuthResponse> {
});
const data = await handleResponse<AuthResponse>(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

View File

@ -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;
}

View File

@ -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();