From fa2d797918f438d30b32e4ce15aec2774f47ae3b Mon Sep 17 00:00:00 2001 From: Arnaud Date: Wed, 1 Jul 2026 08:09:37 +0200 Subject: [PATCH] feat(auth): login, signup, email verification, Better Auth routes --- apps/web/app/(auth)/forgot-password/page.tsx | 68 +++++++++++ apps/web/app/(auth)/layout.tsx | 7 ++ apps/web/app/(auth)/login/page.tsx | 120 +++++++++++++++++++ apps/web/app/(auth)/reset-password/page.tsx | 79 ++++++++++++ apps/web/app/(auth)/signup/page.tsx | 86 +++++++++++++ apps/web/app/api/auth/[...all]/route.ts | 4 + 6 files changed, 364 insertions(+) create mode 100644 apps/web/app/(auth)/forgot-password/page.tsx create mode 100644 apps/web/app/(auth)/layout.tsx create mode 100644 apps/web/app/(auth)/login/page.tsx create mode 100644 apps/web/app/(auth)/reset-password/page.tsx create mode 100644 apps/web/app/(auth)/signup/page.tsx create mode 100644 apps/web/app/api/auth/[...all]/route.ts diff --git a/apps/web/app/(auth)/forgot-password/page.tsx b/apps/web/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..ad2c619 --- /dev/null +++ b/apps/web/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { useTranslations } from "next-intl"; +import { authClient } from "@/lib/auth/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function ForgotPasswordPage() { + const t = useTranslations("auth"); + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [sent, setSent] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + const { error } = await authClient.requestPasswordReset({ + email, + redirectTo: "/reset-password", + }); + setLoading(false); + if (error) { + toast.error(error.message ?? "Failed to send reset email"); + } else { + setSent(true); + } + } + + return ( + + + {t("forgotPasswordTitle")} + + {sent ? t("forgotPasswordSent") : t("forgotPasswordDescription")} + + + {sent ? ( + +

+ {t("forgotPasswordSentDescription", { email })} +

+
+ ) : ( +
+ +
+ + setEmail(e.target.value)} required /> +
+ +
+
+ )} + + + {t("backToSignIn")} + + +
+ ); +} diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx new file mode 100644 index 0000000..f3cbe44 --- /dev/null +++ b/apps/web/app/(auth)/layout.tsx @@ -0,0 +1,7 @@ +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx new file mode 100644 index 0000000..819a1db --- /dev/null +++ b/apps/web/app/(auth)/login/page.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { useTranslations } from "next-intl"; +import { authClient } from "@/lib/auth/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; + +export default function LoginPage() { + const router = useRouter(); + const t = useTranslations("auth"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [unverified, setUnverified] = useState(false); + const [resending, setResending] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setUnverified(false); + setLoading(true); + const { error } = await authClient.signIn.email({ email, password, callbackURL: "/recipes" }); + setLoading(false); + if (error) { + if (error.code === "EMAIL_NOT_VERIFIED") { + setUnverified(true); + } else { + toast.error(error.message ?? "Sign in failed"); + } + } else { + router.push("/recipes"); + } + } + + async function resendVerification() { + setResending(true); + const { error } = await authClient.sendVerificationEmail({ + email, + callbackURL: "/recipes", + }); + setResending(false); + if (error) { + toast.error(error.message ?? "Failed to resend"); + } else { + toast.success("Verification email sent — check your inbox"); + setUnverified(false); + } + } + + async function handleGoogle() { + await authClient.signIn.social({ provider: "google", callbackURL: "/recipes" }); + } + + return ( + + + Epicure + {t("signInTitle")} + +
+ + +
+ + {t("or")} + +
+
+ + { setEmail(e.target.value); setUnverified(false); }} required /> +
+
+
+ + + {t("forgotPassword")} + +
+ setPassword(e.target.value)} required /> +
+ + {unverified && ( +
+

{t("emailNotVerified")}

+

{t("checkInboxVerification")}

+ +
+ )} + + +
+
+ +

+ {t("noAccount")}{" "} + {t("signUp")} +

+
+
+ ); +} diff --git a/apps/web/app/(auth)/reset-password/page.tsx b/apps/web/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..ac71eee --- /dev/null +++ b/apps/web/app/(auth)/reset-password/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { toast } from "sonner"; +import { useTranslations } from "next-intl"; +import { authClient } from "@/lib/auth/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import Link from "next/link"; + +function ResetPasswordForm() { + const router = useRouter(); + const t = useTranslations("auth"); + const searchParams = useSearchParams(); + const token = searchParams.get("token") ?? ""; + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (password !== confirm) { toast.error("Passwords don't match"); return; } + setLoading(true); + const { error } = await authClient.resetPassword({ newPassword: password, token }); + setLoading(false); + if (error) { + toast.error(error.message ?? "Reset failed — link may have expired"); + } else { + toast.success("Password updated"); + router.push("/login"); + } + } + + if (!token) { + return

{t("invalidToken")}

; + } + + return ( +
+ +
+ + setPassword(e.target.value)} required minLength={8} /> +
+
+ + setConfirm(e.target.value)} required minLength={8} /> +
+ +
+
+ ); +} + +export default function ResetPasswordPage() { + const t = useTranslations("auth"); + + return ( + + + {t("resetPasswordTitle")} + {t("resetPasswordDescription")} + +

{t("updatingPassword")}

}> + +
+ + + {t("backToSignIn")} + + +
+ ); +} diff --git a/apps/web/app/(auth)/signup/page.tsx b/apps/web/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..6ebce31 --- /dev/null +++ b/apps/web/app/(auth)/signup/page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { useTranslations } from "next-intl"; +import { authClient } from "@/lib/auth/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; + +export default function SignupPage() { + const router = useRouter(); + const t = useTranslations("auth"); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + const { error } = await authClient.signUp.email({ + name, + email, + password, + callbackURL: "/recipes", + }); + setLoading(false); + if (error) { + toast.error(error.message ?? "Sign up failed"); + } else { + toast.success("Account created — check your email to verify"); + router.push("/login"); + } + } + + async function handleGoogle() { + await authClient.signIn.social({ provider: "google", callbackURL: "/recipes" }); + } + + return ( + + + {t("signUpTitle")} + {t("signUpSubtitle")} + +
+ + +
+ + {t("or")} + +
+
+ + setName(e.target.value)} required /> +
+
+ + setEmail(e.target.value)} required /> +
+
+ + setPassword(e.target.value)} required minLength={8} /> +
+ +
+
+ +

+ {t("alreadyHaveAccount")}{" "} + {t("signIn")} +

+
+
+ ); +} diff --git a/apps/web/app/api/auth/[...all]/route.ts b/apps/web/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..e3228bc --- /dev/null +++ b/apps/web/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth/server"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth);