Enhance authentication flow by integrating @tanstack/react-query for improved data fetching, adding form validation in Login and LoginDialog components, and updating user redirection logic post-login. Also, include new dependencies: input-otp and zod for OTP input handling and schema validation.

This commit is contained in:
iamkiddy 2025-11-23 13:29:31 +00:00
parent a819dc3a04
commit eb8a800eb7
15 changed files with 1543 additions and 61 deletions

View File

@ -1,20 +1,95 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Heart, Eye, EyeOff, X } from "lucide-react"; import { Heart, Eye, EyeOff, X, Loader2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { useAuth } from "@/hooks/useAuth";
import { loginSchema, type LoginInput } from "@/lib/schema/auth";
import { toast } from "sonner";
export default function Login() { export default function Login() {
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const [formData, setFormData] = useState<LoginInput>({
email: "",
password: "",
});
const [errors, setErrors] = useState<Partial<Record<keyof LoginInput, string>>>({});
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { login, isAuthenticated, loginMutation } = useAuth();
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
const redirect = searchParams.get("redirect") || "/admin/dashboard";
router.push(redirect);
}
}, [isAuthenticated, router, searchParams]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Validate form
const validation = loginSchema.safeParse(formData);
if (!validation.success) {
const fieldErrors: Partial<Record<keyof LoginInput, string>> = {};
validation.error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as keyof LoginInput] = err.message;
}
});
setErrors(fieldErrors);
return;
}
try {
const result = await login(formData);
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 =
user.is_admin === true ||
(user as any)?.isAdmin === true ||
(user as any)?.is_staff === true ||
(user as any)?.isStaff === 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");
}
}
} 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
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
return ( return (
<div className="min-h-screen relative flex items-center justify-center px-4 py-12"> <div className="min-h-screen relative flex items-center justify-center px-4 py-12">
@ -70,10 +145,7 @@ export default function Login() {
</div> </div>
{/* Login Form */} {/* Login Form */}
<form className="space-y-6" onSubmit={(e) => { <form className="space-y-6" onSubmit={handleSubmit}>
e.preventDefault();
router.push("/");
}}>
{/* Email Field */} {/* Email Field */}
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}> <label htmlFor="email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
@ -83,7 +155,9 @@ export default function Login() {
id="email" id="email"
type="email" type="email"
placeholder="Email address" placeholder="Email address"
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} value={formData.email}
onChange={(e) => handleChange("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 required
/> />
</div> </div>
@ -98,7 +172,9 @@ export default function Login() {
id="password" id="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
placeholder="Your password" placeholder="Your password"
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'}`} value={formData.password}
onChange={(e) => handleChange("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 required
/> />
<Button <Button
@ -116,14 +192,25 @@ export default function Login() {
)} )}
</Button> </Button>
</div> </div>
{errors.password && (
<p className="text-sm text-red-500">{errors.password}</p>
)}
</div> </div>
{/* Submit Button */} {/* Submit Button */}
<Button <Button
type="submit" type="submit"
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={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"
> >
Log in {loginMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Logging in...
</>
) : (
"Log in"
)}
</Button> </Button>
{/* Remember Me & Forgot Password */} {/* Remember Me & Forgot Password */}

450
app/(auth)/signup/page.tsx Normal file
View File

@ -0,0 +1,450 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { Heart, Eye, EyeOff, X, Loader2, CheckCircle2 } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useAppTheme } from "@/components/ThemeProvider";
import { useAuth } from "@/hooks/useAuth";
import { registerSchema, verifyOtpSchema, type RegisterInput, type VerifyOtpInput } from "@/lib/schema/auth";
import { toast } from "sonner";
export default function Signup() {
const { theme } = useAppTheme();
const isDark = theme === "dark";
const [showPassword, setShowPassword] = useState(false);
const [showPassword2, setShowPassword2] = useState(false);
const [step, setStep] = useState<"register" | "verify">("register");
const [registeredEmail, setRegisteredEmail] = useState("");
const [formData, setFormData] = useState<RegisterInput>({
first_name: "",
last_name: "",
email: "",
phone_number: "",
password: "",
password2: "",
});
const [otpData, setOtpData] = useState<VerifyOtpInput>({
email: "",
otp: "",
});
const [errors, setErrors] = useState<Partial<Record<keyof RegisterInput | keyof VerifyOtpInput, string>>>({});
const router = useRouter();
const searchParams = useSearchParams();
const { register, verifyOtp, isAuthenticated, registerMutation, verifyOtpMutation, resendOtpMutation } = useAuth();
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
const redirect = searchParams.get("redirect") || "/admin/dashboard";
router.push(redirect);
}
}, [isAuthenticated, router, searchParams]);
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Validate form
const validation = registerSchema.safeParse(formData);
if (!validation.success) {
const fieldErrors: Partial<Record<keyof RegisterInput, string>> = {};
validation.error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as keyof RegisterInput] = err.message;
}
});
setErrors(fieldErrors);
return;
}
try {
const result = await register(formData);
// If registration is successful (no error thrown), show OTP verification
toast.success("Registration successful! Please check your email for OTP verification.");
setRegisteredEmail(formData.email);
setOtpData({ email: formData.email, otp: "" });
setStep("verify");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Registration failed. Please try again.";
toast.error(errorMessage);
// Don't set field errors for server errors, only show toast
setErrors({});
}
};
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Validate OTP
const validation = verifyOtpSchema.safeParse(otpData);
if (!validation.success) {
const fieldErrors: Partial<Record<keyof VerifyOtpInput, string>> = {};
validation.error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as keyof VerifyOtpInput] = err.message;
}
});
setErrors(fieldErrors);
return;
}
try {
const result = await verifyOtp(otpData);
// 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
setTimeout(() => {
router.push("/login");
}, 1500);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "OTP verification failed. Please try again.";
toast.error(errorMessage);
// Don't set field errors for server errors, only show toast
setErrors({});
}
};
const handleResendOtp = async () => {
try {
await resendOtpMutation.mutateAsync({ email: registeredEmail, context: "registration" });
toast.success("OTP resent successfully! Please check your email.");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to resend OTP. Please try again.";
toast.error(errorMessage);
}
};
const handleChange = (field: keyof RegisterInput, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleOtpChange = (field: keyof VerifyOtpInput, value: string) => {
setOtpData((prev) => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
return (
<div className="min-h-screen relative flex items-center justify-center px-4 py-12">
{/* Background Image */}
<div className="absolute inset-0 z-0">
<Image
src="/woman.jpg"
alt="Therapy and counseling session with African American clients"
fill
className="object-cover object-center"
priority
sizes="100vw"
/>
{/* Overlay for better readability */}
<div className="absolute inset-0 bg-black/20"></div>
</div>
{/* Branding - Top Left */}
<div className="absolute top-8 left-8 flex items-center gap-3 z-30">
<Heart className="w-6 h-6 text-white" fill="white" />
<span className="text-white text-xl font-semibold">Attune Heart Therapy</span>
</div>
{/* Centered White Card - Signup Form */}
<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">
{step === "register" ? "Create an account" : "Verify your email"}
</h1>
{/* Login Prompt */}
{step === "register" && (
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Already have an account?{" "}
<Link href="/login" className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}>
Log in
</Link>
</p>
)}
{step === "verify" && (
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
We've sent a verification code to <strong>{registeredEmail}</strong>
</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'}`}
aria-label="Close"
>
<X className="w-5 h-5" />
</Button>
</div>
{step === "register" ? (
/* Registration Form */
<form className="space-y-4" onSubmit={handleRegister}>
{/* 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={formData.first_name}
onChange={(e) => handleChange("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={formData.last_name}
onChange={(e) => handleChange("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="email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address *
</label>
<Input
id="email"
type="email"
placeholder="Email address"
value={formData.email}
onChange={(e) => handleChange("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
/>
</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={formData.phone_number || ""}
onChange={(e) => handleChange("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="password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Password *
</label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Password (min 8 characters)"
value={formData.password}
onChange={(e) => handleChange("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="password2" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Confirm Password *
</label>
<div className="relative">
<Input
id="password2"
type={showPassword2 ? "text" : "password"}
placeholder="Confirm password"
value={formData.password2}
onChange={(e) => handleChange("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-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 mt-6"
>
{registerMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating account...
</>
) : (
"Sign up"
)}
</Button>
</form>
) : (
/* OTP Verification Form */
<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>
{/* 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>
</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-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"
>
{verifyOtpMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verifying...
</>
) : (
"Verify Email"
)}
</Button>
{/* Back to registration */}
<div className="text-center">
<button
type="button"
onClick={() => {
setStep("register");
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 registration
</button>
</div>
</form>
)}
</div>
</div>
);
}

View File

@ -2,12 +2,26 @@
import { ThemeProvider } from "../components/ThemeProvider"; import { ThemeProvider } from "../components/ThemeProvider";
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: ReactNode }) { export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
retry: 1,
},
},
})
);
return ( return (
<ThemeProvider> <QueryClientProvider client={queryClient}>
{children} <ThemeProvider>{children}</ThemeProvider>
</ThemeProvider> </QueryClientProvider>
); );
} }

