Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions app/(marketing)/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container className="flex min-h-[calc(100dvh-4rem)] items-center justify-center py-16">
<div className="w-full max-w-md">
<div className="rounded-xl border border-border-subtle bg-surface-1 p-7 shadow-elev-2 sm:p-8">
<div className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight text-primary">
Reset your password
</h1>
<p className="mt-1.5 text-sm text-secondary">
Enter the email tied to your workspace and we’ll send you a link to set a new
password.
</p>
</div>
<ForgotPasswordForm />
</div>
</div>
</Container>
);
}
36 changes: 36 additions & 0 deletions app/(marketing)/reset-password/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container className="flex min-h-[calc(100dvh-4rem)] items-center justify-center py-16">
<div className="w-full max-w-md">
<div className="rounded-xl border border-border-subtle bg-surface-1 p-7 shadow-elev-2 sm:p-8">
<div className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight text-primary">
Set a new password
</h1>
<p className="mt-1.5 text-sm text-secondary">
Choose a new password to finish signing back in.
</p>
</div>
<Suspense fallback={null}>
<ResetPasswordForm />
</Suspense>
</div>
</div>
</Container>
);
}
103 changes: 103 additions & 0 deletions components/auth/ForgotPasswordForm.tsx
Original file line number Diff line number Diff line change
@@ -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<string | undefined>(undefined);
const [status, setStatus] = useState<"idle" | "submitting" | "sent" | "error">("idle");
const [submitError, setSubmitError] = useState<string | null>(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 (
<div className="space-y-5">
<Banner variant="info" title="Check your inbox">
If an account exists for <span className="text-primary">{email.trim()}</span>, we’ve
sent a link to reset your password. The link expires shortly, so use it soon.
</Banner>
<p className="text-center text-sm text-secondary">
<Link href="/login" className="text-accent underline-offset-2 hover:underline">
Back to sign in
</Link>
</p>
</div>
);
}

const submitting = status === "submitting";

return (
<form onSubmit={handleSubmit} noValidate className="space-y-5">
{submitError && (
<Banner variant="danger" title="Couldn’t send the reset email">
{submitError}
</Banner>
)}

<Field id="email" label="Work email" required error={error}>
<Input
type="email"
inputMode="email"
autoComplete="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
if (error) setError(undefined);
}}
placeholder="you@operator.com"
/>
</Field>

<Button type="submit" size="lg" className="w-full" disabled={submitting}>
{submitting ? "Sending link…" : "Send reset link"}
</Button>

<p className="text-center text-sm text-secondary">
Remembered it?{" "}
<Link href="/login" className="text-accent underline-offset-2 hover:underline">
Back to sign in
</Link>
</p>
</form>
);
}
9 changes: 9 additions & 0 deletions components/auth/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ export function LoginForm({ next }: { next?: string }) {
/>
</Field>

<div className="-mt-2 text-right">
<Link
href="/forgot-password"
className="text-sm text-secondary underline-offset-2 hover:text-primary hover:underline"
>
Forgot your password?
</Link>
</div>

<Button type="submit" size="lg" className="w-full" disabled={submitting}>
{submitting ? "Signing in…" : "Sign in"}
</Button>
Expand Down
127 changes: 127 additions & 0 deletions components/auth/ResetPasswordForm.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="space-y-5">
<Banner variant="danger" title="This reset link isn’t valid">
The link may have expired or already been used. Request a fresh one and try again.
</Banner>
<Button href="/forgot-password" size="lg" className="w-full">
Request a new link
</Button>
</div>
);
}

if (status === "done") {
return (
<div className="space-y-5">
<Banner variant="info" title="Password updated">
Your password has been changed. You can now sign in with your new password.
</Banner>
<Button href="/login" size="lg" className="w-full">
Back to sign in
</Button>
</div>
);
}

const submitting = status === "submitting";

return (
<form onSubmit={handleSubmit} noValidate className="space-y-5">
{submitError && (
<Banner variant="danger" title="Couldn’t reset your password">
{submitError}
</Banner>
)}

<Field id="password" label="New password" hint="At least 8 characters." required error={errors.password}>
<Input
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
if (errors.password) setErrors((p) => ({ ...p, password: undefined }));
}}
placeholder="••••••••"
/>
</Field>

<Field id="confirm" label="Confirm new password" required error={errors.confirm}>
<Input
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => {
setConfirm(e.target.value);
if (errors.confirm) setErrors((p) => ({ ...p, confirm: undefined }));
}}
placeholder="••••••••"
/>
</Field>

<Button type="submit" size="lg" className="w-full" disabled={submitting}>
{submitting ? "Updating…" : "Update password"}
</Button>
</form>
);
}
Loading