diff --git a/app/(marketing)/forgot-password/page.tsx b/app/(marketing)/forgot-password/page.tsx new file mode 100644 index 0000000..6696750 --- /dev/null +++ b/app/(marketing)/forgot-password/page.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { Container } from "@/components/ui/Container"; +import { ForgotPasswordForm } from "@/components/auth/ForgotPasswordForm"; + +export const metadata: Metadata = { + title: "Reset your password", + description: "Request a link to reset your PetroBrain password.", + robots: { index: false, follow: false }, +}; + +/** + * /forgot-password — request a password-reset link. Lives in the marketing zone alongside + * /login and /signup so the auth flow stays on-theme. The emailed link returns the user to + * /reset-password with a one-time token. + */ +export default function ForgotPasswordPage() { + return ( + +
+
+
+

+ Reset your password +

+

+ Enter the email tied to your workspace and we’ll send you a link to set a new + password. +

+
+ +
+
+
+ ); +} diff --git a/app/(marketing)/reset-password/page.tsx b/app/(marketing)/reset-password/page.tsx new file mode 100644 index 0000000..eb8c66a --- /dev/null +++ b/app/(marketing)/reset-password/page.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { Container } from "@/components/ui/Container"; +import { ResetPasswordForm } from "@/components/auth/ResetPasswordForm"; + +export const metadata: Metadata = { + title: "Set a new password", + description: "Choose a new password for your PetroBrain workspace.", + robots: { index: false, follow: false }, +}; + +/** + * /reset-password — the landing page for the emailed reset link. The form reads the + * one-time `?token` via useSearchParams, so it's wrapped in Suspense (Next requirement). + */ +export default function ResetPasswordPage() { + return ( + +
+
+
+

+ Set a new password +

+

+ Choose a new password to finish signing back in. +

+
+ + + +
+
+
+ ); +} diff --git a/components/auth/ForgotPasswordForm.tsx b/components/auth/ForgotPasswordForm.tsx new file mode 100644 index 0000000..87f16e3 --- /dev/null +++ b/components/auth/ForgotPasswordForm.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Field } from "@/components/ui/Field"; +import { Input } from "@/components/ui/Input"; +import { Button } from "@/components/ui/Button"; +import { Banner } from "@/components/ui/Banner"; +import { authClient } from "@/lib/auth/client"; + +/** + * ForgotPasswordForm — kicks off a password reset via Neon Auth (Better Auth). + * We always show the same "check your inbox" confirmation on success regardless of + * whether the email exists, so the form can't be used to probe which accounts exist. + * `redirectTo` is where the emailed link lands; Better Auth appends `?token=…` there. + */ + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function ForgotPasswordForm() { + const [email, setEmail] = useState(""); + const [error, setError] = useState(undefined); + const [status, setStatus] = useState<"idle" | "submitting" | "sent" | "error">("idle"); + const [submitError, setSubmitError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitError(null); + + if (!EMAIL_RE.test(email.trim())) { + setError("Enter a valid email address."); + document.getElementById("email")?.focus(); + return; + } + setError(undefined); + + setStatus("submitting"); + try { + const res = await authClient.requestPasswordReset({ + email: email.trim(), + redirectTo: `${window.location.origin}/reset-password`, + }); + if (res?.error) throw new Error(res.error.message || "We couldn’t send the reset email."); + setStatus("sent"); + } catch (err) { + setStatus("error"); + setSubmitError(err instanceof Error ? err.message : "We couldn’t send the reset email."); + } + } + + if (status === "sent") { + return ( +
+ + If an account exists for {email.trim()}, we’ve + sent a link to reset your password. The link expires shortly, so use it soon. + +

+ + Back to sign in + +

+
+ ); + } + + const submitting = status === "submitting"; + + return ( +
+ {submitError && ( + + {submitError} + + )} + + + { + setEmail(e.target.value); + if (error) setError(undefined); + }} + placeholder="you@operator.com" + /> + + + + +

+ Remembered it?{" "} + + Back to sign in + +

+
+ ); +} diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx index 6478a9b..43eb19b 100644 --- a/components/auth/LoginForm.tsx +++ b/components/auth/LoginForm.tsx @@ -87,6 +87,15 @@ export function LoginForm({ next }: { next?: string }) { /> +
+ + Forgot your password? + +
+ diff --git a/components/auth/ResetPasswordForm.tsx b/components/auth/ResetPasswordForm.tsx new file mode 100644 index 0000000..02241a3 --- /dev/null +++ b/components/auth/ResetPasswordForm.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Field } from "@/components/ui/Field"; +import { Input } from "@/components/ui/Input"; +import { Button } from "@/components/ui/Button"; +import { Banner } from "@/components/ui/Banner"; +import { authClient } from "@/lib/auth/client"; + +/** + * ResetPasswordForm — completes a reset using the one-time `token` from the emailed link + * (Better Auth puts it on the URL as `?token=…`). On success Neon invalidates the token and + * we send the user to /login to sign in with the new password. A missing/invalid token is + * surfaced up front so the user isn't left filling a form that can't succeed. + */ +export function ResetPasswordForm() { + const searchParams = useSearchParams(); + const token = searchParams.get("token"); + const tokenError = searchParams.get("error"); // Better Auth redirects here with ?error=INVALID_TOKEN + + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [errors, setErrors] = useState<{ password?: string; confirm?: string }>({}); + const [status, setStatus] = useState<"idle" | "submitting" | "done" | "error">("idle"); + const [submitError, setSubmitError] = useState(null); + + const missingToken = !token || tokenError === "INVALID_TOKEN"; + + // If the user lands here with no usable token, focus stays on the call-to-action below. + useEffect(() => { + if (!missingToken) document.getElementById("password")?.focus(); + }, [missingToken]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitError(null); + + const next: { password?: string; confirm?: string } = {}; + if (password.length < 8) next.password = "At least 8 characters."; + if (confirm !== password) next.confirm = "Passwords don’t match."; + if (Object.keys(next).length > 0) { + setErrors(next); + document.getElementById(next.password ? "password" : "confirm")?.focus(); + return; + } + setErrors({}); + + setStatus("submitting"); + try { + const res = await authClient.resetPassword({ newPassword: password, token: token! }); + if (res?.error) throw new Error(res.error.message || "We couldn’t reset your password."); + setStatus("done"); + } catch (err) { + setStatus("error"); + setSubmitError(err instanceof Error ? err.message : "We couldn’t reset your password."); + } + } + + if (missingToken) { + return ( +
+ + The link may have expired or already been used. Request a fresh one and try again. + + +
+ ); + } + + if (status === "done") { + return ( +
+ + Your password has been changed. You can now sign in with your new password. + + +
+ ); + } + + const submitting = status === "submitting"; + + return ( +
+ {submitError && ( + + {submitError} + + )} + + + { + setPassword(e.target.value); + if (errors.password) setErrors((p) => ({ ...p, password: undefined })); + }} + placeholder="••••••••" + /> + + + + { + setConfirm(e.target.value); + if (errors.confirm) setErrors((p) => ({ ...p, confirm: undefined })); + }} + placeholder="••••••••" + /> + + + +
+ ); +}