feat/authentication #21
@ -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
450
app/(auth)/signup/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
const result = await login(loginData);
|
||||||
|
|
||||||
|
if (result.tokens && result.user) {
|
||||||
|
toast.success("Login successful!");
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
setLoginLoading(false);
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onLoginSuccess();
|
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'}`}
|
||||||
|
/>
|
||||||
|
</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
|
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>
|
</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...
|
||||||
|
|||||||
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
77
components/ui/input-otp.tsx
Normal file
77
components/ui/input-otp.tsx
Normal 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 }
|
||||||
@ -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
178
hooks/useAuth.ts
Normal 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
289
lib/actions/auth.ts
Normal 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
28
lib/api_urls.ts
Normal 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
48
lib/models/auth.ts
Normal 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
80
lib/schema/auth.ts
Normal 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
53
middleware.ts
Normal 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)$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user