Skip to content
Open
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ Open [http://localhost:3000](http://localhost:3000) to see the demo.
- `/email-password` - Email + Password authentication demo
- `/google-login` - Google OAuth authentication demo

## Password Reset Flow

The email/password demo ships with a complete Supabase recovery experience:

1. Click **Forgot password?** while in the sign-in view. The client calls `supabase.auth.resetPasswordForEmail` and sends users to `/email-password?password-reset=true`.
2. Supabase emails the reset link. Configure the Auth **Site URL** and allowed **Redirect URLs** in your project so the link is accepted in local/dev/staging environments.
3. Following the link both signs the user in and flags the recovery session. The page detects `#type=recovery` or the `password-reset` query and surfaces the “Choose a new password” form even though a session exists.
4. Submitting that form calls `supabase.auth.updateUser({ password })` and keeps the new session active so users stay logged in.

If you customize the route, update the `redirectTo` option and allowed redirect URLs in Supabase to match.

## Scripts

- `npm run dev` - Start development server
Expand Down
294 changes: 243 additions & 51 deletions app/email-password/EmailPasswordDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,39 +9,84 @@ type EmailPasswordDemoProps = {
user: User | null;
};

type Mode = "signup" | "signin"
type Mode = "signup" | "signin";
type ResetMode = "none" | "request" | "update";

function hasRecoveryParams() {
if (typeof window === "undefined") {
return false;
}
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ""));
if (hashParams.get("type") === "recovery") {
return true;
}
const searchParams = new URLSearchParams(window.location.search);
return searchParams.get("password-reset") === "true";
}

