feat: add password reset

This commit is contained in:
Sosokker 2024-12-14 15:28:59 +07:00
parent d9415534fb
commit 6d9cc8a398
5 changed files with 205 additions and 1 deletions

View File

@ -1,3 +1,6 @@
# NEXT
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Supabase Configuration
PROJECT_ID=supabase-project-id
NEXT_PUBLIC_SUPABASE_URL=supabase-project-url

View File

@ -0,0 +1,81 @@
"use client";
import { useRef, useState } from "react";
import Link from "next/link";
import { Card, CardContent, CardFooter, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ChevronLeft, CircleAlert } from "lucide-react";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import toast from "react-hot-toast";
import { createClient } from "@supabase/supabase-js";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [captchaToken, setCaptchaToken] = useState<string | undefined>(undefined);
const captcha = useRef<HCaptcha | null>(null);
const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
const forgotPassword = async (email: string) => {
try {
setLoading(true);
const { error } = await supabase.auth.resetPasswordForEmail(email, {
captchaToken,
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL!}/reset`,
});
if (error) {
toast.error("Failed to send reset link. Please check the email and try again.");
} else {
toast.success("Reset link sent! Check your email.");
}
} catch (e) {
toast.error("An unexpected error occurred. Please try again later.");
} finally {
captcha.current?.resetCaptcha();
setLoading(false);
}
};
return (
<div className="container max-w-screen-xl flex items-center justify-center my-24">
<Card>
<CardHeader className="items-center">
<CircleAlert className="text-red-600 w-12 h-12" />
<CardTitle className="text-2xl font-bold">Forgot Password</CardTitle>
<CardDescription>Enter your email and we&apos;ll send you a link to reset your password.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-y-2 justify-center items-center">
{/* Input field with state binding */}
<Input
id="email"
type="email"
placeholder="example@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Button variant="default" className="w-full" onClick={() => forgotPassword(email)} disabled={loading}>
{loading ? "Sending..." : "Send Reset Link"}
</Button>
<HCaptcha
ref={captcha}
sitekey={process.env.NEXT_PUBLIC_SITEKEY!}
onVerify={(token) => {
setCaptchaToken(token);
}}
/>
</CardContent>
<CardFooter className="text-xs justify-center">
<Link href="/login" className="text-blue-600 hover:text-blue-800 flex justify-center items-center gap-x-2">
<ChevronLeft /> Back to Login
</Link>
</CardFooter>
</Card>
</div>
);
}

View File

@ -23,6 +23,9 @@ export default function Login() {
<LoginForm />
<hr></hr>
<LoginButton />
<Link href={"/auth/forgot"}>
<span className="text-blue-600 hover:text-blue-800 hover:underline">Forget your password?</span>
</Link>
</CardContent>
<CardFooter className="text-xs justify-center">
<span>

117
src/app/reset/page.tsx Normal file
View File

@ -0,0 +1,117 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardFooter, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ListRestart } from "lucide-react";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import { createClient } from "@supabase/supabase-js";
export default function ResetPasswordPage() {
const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
const router = useRouter();
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [resetSuccess, setResetSuccess] = useState(false);
const [failedAttempts, setFailedAttempts] = useState(0);
useEffect(() => {
if (resetSuccess) return;
const { data: authListener } = supabase.auth.onAuthStateChange(async (event) => {
if (event === "PASSWORD_RECOVERY") {
toast.success("Password recovery successful! Please enter your new password.");
setResetSuccess(true);
setFailedAttempts(0);
return;
} else {
setFailedAttempts((prev) => prev + 1);
}
});
// Cleanup listener on unmount
return () => {
authListener?.subscription.unsubscribe();
};
}, [resetSuccess, supabase.auth, router]);
useEffect(() => {
if (failedAttempts >= 3) {
router.push("/");
}
}, [failedAttempts, router]);
const updatePassword = async () => {
if (!newPassword || !confirmPassword) {
toast.error("Please fill out both fields.");
return;
}
if (newPassword !== confirmPassword) {
toast.error("Passwords do not match. Please try again.");
return;
}
try {
setLoading(true);
const { error } = await supabase.auth.updateUser({ password: newPassword });
if (error) {
console.error("Error updating password:", error);
toast.error(error.message || "There was an error updating your password.");
} else {
toast.success("Password updated successfully!");
router.push("/");
}
} catch (e) {
console.error("Unexpected error:", e);
toast.error("An unexpected error occurred. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div className="container max-w-screen-xl flex items-center justify-center my-24">
<Card>
<CardHeader className="items-center">
<ListRestart className="text-blue-600 w-12 h-12" />
<CardTitle className="text-2xl font-bold">Reset Password</CardTitle>
<CardDescription>Enter your new password below to reset it.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-y-4 justify-center items-center">
<Input
id="new-password"
type="password"
placeholder="New password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={failedAttempts >= 3 && !resetSuccess}
/>
<Input
id="confirm-password"
type="password"
placeholder="Confirm new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={failedAttempts >= 3 && !resetSuccess}
/>
<Button
variant="default"
className="w-full"
onClick={updatePassword}
disabled={loading || (failedAttempts >= 3 && !resetSuccess)}
>
{loading ? "Updating..." : "Update Password"}
</Button>
</CardContent>
<CardFooter className="text-xs justify-center">
<p>If you encounter issues, please contact support.</p>
</CardFooter>
</Card>
</div>
);
}

View File

@ -14,6 +14,6 @@ export const config = {
* - favicon.ico (favicon file)
* Feel free to modify this pattern to include more paths.
*/
"/((?!_next/static|_next/image|$|favicon.ico|payment-success|verify|terms|contact|risks|about|privacy|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
"/((?!_next/static|_next/image|$|favicon.ico|payment-success|verify|terms|contact|risks|about|privacy|reset|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};