From 8c6e326bade6b6fd2ddd1475914c04ba6e82423a Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 26 Apr 2026 01:27:17 +0800 Subject: [PATCH] feat: add login, forgot password, and reset password forms --- app/(auth)/forgot-password/page.tsx | 5 + app/(auth)/login/page.tsx | 5 + app/(auth)/reset-password/page.tsx | 21 ++ components/auth/forgot-password-form.tsx | 96 ++++++++++ components/auth/login-form.tsx | 234 +++++++++++++++++++++++ components/auth/reset-password-form.tsx | 222 +++++++++++++++++++++ 6 files changed, 583 insertions(+) create mode 100644 app/(auth)/forgot-password/page.tsx create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(auth)/reset-password/page.tsx create mode 100644 components/auth/forgot-password-form.tsx create mode 100644 components/auth/login-form.tsx create mode 100644 components/auth/reset-password-form.tsx diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..71eeeed --- /dev/null +++ b/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,5 @@ +import ForgotPasswordForm from "@/components/auth/forgot-password-form" + +export default function ForgotPasswordPage() { + return +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..ab21ea4 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,5 @@ +import LoginForm from "@/components/auth/login-form" + +export default function LoginPage() { + return +} diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..98ccaed --- /dev/null +++ b/app/(auth)/reset-password/page.tsx @@ -0,0 +1,21 @@ +import { Suspense } from "react" +import ResetPasswordForm from "@/components/auth/reset-password-form" +import { redirect } from "next/navigation" + +interface PageProps { + searchParams: Promise<{ email?: string }> +} + +export default async function ResetPasswordPage({ searchParams }: PageProps) { + const { email } = await searchParams + + if (!email) { + redirect("/forgot-password") + } + + return ( + + + + ) +} diff --git a/components/auth/forgot-password-form.tsx b/components/auth/forgot-password-form.tsx new file mode 100644 index 0000000..646d8d4 --- /dev/null +++ b/components/auth/forgot-password-form.tsx @@ -0,0 +1,96 @@ +"use client" + +import { useState } from "react" +import { Lock, Loader2, ArrowLeft } from "lucide-react" +import { authClient } from "@/lib/auth-client" +import { + forgotPasswordSchema, + type ForgotPasswordInput, +} from "@/lib/auth/validation" +import Link from "next/link" +import { useRouter } from "next/navigation" + +export default function ForgotPasswordForm() { + const router = useRouter() + const [email, setEmail] = useState("") + const [error, setError] = useState("") + const [isLoading, setIsLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + + const result = forgotPasswordSchema.safeParse({ email }) + if (!result.success) { + setError(result.error.issues[0].message) + return + } + + setIsLoading(true) + const { error } = await authClient.emailOtp.requestPasswordReset({ + email, + }) + setIsLoading(false) + + if (error) { + setError( + error.message || "Something went wrong. Please try again.", + ) + return + } + + // Redirect to reset password page with email + router.push(`/reset-password?email=${encodeURIComponent(email)}`) + } + + return ( +
+
+ +

+ Forgot your password? +

+

+ Enter your email and we'll send you a verification code to + reset your password. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="w-full px-3 py-2.5 rounded-lg border border-white/10 bg-white/[0.03] text-[#ebe4f1] text-sm placeholder:text-[#ebe4f1]/40 outline-none focus:border-[#eb3779] focus:ring-2 focus:ring-[#eb3779]/50 transition-colors" + /> +
+ + + + + + Back to sign in + + + ) +} diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx new file mode 100644 index 0000000..c9d06cb --- /dev/null +++ b/components/auth/login-form.tsx @@ -0,0 +1,234 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { Loader2 } from "lucide-react" +import { authClient } from "@/lib/auth-client" +import { + loginEmailSchema, + loginSchema, + type LoginInput, +} from "@/lib/auth/validation" +import SocialButtons from "./social-buttons" +import Link from "next/link" +import { useRouter } from "next/navigation" + +export default function LoginForm() { + const router = useRouter() + const [phase, setPhase] = useState<"email" | "password">("email") + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState("") + const [isLoading, setIsLoading] = useState(false) + + // Preload passkeys for conditional UI + useEffect(() => { + if (phase === "password" && "PublicKeyCredential" in window) { + authClient.signIn.passkey({ autoFill: true }) + } + }, [phase]) + + const handleEmailSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + + const result = loginEmailSchema.safeParse({ email }) + if (!result.success) { + setError(result.error.issues[0].message) + return + } + + // Check if email exists by attempting sign-in with empty password + setIsLoading(true) + const { error } = await authClient.signIn.email( + { email, password: "" }, + { + onError: () => { + // Silently handle - we check the error type below + }, + }, + ) + setIsLoading(false) + + if (error) { + // If error is about invalid credentials, email exists but password is wrong + // If error is about user not found, email doesn't exist + if ( + error.message?.toLowerCase().includes("not found") || + error.message?.toLowerCase().includes("invalid email") || + error.status === 404 + ) { + setError( + "No account found with this email.", + ) + return + } + // Email exists, move to password phase + setPhase("password") + return + } + + // Shouldn't reach here with empty password, but handle it + setPhase("password") + } + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + + const result = loginSchema.safeParse({ email, password }) + if (!result.success) { + setError(result.error.issues[0].message) + return + } + + setIsLoading(true) + const { error } = await authClient.signIn.email({ email, password }) + setIsLoading(false) + + if (error) { + setError(error.message || "Invalid credentials. Please try again.") + return + } + + router.push("/") + } + + const handleChangeEmail = () => { + setPhase("email") + setPassword("") + setError("") + } + + return ( +
+
+

+ Welcome back +

+

+ {phase === "email" + ? "Sign in to DeckyVault" + : `Signing in as `} + {phase === "password" && ( + <> + {email} + {" "} + + + )} +

+
+ + + +
+
+ + or continue with email + +
+
+ + {error && ( +
+ {error} + {error.includes("No account found") && ( + <> + {" "} + + Create one → + + + )} +
+ )} + + {phase === "email" ? ( +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + autoComplete="username webauthn" + className="w-full px-3 py-2.5 rounded-lg border border-white/10 bg-white/[0.03] text-[#ebe4f1] text-sm placeholder:text-[#ebe4f1]/40 outline-none focus:border-[#eb3779] focus:ring-2 focus:ring-[#eb3779]/50 transition-colors" + /> +
+ +
+ ) : ( +
+
+ + setPassword(e.target.value)} + placeholder="Enter your password" + autoComplete="current-password webauthn" + autoFocus + className="w-full px-3 py-2.5 rounded-lg border border-white/10 bg-white/[0.03] text-[#ebe4f1] text-sm placeholder:text-[#ebe4f1]/40 outline-none focus:border-[#eb3779] focus:ring-2 focus:ring-[#eb3779]/50 transition-colors" + /> +
+
+ + Forgot password? + +
+ + {/* Passkey hint */} +
+

+ Your browser may offer to sign in with a passkey +

+
+
+ )} + +

+ Don't have an account?{" "} + + Create one + +

+
+ ) +} diff --git a/components/auth/reset-password-form.tsx b/components/auth/reset-password-form.tsx new file mode 100644 index 0000000..6fa25ac --- /dev/null +++ b/components/auth/reset-password-form.tsx @@ -0,0 +1,222 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { + Mail, + CheckCircle2, + Loader2, + ArrowLeft, +} from "lucide-react" +import { authClient } from "@/lib/auth-client" +import { + resetPasswordSchema, + type ResetPasswordInput, +} from "@/lib/auth/validation" +import OtpInput from "./otp-input" +import PasswordStrengthMeter from "./password-strength" +import Link from "next/link" + +interface ResetPasswordFormProps { + email: string +} + +export default function ResetPasswordForm({ + email, +}: ResetPasswordFormProps) { + const [otp, setOtp] = useState("") + const [newPassword, setNewPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [error, setError] = useState("") + const [isLoading, setIsLoading] = useState(false) + const [isSuccess, setIsSuccess] = useState(false) + const [resendTimer, setResendTimer] = useState(300) + const [canResend, setCanResend] = useState(false) + + // Countdown timer + useEffect(() => { + if (resendTimer <= 0) { + setCanResend(true) + return + } + const interval = setInterval(() => { + setResendTimer((prev) => prev - 1) + }, 1000) + return () => clearInterval(interval) + }, [resendTimer]) + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, "0")}` + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + + const result = resetPasswordSchema.safeParse({ + otp, + newPassword, + confirmPassword, + }) + if (!result.success) { + setError(result.error.issues[0].message) + return + } + + setIsLoading(true) + const { error } = await authClient.resetPassword({ + newPassword, + otp, + }) + setIsLoading(false) + + if (error) { + setError( + error.code === "TOO_MANY_ATTEMPTS" + ? "Too many attempts. Please request a new code." + : error.message || "Failed to reset password.", + ) + return + } + + setIsSuccess(true) + } + + const handleResend = async () => { + setError("") + await authClient.emailOtp.requestPasswordReset({ email }) + setResendTimer(300) + setCanResend(false) + } + + if (isSuccess) { + return ( +
+ +

+ Password reset successful +

+

+ Your password has been updated. You can now sign in with + your new password. +

+ + Sign in + +
+ ) + } + + return ( +
+
+ +

+ Check your email +

+

+ We sent a 6-digit code to{" "} + {email} +

+
+ +
+ + +
+ +
+ {canResend ? ( + + ) : ( + + Resend code in{" "} + + {formatTime(resendTimer)} + + + )} +
+ +
+
+ + then set new password + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + setNewPassword(e.target.value)} + placeholder="Min. 10 characters" + className="w-full px-3 py-2.5 rounded-lg border border-white/10 bg-white/[0.03] text-[#ebe4f1] text-sm placeholder:text-[#ebe4f1]/40 outline-none focus:border-[#eb3779] focus:ring-2 focus:ring-[#eb3779]/50 transition-colors" + /> + +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Re-enter password" + className="w-full px-3 py-2.5 rounded-lg border border-white/10 bg-white/[0.03] text-[#ebe4f1] text-sm placeholder:text-[#ebe4f1]/40 outline-none focus:border-[#eb3779] focus:ring-2 focus:ring-[#eb3779]/50 transition-colors" + /> + {confirmPassword && + newPassword !== confirmPassword && ( +

+ Passwords do not match +

+ )} +
+ + + + + + Back to sign in + + + ) +}