export default function EmailPasswordDemo({ user }: EmailPasswordDemoProps) {
const [mode, setMode] = useState("signup");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [status, setStatus] = useState("");
const [status, setStatus] = useState(() =>
hasRecoveryParams() ? "Enter a new password to finish resetting your account." : ""
);
const [resetMode, setResetMode] = useState<ResetMode>(() =>
hasRecoveryParams() ? "update" : "none"
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Hydration mismatch when recovery params present in URL

The useState initializers for status and resetMode call hasRecoveryParams(), which returns different values during server-side rendering versus client hydration. On the server, window is undefined so it returns false, but on the client with recovery URL params like ?password-reset=true, it returns true. This causes a React hydration mismatch, resulting in console warnings and potential UI flickering when users arrive via a password reset link. The state detection logic would work better if moved entirely into the useEffect that already exists for this purpose.

Fix in Cursor Fix in Web

const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const supabase = getSupabaseBrowserClient();
const [currentUser, setCurrentUser] = useState<User | null>(user);

async function handleSignOut() {
await supabase.auth.signOut();
setCurrentUser(null);
setStatus("Signed out successfully");
setResetMode("none");
setNewPassword("");
setConfirmPassword("");
}

useEffect(() => {
const { data: listener } = supabase.auth.onAuthStateChange(
(_event, session) => {
(event, session) => {
setCurrentUser(session?.user ?? null);
if (event === "PASSWORD_RECOVERY") {
setResetMode("update");
setStatus("Enter a new password to finish resetting your account.");
}
}
);

return () => {
listener?.subscription.unsubscribe();
};
}, [supabase])
}, [supabase]);

useEffect(() => {
if (!hasRecoveryParams() || typeof window === "undefined") {
return;
}
setResetMode("update");
setStatus("Enter a new password to finish resetting your account.");

const searchParams = new URLSearchParams(window.location.search.replace(/^\?/, ""));
if (searchParams.has("password-reset")) {
searchParams.delete("password-reset");
}
window.history.replaceState(
null,
document.title,
`${window.location.pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`
);
}, []);

async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();

if (mode == "signup") {
const { error, data } = await supabase.auth.signUp({
const { error } = await supabase.auth.signUp({
email,
password,
options: {
Expand All @@ -54,7 +99,7 @@ export default function EmailPasswordDemo({ user }: EmailPasswordDemoProps) {
setStatus("Check your inbox to confirm the new account.");
}
} else {
const { error, data } = await supabase.auth.signInWithPassword({
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
Expand All @@ -66,21 +111,105 @@ export default function EmailPasswordDemo({ user }: EmailPasswordDemoProps) {
}
}

async function handlePasswordResetRequest(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/email-password?password-reset=true`,
});

if (error) {
setStatus(error.message);
return;
}

setStatus("Password reset email sent. Check your inbox.");
setResetMode("none");
setMode("signin");
}

async function handlePasswordUpdate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (newPassword.length < 6) {
setStatus("New password must be at least 6 characters.");
return;
}
if (newPassword !== confirmPassword) {
setStatus("Passwords do not match.");
return;
}

const { error } = await supabase.auth.updateUser({ password: newPassword });
if (error) {
setStatus(error.message);
return;
}

setStatus("Password updated. You're now signed in.");
setResetMode("none");
setNewPassword("");
setConfirmPassword("");
}

function startPasswordReset() {
setResetMode("request");
setStatus("");
setMode("signin");
setPassword("");
}

function cancelPasswordReset() {
setResetMode("none");
setMode("signin");
setStatus("");
setNewPassword("");
setConfirmPassword("");
setPassword("");
}

const isResetRequest = resetMode === "request";
const isPasswordUpdate = resetMode === "update";
const handleFormSubmit = isResetRequest
? handlePasswordResetRequest
: isPasswordUpdate
? handlePasswordUpdate
: handleSubmit;
const primaryButtonLabel = isResetRequest
? "Send reset email"
: isPasswordUpdate
? "Update password"
: mode === "signup"
? "Create account"
: "Sign in";
const primaryHeading = isResetRequest
? "Request password reset"
: isPasswordUpdate
? "Choose a new password"
: mode === "signup"
? "Create an account"
: "Welcome back";
const primarySubheading = isResetRequest
? "Enter your account email and we'll send recovery instructions."
: isPasswordUpdate
? "Supabase opened a recovery session—set a new password to finish."
: null;
const badgeLabel = isResetRequest || isPasswordUpdate ? "Recovery" : "Credentials";

return (
<AuthDemoPage
title="Email + Password"
intro="Classic credentials—users enter details, Supabase secures the rest while getSession + onAuthStateChange keep the UI live."
steps={[
"Toggle between sign up and sign in.",
"Submit to watch the session card refresh instantly.",
"Use “Forgot password?” to request a reset and follow the emailed link.",
"Sign out to reset the listener.",
]}
>
{!currentUser && (
{(!currentUser || resetMode !== "none") && (
<>
<form
className="relative overflow-hidden rounded-[32px] border border-emerald-500/30 bg-gradient-to-br from-[#05130d] via-[#04100c] to-[#0c2a21] p-8 text-slate-100 shadow-[0_35px_90px_rgba(2,6,23,0.65)]"
onSubmit={handleSubmit}
onSubmit={handleFormSubmit}
>
<div
className="pointer-events-none absolute -left-4 -top-4 -z-10 h-20 w-28 rounded-full bg-[radial-gradient(circle,_rgba(16,185,129,0.25),_transparent)] blur-lg"
Expand All @@ -97,60 +226,123 @@ export default function EmailPasswordDemo({ user }: EmailPasswordDemoProps) {
<div className="mt-8 flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-200/70">
Credentials
{badgeLabel}
</p>
<h3 className="text-xl font-semibold text-white">
{mode === "signup" ? "Create an account" : "Welcome back"}
{primaryHeading}
</h3>
{primarySubheading && (
<p className="mt-1 text-sm text-emerald-100/80">
{primarySubheading}
</p>
)}
</div>
<div className="flex rounded-full border border-white/10 bg-white/[0.07] p-1 text-xs font-semibold text-slate-300">
{(["signup", "signin"] as Mode[]).map((option) => (
<button
key={option}
type="button"
aria-pressed={mode === option}
onClick={() => setMode(option)}
className={`rounded-full px-4 py-1 transition ${mode === option
? "bg-emerald-500/30 text-white shadow shadow-emerald-500/20"
: "text-slate-400"
}`}
>
{option === "signup" ? "Sign up" : "Sign in"}
</button>
))}
</div>
{resetMode === "none" ? (
<div className="flex rounded-full border border-white/10 bg-white/[0.07] p-1 text-xs font-semibold text-slate-300">
{(["signup", "signin"] as Mode[]).map((option) => (
<button
key={option}
type="button"
aria-pressed={mode === option}
onClick={() => {
setMode(option);
setStatus("");
}}
className={`rounded-full px-4 py-1 transition ${mode === option
? "bg-emerald-500/30 text-white shadow shadow-emerald-500/20"
: "text-slate-400"
}`}
>
{option === "signup" ? "Sign up" : "Sign in"}
</button>
))}
</div>
) : (
<button
type="button"
onClick={cancelPasswordReset}
className="rounded-full border border-white/10 bg-white/[0.07] px-4 py-1 text-xs font-semibold text-slate-200 transition hover:bg-white/15"
>
Back to sign in
</button>
)}
</div>
<div className="mt-6 space-y-4">
<label className="block text-sm font-medium text-slate-200">
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
className="mt-2 w-full rounded-2xl border border-white/10 bg-[#0b1b18] px-3 py-2.5 text-base text-white placeholder-slate-500 shadow-inner shadow-black/30 focus:border-emerald-400 focus:outline-none focus:ring-2 focus:ring-emerald-400/30"
placeholder="you@email.com"
/>
</label>
<label className="block text-sm font-medium text-slate-200">
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
minLength={6}
className="mt-2 w-full rounded-2xl border border-white/10 bg-[#0b1b18] px-3 py-2.5 text-base text-white placeholder-slate-500 shadow-inner shadow-black/30 focus:border-emerald-400 focus:outline-none focus:ring-2 focus:ring-emerald-400/30"
placeholder="At least 6 characters"
/>
</label>
{resetMode !== "update" && (
<label className="block text-sm font-medium text-slate-200">
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
className="mt-2 w-full rounded-2xl border border-white/10 bg-[#0b1b18] px-3 py-2.5 text-base text-white placeholder-slate-500 shadow-inner shadow-black/30 focus:border-emerald-400 focus:outline-none focus:ring-2 focus:ring-emerald-400/30"
placeholder="you@email.com"
autoComplete="email"
/>
</label>
)}
{resetMode === "none" && (
<label className="block text-sm font-medium text-slate-200">
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
minLength={6}
className="mt-2 w-full rounded-2xl border border-white/10 bg-[#0b1b18] px-3 py-2.5 text-base text-white placeholder-slate-500 shadow-inner shadow-black/30 focus:border-emerald-400 focus:outline-none focus:ring-2 focus:ring-emerald-400/30"
placeholder="At least 6 characters"
autoComplete={mode === "signup" ? "new-password" : "current-password"}
/>
</label>
)}
{isPasswordUpdate && (
<>
<label className="block text-sm font-medium text-slate-200">
New password
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
required
minLength={6}
className="mt-2 w-full rounded-2xl border border-white/10 bg-[#0b1b18] px-3 py-2.5 text-base text-white placeholder-slate-500 shadow-inner shadow-black/30 focus:border-emerald-400 focus:outline-none focus:ring-2 focus:ring-emerald-400/30"
placeholder="At least 6 characters"
autoComplete="new-password"
/>
</label>
<label className="block text-sm font-medium text-slate-200">
Confirm new password
<input
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
required
minLength={6}
className="mt-2 w-full rounded-2xl border border-white/10 bg-[#0b1b18] px-3 py-2.5 text-base text-white placeholder-slate-500 shadow-inner shadow-black/30 focus:border-emerald-400 focus:outline-none focus:ring-2 focus:ring-emerald-400/30"
placeholder="Re-enter the new password"
autoComplete="new-password"
/>
</label>
</>
)}
</div>
<button
type="submit"
className="mt-6 inline-flex w-full items-center justify-center rounded-full bg-emerald-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-emerald-900/30 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:bg-emerald-600/40"
>
{mode === "signup" ? "Create account" : "Sign in"}
{primaryButtonLabel}
</button>
{resetMode === "none" && mode === "signin" && (
<button
type="button"
onClick={startPasswordReset}
className="mt-3 w-full text-center text-sm font-medium text-emerald-300 transition hover:text-emerald-200"
>
Forgot password?
</button>
)}
{status && (
<p className="mt-4 text-sm text-slate-300" role="status" aria-live="polite">
{status}
Expand Down Expand Up @@ -213,4 +405,4 @@ export default function EmailPasswordDemo({ user }: EmailPasswordDemoProps) {
</section>
</AuthDemoPage>
);
}
}
Loading