View File

@ -12,6 +12,10 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Eye, EyeOff, Loader2, X } from "lucide-react"; import { Eye, EyeOff, Loader2, X } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { loginSchema, registerSchema, type LoginInput, type RegisterInput } from "@/lib/schema/auth";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
interface LoginDialogProps { interface LoginDialogProps {
open: boolean; open: boolean;
@ -23,58 +27,87 @@ interface LoginDialogProps {
export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogProps) { export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogProps) {
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const router = useRouter();
const { login, register, loginMutation, registerMutation } = useAuth();
const [isSignup, setIsSignup] = useState(false); const [isSignup, setIsSignup] = useState(false);
const [loginData, setLoginData] = useState({ const [loginData, setLoginData] = useState<LoginInput>({
email: "", email: "",
password: "", password: "",
}); });
const [signupData, setSignupData] = useState({ const [signupData, setSignupData] = useState<RegisterInput>({
fullName: "", first_name: "",
last_name: "",
email: "", email: "",
phone: "", phone_number: "",
password: "",
password2: "",
}); });
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showPassword2, setShowPassword2] = useState(false);
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const [loginLoading, setLoginLoading] = useState(false);
const [signupLoading, setSignupLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoginLoading(true);
setError(null); setError(null);
try { // Validate form
// Simulate login API call const validation = loginSchema.safeParse(loginData);
await new Promise((resolve) => setTimeout(resolve, 1000)); if (!validation.success) {
const firstError = validation.error.errors[0];
setError(firstError.message);
return;
}
// After successful login, close dialog and call success callback try {
setShowPassword(false); const result = await login(loginData);
setLoginLoading(false);
onOpenChange(false); if (result.tokens && result.user) {
onLoginSuccess(); toast.success("Login successful!");
setShowPassword(false);
onOpenChange(false);
onLoginSuccess();
}
} catch (err) { } catch (err) {
setError("Login failed. Please try again."); const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again.";
setLoginLoading(false); setError(errorMessage);
toast.error(errorMessage);
} }
}; };
const handleSignup = async (e: React.FormEvent) => { const handleSignup = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSignupLoading(true);
setError(null); setError(null);
try { // Validate form
// Simulate signup API call const validation = registerSchema.safeParse(signupData);
await new Promise((resolve) => setTimeout(resolve, 1000)); if (!validation.success) {
const firstError = validation.error.errors[0];
setError(firstError.message);
return;
}
// After successful signup, automatically log in and proceed try {
setSignupLoading(false); const result = await register(signupData);
onOpenChange(false);
onLoginSuccess(); if (result.message) {
toast.success("Registration successful! Please check your email for OTP verification.");
// Switch to login after successful registration
setIsSignup(false);
setLoginData({ email: signupData.email, password: "" });
setSignupData({
first_name: "",
last_name: "",
email: "",
phone_number: "",
password: "",
password2: "",
});
}
} catch (err) { } catch (err) {
setError("Signup failed. Please try again."); const errorMessage = err instanceof Error ? err.message : "Signup failed. Please try again.";
setSignupLoading(false); setError(errorMessage);
toast.error(errorMessage);
} }
}; };
@ -87,7 +120,14 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
const handleSwitchToLogin = () => { const handleSwitchToLogin = () => {
setIsSignup(false); setIsSignup(false);
setError(null); setError(null);
setSignupData({ fullName: "", email: "", phone: "" }); setSignupData({
first_name: "",
last_name: "",
email: "",
phone_number: "",
password: "",
password2: "",
});
}; };
return ( return (
@ -127,17 +167,33 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
</div> </div>
)} )}
{/* Full Name Field */} {/* First Name Field */}
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="signup-fullName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}> <label htmlFor="signup-firstName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Full Name * First Name *
</label> </label>
<Input <Input
id="signup-fullName" id="signup-firstName"
type="text" type="text"
placeholder="John Doe" placeholder="John"
value={signupData.fullName} value={signupData.first_name}
onChange={(e) => setSignupData({ ...signupData, fullName: e.target.value })} onChange={(e) => setSignupData({ ...signupData, first_name: 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'}`}
required
/>
</div>
{/* Last Name Field */}
<div className="space-y-2">
<label htmlFor="signup-lastName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Last Name *
</label>
<Input
id="signup-lastName"
type="text"
placeholder="Doe"
value={signupData.last_name}
onChange={(e) => setSignupData({ ...signupData, last_name: 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'}`} className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required required
/> />
@ -162,26 +218,89 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
{/* Phone Field */} {/* Phone Field */}
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="signup-phone" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}> <label htmlFor="signup-phone" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Phone Number * Phone Number (Optional)
</label> </label>
<Input <Input
id="signup-phone" id="signup-phone"
type="tel" type="tel"
placeholder="+1 (555) 123-4567" placeholder="+1 (555) 123-4567"
value={signupData.phone} value={signupData.phone_number || ""}
onChange={(e) => setSignupData({ ...signupData, phone: e.target.value })} onChange={(e) => setSignupData({ ...signupData, phone_number: 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'}`} className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/> />
</div> </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) => setSignupData({ ...signupData, 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'}`}
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>
</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) => setSignupData({ ...signupData, password2: 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'}`}
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>
</div>
{/* Submit Button */} {/* Submit Button */}
<Button <Button
type="submit" type="submit"
disabled={signupLoading} disabled={registerMutation.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-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"
> >
{signupLoading ? ( {registerMutation.isPending ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating account... Creating account...
@ -263,10 +382,10 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
{/* Submit Button */} {/* Submit Button */}
<Button <Button
type="submit" type="submit"
disabled={loginLoading} 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-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"
> >
{loginLoading ? ( {loginMutation.isPending ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
Logging in... Logging in...

View File

@ -28,8 +28,8 @@ export function Navbar() {
}; };
const handleLoginSuccess = () => { const handleLoginSuccess = () => {
// Redirect to user dashboard after successful login // Redirect to admin dashboard after successful login
router.push("/user/dashboard"); router.push("/admin/dashboard");
setMobileMenuOpen(false); setMobileMenuOpen(false);
}; };

View File

@ -0,0 +1,77 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@ -1,8 +1,29 @@
"use client"; "use client";
// Simple toaster component - can be enhanced later with toast notifications import { Toaster as Sonner } from "sonner";
import { useAppTheme } from "@/components/ThemeProvider";
export function Toaster() { export function Toaster() {
return null; const { theme } = useAppTheme();
return (
<Sonner
theme={theme === "dark" ? "dark" : "light"}
position="top-center"
richColors
closeButton
duration={4000}
expand={true}
toastOptions={{
classNames: {
toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
/>
);
} }

178
hooks/useAuth.ts Normal file
View File

@ -0,0 +1,178 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useCallback } from "react";
import {
loginUser,
registerUser,
verifyOtp,
resendOtp,
forgotPassword,
verifyPasswordResetOtp,
resetPassword,
refreshToken,
getStoredTokens,
getStoredUser,
storeTokens,
storeUser,
clearAuthData,
} from "@/lib/actions/auth";
import type {
LoginInput,
RegisterInput,
VerifyOtpInput,
ResendOtpInput,
ForgotPasswordInput,
VerifyPasswordResetOtpInput,
ResetPasswordInput,
} from "@/lib/schema/auth";
import type { User } from "@/lib/models/auth";
export function useAuth() {
const router = useRouter();
const queryClient = useQueryClient();
// Get current user from storage
const { data: user } = useQuery<User | null>({
queryKey: ["auth", "user"],
queryFn: () => getStoredUser(),
staleTime: Infinity,
});
// 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;
// Login mutation
const loginMutation = useMutation({
mutationFn: (input: LoginInput) => loginUser(input),
onSuccess: (data) => {
if (data.tokens && data.user) {
storeTokens(data.tokens);
storeUser(data.user);
queryClient.setQueryData(["auth", "user"], data.user);
queryClient.invalidateQueries({ queryKey: ["auth"] });
}
},
});
// Register mutation
const registerMutation = useMutation({
mutationFn: (input: RegisterInput) => registerUser(input),
});
// Verify OTP mutation
const verifyOtpMutation = useMutation({
mutationFn: (input: VerifyOtpInput) => verifyOtp(input),
onSuccess: (data) => {
if (data.tokens && data.user) {
storeTokens(data.tokens);
storeUser(data.user);
queryClient.setQueryData(["auth", "user"], data.user);
queryClient.invalidateQueries({ queryKey: ["auth"] });
}
},
});
// Resend OTP mutation
const resendOtpMutation = useMutation({
mutationFn: (input: ResendOtpInput) => resendOtp(input),
});
// Forgot password mutation
const forgotPasswordMutation = useMutation({
mutationFn: (input: ForgotPasswordInput) => forgotPassword(input),
});
// Verify password reset OTP mutation
const verifyPasswordResetOtpMutation = useMutation({
mutationFn: (input: VerifyPasswordResetOtpInput) => verifyPasswordResetOtp(input),
});
// Reset password mutation
const resetPasswordMutation = useMutation({
mutationFn: (input: ResetPasswordInput) => resetPassword(input),
});
// Refresh token mutation
const refreshTokenMutation = useMutation({
mutationFn: (refresh: string) => refreshToken({ refresh }),
onSuccess: (tokens) => {
storeTokens(tokens);
},
});
// Logout function
const logout = useCallback(() => {
clearAuthData();
queryClient.clear();
router.push("/login");
}, [queryClient, router]);
// Login function
const login = useCallback(
async (input: LoginInput) => {
try {
const result = await loginMutation.mutateAsync(input);
return result;
} catch (error) {
throw error;
}
},
[loginMutation]
);
// Register function
const register = useCallback(
async (input: RegisterInput) => {
try {
const result = await registerMutation.mutateAsync(input);
return result;
} catch (error) {
throw error;
}
},
[registerMutation]
);
// Verify OTP function
const verifyOtpCode = useCallback(
async (input: VerifyOtpInput) => {
try {
const result = await verifyOtpMutation.mutateAsync(input);
return result;
} catch (error) {
throw error;
}
},
[verifyOtpMutation]
);
return {
// State
user,
isAuthenticated,
isAdmin,
isLoading: loginMutation.isPending || registerMutation.isPending,
// Actions
login,
register,
logout,
verifyOtp: verifyOtpCode,
// Mutations (for direct access if needed)
loginMutation,
registerMutation,
verifyOtpMutation,
resendOtpMutation,
forgotPasswordMutation,
verifyPasswordResetOtpMutation,
resetPasswordMutation,
refreshTokenMutation,
};
}

289
lib/actions/auth.ts Normal file
View File

@ -0,0 +1,289 @@
import { API_ENDPOINTS } from "@/lib/api_urls";
import type {
RegisterInput,
VerifyOtpInput,
LoginInput,
ResendOtpInput,
ForgotPasswordInput,
VerifyPasswordResetOtpInput,
ResetPasswordInput,
TokenRefreshInput,
} from "@/lib/schema/auth";
import type { AuthResponse, ApiError, AuthTokens, User } from "@/lib/models/auth";
// Helper function to extract error message from API response
function extractErrorMessage(error: ApiError): string {
// Check for main error messages
if (error.detail) {
// Handle both string and array formats
if (Array.isArray(error.detail)) {
return error.detail.join(", ");
}
return String(error.detail);
}
if (error.message) {
if (Array.isArray(error.message)) {
return error.message.join(", ");
}
return String(error.message);
}
if (error.error) {
if (Array.isArray(error.error)) {
return error.error.join(", ");
}
return String(error.error);
}
// Check for field-specific errors (common in Django REST Framework)
const fieldErrors: string[] = [];
Object.keys(error).forEach((key) => {
if (key !== "detail" && key !== "message" && key !== "error") {
const fieldError = error[key];
if (Array.isArray(fieldError)) {
fieldErrors.push(`${key}: ${fieldError.join(", ")}`);
} else if (typeof fieldError === "string") {
fieldErrors.push(`${key}: ${fieldError}`);
}
}
});
if (fieldErrors.length > 0) {
return fieldErrors.join(". ");
}
return "An error occurred";
}
// Helper function to handle API responses
async function handleResponse<T>(response: Response): Promise<T> {
let data: any;
try {
data = await response.json();
} catch {
// If response is not JSON, use status text
throw new Error(response.statusText || "An error occurred");
}
if (!response.ok) {
const error: ApiError = data;
const errorMessage = extractErrorMessage(error);
throw new Error(errorMessage);
}
return data as T;
}
// Register a new user
export async function registerUser(input: RegisterInput): Promise<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.register, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return handleResponse<AuthResponse>(response);
}
// Verify OTP
export async function verifyOtp(input: VerifyOtpInput): Promise<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.verifyOtp, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
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;
}
// Login user
export async function loginUser(input: LoginInput): Promise<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.login, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
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;
}
// Resend OTP
export async function resendOtp(input: ResendOtpInput): Promise<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.resendOtp, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return handleResponse<AuthResponse>(response);
}
// Forgot password
export async function forgotPassword(input: ForgotPasswordInput): Promise<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.forgotPassword, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return handleResponse<AuthResponse>(response);
}
// Verify password reset OTP
export async function verifyPasswordResetOtp(
input: VerifyPasswordResetOtpInput
): Promise<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.verifyPasswordResetOtp, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return handleResponse<AuthResponse>(response);
}
// Reset password
export async function resetPassword(input: ResetPasswordInput): Promise<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.resetPassword, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return handleResponse<AuthResponse>(response);
}
// Refresh access token
export async function refreshToken(input: TokenRefreshInput): Promise<AuthTokens> {
const response = await fetch(API_ENDPOINTS.auth.tokenRefresh, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return handleResponse<AuthTokens>(response);
}
// Get stored tokens
export function getStoredTokens(): { access: string | null; refresh: string | null } {
if (typeof window === "undefined") {
return { access: null, refresh: null };
}
return {
access: localStorage.getItem("auth_access_token"),
refresh: localStorage.getItem("auth_refresh_token"),
};
}
// Store tokens
export function storeTokens(tokens: AuthTokens): void {
if (typeof window === "undefined") return;
localStorage.setItem("auth_access_token", tokens.access);
localStorage.setItem("auth_refresh_token", tokens.refresh);
// Also set cookies for middleware
document.cookie = `auth_access_token=${tokens.access}; path=/; max-age=${7 * 24 * 60 * 60}; SameSite=Lax`;
document.cookie = `auth_refresh_token=${tokens.refresh}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
}
// Store user
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`;
}
// Get stored user
export function getStoredUser(): User | null {
if (typeof window === "undefined") return null;
const userStr = localStorage.getItem("auth_user");
if (!userStr) return null;
try {
return JSON.parse(userStr) as User;
} catch {
return null;
}
}
// Clear auth data
export function clearAuthData(): void {
if (typeof window === "undefined") return;
localStorage.removeItem("auth_access_token");
localStorage.removeItem("auth_refresh_token");
localStorage.removeItem("auth_user");
// Also clear cookies
document.cookie = "auth_access_token=; path=/; max-age=0";
document.cookie = "auth_refresh_token=; path=/; max-age=0";
document.cookie = "auth_user=; path=/; max-age=0";
}
// Get auth header for API requests
export function getAuthHeader(): { Authorization: string } | {} {
const tokens = getStoredTokens();
if (tokens.access) {
return { Authorization: `Bearer ${tokens.access}` };
}
return {};
}

28
lib/api_urls.ts Normal file
View File

@ -0,0 +1,28 @@
// Get API base URL from environment variable
const getApiBaseUrl = () => {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "";
// Remove trailing slash if present
const cleanUrl = baseUrl.replace(/\/$/, "");
// Add /api if not already present
return cleanUrl ? `${cleanUrl}/api` : "";
};
export const API_BASE_URL = getApiBaseUrl();
export const API_ENDPOINTS = {
auth: {
base: `${API_BASE_URL}/auth/`,
register: `${API_BASE_URL}/auth/register/`,
verifyOtp: `${API_BASE_URL}/auth/verify-otp/`,
login: `${API_BASE_URL}/auth/login/`,
resendOtp: `${API_BASE_URL}/auth/resend-otp/`,
forgotPassword: `${API_BASE_URL}/auth/forgot-password/`,
verifyPasswordResetOtp: `${API_BASE_URL}/auth/verify-password-reset-otp/`,
resetPassword: `${API_BASE_URL}/auth/reset-password/`,
tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`,
},
meetings: {
base: `${API_BASE_URL}/meetings/`,
},
} as const;

48
lib/models/auth.ts Normal file
View File

@ -0,0 +1,48 @@
// Authentication Response Models
export interface AuthTokens {
access: string;
refresh: string;
}
export interface User {
id: number;
email: string;
first_name: string;
last_name: string;
phone_number?: string;
is_admin?: boolean;
is_verified?: boolean;
isVerified?: boolean; // API uses camelCase
date_joined?: string;
created_at?: string;
updated_at?: string;
}
export interface AuthResponse {
message?: string;
access?: string; // Tokens can be at root level
refresh?: string; // Tokens can be at root level
tokens?: AuthTokens; // Or nested in tokens object
user?: User;
detail?: string;
error?: string;
}
export interface ApiError {
detail?: string;
message?: string;
error?: string;
email?: string[];
password?: string[];
password2?: string[];
otp?: string[];
[key: string]: string | string[] | undefined;
}
// Token Storage Keys
export const TOKEN_STORAGE_KEYS = {
ACCESS_TOKEN: "auth_access_token",
REFRESH_TOKEN: "auth_refresh_token",
USER: "auth_user",
} as const;

80
lib/schema/auth.ts Normal file
View File

@ -0,0 +1,80 @@
import { z } from "zod";
// Register Schema
export const registerSchema = z
.object({
email: z.string().email("Invalid email address"),
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
phone_number: z.string().optional(),
password: z.string().min(8, "Password must be at least 8 characters"),
password2: z.string().min(8, "Password confirmation is required"),
})
.refine((data) => data.password === data.password2, {
message: "Passwords do not match",
path: ["password2"],
});
export type RegisterInput = z.infer<typeof registerSchema>;
// Verify OTP Schema
export const verifyOtpSchema = z.object({
email: z.string().email("Invalid email address"),
otp: z.string().min(6, "OTP must be 6 digits").max(6, "OTP must be 6 digits"),
});
export type VerifyOtpInput = z.infer<typeof verifyOtpSchema>;
// Login Schema
export const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(1, "Password is required"),
});
export type LoginInput = z.infer<typeof loginSchema>;
// Resend OTP Schema
export const resendOtpSchema = z.object({
email: z.string().email("Invalid email address"),
context: z.enum(["registration", "password_reset"]).optional(),
});
export type ResendOtpInput = z.infer<typeof resendOtpSchema>;
// Forgot Password Schema
export const forgotPasswordSchema = z.object({
email: z.string().email("Invalid email address"),
});
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
// Verify Password Reset OTP Schema
export const verifyPasswordResetOtpSchema = z.object({
email: z.string().email("Invalid email address"),
otp: z.string().min(6, "OTP must be 6 digits").max(6, "OTP must be 6 digits"),
});
export type VerifyPasswordResetOtpInput = z.infer<typeof verifyPasswordResetOtpSchema>;
// Reset Password Schema
export const resetPasswordSchema = z
.object({
email: z.string().email("Invalid email address"),
otp: z.string().min(6, "OTP must be 6 digits").max(6, "OTP must be 6 digits"),
new_password: z.string().min(8, "Password must be at least 8 characters"),
confirm_password: z.string().min(8, "Password confirmation is required"),
})
.refine((data) => data.new_password === data.confirm_password, {
message: "Passwords do not match",
path: ["confirm_password"],
});
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
// Token Refresh Schema
export const tokenRefreshSchema = z.object({
refresh: z.string().min(1, "Refresh token is required"),
});
export type TokenRefreshInput = z.infer<typeof tokenRefreshSchema>;

53
middleware.ts Normal file
View File

@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Get tokens from cookies
const accessToken = request.cookies.get("auth_access_token")?.value;
const userStr = request.cookies.get("auth_user")?.value;
const isAuthenticated = !!accessToken;
let isAdmin = false;
if (userStr) {
try {
const user = JSON.parse(userStr);
isAdmin = user.is_admin === true;
} catch {
// Invalid user data
}
}
// Protected routes
const isProtectedRoute = pathname.startsWith("/user") || pathname.startsWith("/admin");
const isAdminRoute = pathname.startsWith("/admin");
const isAuthRoute = pathname.startsWith("/login") || pathname.startsWith("/signup");
// Redirect unauthenticated users away from protected routes
if (isProtectedRoute && !isAuthenticated) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
// Redirect authenticated users away from auth routes
if (isAuthRoute && isAuthenticated) {
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));
}
return NextResponse.next();
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};

View File

@ -17,10 +17,12 @@
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.90.10",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"input-otp": "^1.4.2",
"lucide-react": "^0.552.0", "lucide-react": "^0.552.0",
"next": "16.0.1", "next": "16.0.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@ -28,7 +30,8 @@
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1",
"zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View File

@ -23,6 +23,9 @@ importers:
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.2.4 specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.2)(react@19.2.0) version: 1.2.4(@types/react@19.2.2)(react@19.2.0)
'@tanstack/react-query':
specifier: ^5.90.10
version: 5.90.10(react@19.2.0)
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@ -35,6 +38,9 @@ importers:
framer-motion: framer-motion:
specifier: ^12.23.24 specifier: ^12.23.24
version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
lucide-react: lucide-react:
specifier: ^0.552.0 specifier: ^0.552.0
version: 0.552.0(react@19.2.0) version: 0.552.0(react@19.2.0)
@ -59,6 +65,9 @@ importers:
tailwind-merge: tailwind-merge:
specifier: ^3.3.1 specifier: ^3.3.1
version: 3.3.1 version: 3.3.1
zod:
specifier: ^4.1.12
version: 4.1.12
devDependencies: devDependencies:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4 specifier: ^4
@ -890,6 +899,14 @@ packages:
'@tailwindcss/postcss@4.1.16': '@tailwindcss/postcss@4.1.16':
resolution: {integrity: sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==} resolution: {integrity: sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==}
'@tanstack/query-core@5.90.10':
resolution: {integrity: sha512-EhZVFu9rl7GfRNuJLJ3Y7wtbTnENsvzp+YpcAV7kCYiXni1v8qZh++lpw4ch4rrwC0u/EZRnBHIehzCGzwXDSQ==}
'@tanstack/react-query@5.90.10':
resolution: {integrity: sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==}
peerDependencies:
react: ^18 || ^19
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@ -1633,6 +1650,12 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'} engines: {node: '>=0.8.19'}
input-otp@1.4.2:
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
internal-slot@1.1.0: internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -3193,6 +3216,13 @@ snapshots:
postcss: 8.5.6 postcss: 8.5.6
tailwindcss: 4.1.16 tailwindcss: 4.1.16
'@tanstack/query-core@5.90.10': {}
'@tanstack/react-query@5.90.10(react@19.2.0)':
dependencies:
'@tanstack/query-core': 5.90.10
react: 19.2.0
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -4098,6 +4128,11 @@ snapshots:
imurmurhash@0.1.4: {} imurmurhash@0.1.4: {}
input-otp@1.4.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
internal-slot@1.1.0: internal-slot@1.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0