From eb8a800eb76e64dedbc3236a9cb905d6bed258c9 Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Sun, 23 Nov 2025 13:29:31 +0000 Subject: [PATCH] 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. --- app/(auth)/login/page.tsx | 109 ++++++++- app/(auth)/signup/page.tsx | 450 ++++++++++++++++++++++++++++++++++++ app/providers.tsx | 22 +- components/LoginDialog.tsx | 201 ++++++++++++---- components/Navbar.tsx | 4 +- components/ui/input-otp.tsx | 77 ++++++ components/ui/toaster.tsx | 25 +- hooks/useAuth.ts | 178 ++++++++++++++ lib/actions/auth.ts | 289 +++++++++++++++++++++++ lib/api_urls.ts | 28 +++ lib/models/auth.ts | 48 ++++ lib/schema/auth.ts | 80 +++++++ middleware.ts | 53 +++++ package.json | 5 +- pnpm-lock.yaml | 35 +++ 15 files changed, 1543 insertions(+), 61 deletions(-) create mode 100644 app/(auth)/signup/page.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 hooks/useAuth.ts create mode 100644 lib/actions/auth.ts create mode 100644 lib/api_urls.ts create mode 100644 lib/models/auth.ts create mode 100644 lib/schema/auth.ts create mode 100644 middleware.ts diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index e67b95f..5aefcdd 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,20 +1,95 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; 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 Image from "next/image"; -import { useRouter } from "next/navigation"; +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 { toast } from "sonner"; export default function Login() { const { theme } = useAppTheme(); const isDark = theme === "dark"; const [showPassword, setShowPassword] = useState(false); const [rememberMe, setRememberMe] = useState(false); + const [formData, setFormData] = useState({ + email: "", + password: "", + }); + const [errors, setErrors] = useState>>({}); 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> = {}; + 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 (
@@ -70,10 +145,7 @@ export default function Login() {
{/* Login Form */} -
{ - e.preventDefault(); - router.push("/"); - }}> + {/* Email Field */}
@@ -98,7 +172,9 @@ export default function Login() { id="password" type={showPassword ? "text" : "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 /> {/* Remember Me & Forgot Password */} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..898cd16 --- /dev/null +++ b/app/(auth)/signup/page.tsx @@ -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({ + first_name: "", + last_name: "", + email: "", + phone_number: "", + password: "", + password2: "", + }); + const [otpData, setOtpData] = useState({ + email: "", + otp: "", + }); + const [errors, setErrors] = useState>>({}); + 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> = {}; + 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> = {}; + 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 ( +
+ {/* Background Image */} +
+ Therapy and counseling session with African American clients + {/* Overlay for better readability */} +
+
+ + {/* Branding - Top Left */} +
+ + Attune Heart Therapy +
+ + {/* Centered White Card - Signup Form */} +
+ {/* Header with Close Button */} +
+
+ {/* Heading */} +

+ {step === "register" ? "Create an account" : "Verify your email"} +

+ {/* Login Prompt */} + {step === "register" && ( +

+ Already have an account?{" "} + + Log in + +

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

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

+ )} +
+ {/* Close Button */} + +
+ + {step === "register" ? ( + /* Registration Form */ + + {/* First Name Field */} +
+ + 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 && ( +

{errors.first_name}

+ )} +
+ + {/* Last Name Field */} +
+ + 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 && ( +

{errors.last_name}

+ )} +
+ + {/* Email Field */} +
+ + 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 + /> +
+ + {/* Phone Field */} +
+ + 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'}`} + /> +
+ + {/* Password Field */} +
+ +
+ 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 + /> + +
+ {errors.password && ( +

{errors.password}

+ )} +
+ + {/* Confirm Password Field */} +
+ +
+ 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 + /> + +
+ {errors.password2 && ( +

{errors.password2}

+ )} +
+ + {/* Submit Button */} + + + ) : ( + /* OTP Verification Form */ +
+
+
+ +
+

+ Check your email +

+

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

+
+
+
+ + {/* OTP Field */} +
+ +
+ handleOtpChange("otp", value)} + aria-invalid={!!errors.otp} + > + + + + + + + + + +
+
+ + {/* Resend OTP */} +
+ +
+ + {/* Submit Button */} + + + {/* Back to registration */} +
+ +
+
+ )} +
+
+ ); +} + diff --git a/app/providers.tsx b/app/providers.tsx index 9707b40..7d67f31 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -2,12 +2,26 @@ import { ThemeProvider } from "../components/ThemeProvider"; import { type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; export function Providers({ children }: { children: ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, // 1 minute + refetchOnWindowFocus: false, + retry: 1, + }, + }, + }) + ); + return ( - - {children} - + + {children} + ); } - diff --git a/components/LoginDialog.tsx b/components/LoginDialog.tsx index b5fa24e..fb6f883 100644 --- a/components/LoginDialog.tsx +++ b/components/LoginDialog.tsx @@ -12,6 +12,10 @@ import { DialogTitle, } from "@/components/ui/dialog"; 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 { open: boolean; @@ -23,58 +27,87 @@ interface LoginDialogProps { export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogProps) { const { theme } = useAppTheme(); const isDark = theme === "dark"; + const router = useRouter(); + const { login, register, loginMutation, registerMutation } = useAuth(); const [isSignup, setIsSignup] = useState(false); - const [loginData, setLoginData] = useState({ + const [loginData, setLoginData] = useState({ email: "", password: "", }); - const [signupData, setSignupData] = useState({ - fullName: "", + const [signupData, setSignupData] = useState({ + first_name: "", + last_name: "", email: "", - phone: "", + phone_number: "", + password: "", + password2: "", }); const [showPassword, setShowPassword] = useState(false); + const [showPassword2, setShowPassword2] = useState(false); const [rememberMe, setRememberMe] = useState(false); - const [loginLoading, setLoginLoading] = useState(false); - const [signupLoading, setSignupLoading] = useState(false); const [error, setError] = useState(null); const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); - setLoginLoading(true); setError(null); + // Validate form + const validation = loginSchema.safeParse(loginData); + if (!validation.success) { + const firstError = validation.error.errors[0]; + setError(firstError.message); + return; + } + try { - // Simulate login API call - await new Promise((resolve) => setTimeout(resolve, 1000)); + const result = await login(loginData); - // After successful login, close dialog and call success callback - setShowPassword(false); - setLoginLoading(false); - onOpenChange(false); - onLoginSuccess(); + if (result.tokens && result.user) { + toast.success("Login successful!"); + setShowPassword(false); + onOpenChange(false); + onLoginSuccess(); + } } catch (err) { - setError("Login failed. Please try again."); - setLoginLoading(false); + const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again."; + setError(errorMessage); + toast.error(errorMessage); } }; const handleSignup = async (e: React.FormEvent) => { e.preventDefault(); - setSignupLoading(true); setError(null); + // Validate form + const validation = registerSchema.safeParse(signupData); + if (!validation.success) { + const firstError = validation.error.errors[0]; + setError(firstError.message); + return; + } + try { - // Simulate signup API call - await new Promise((resolve) => setTimeout(resolve, 1000)); + const result = await register(signupData); - // After successful signup, automatically log in and proceed - setSignupLoading(false); - 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) { - setError("Signup failed. Please try again."); - setSignupLoading(false); + const errorMessage = err instanceof Error ? err.message : "Signup failed. Please try again."; + setError(errorMessage); + toast.error(errorMessage); } }; @@ -87,7 +120,14 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP const handleSwitchToLogin = () => { setIsSignup(false); setError(null); - setSignupData({ fullName: "", email: "", phone: "" }); + setSignupData({ + first_name: "", + last_name: "", + email: "", + phone_number: "", + password: "", + password2: "", + }); }; return ( @@ -127,17 +167,33 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP )} - {/* Full Name Field */} + {/* First Name Field */}
-
+ + {/* Last Name Field */} +
+ + 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'}`} required /> @@ -162,26 +218,89 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP {/* Phone Field */}
setSignupData({ ...signupData, phone: e.target.value })} + value={signupData.phone_number || ""} + 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'}`} - required />
+ {/* Password Field */} +
+ +
+ 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 + /> + +
+
+ + {/* Confirm Password Field */} +
+ +
+ 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 + /> + +
+
+ {/* Submit Button */}