fix(frontend): password reset via server callback (#10303)

## Changes 🏗️

### Root Cause

With httpOnly cookies, the Supabase client can't automatically exchange
password reset codes for sessions client-side because it can't access
the secure cookies 🍪 ( _which is a good thing_ ).

Previously, when users clicked email reset links, the Supabase client on
the browser would automatically handle the code exchange, but
with`httpOnly`, this is not possible because the Supabase browser client
does not have access to session info, so it fails silently 🥵

### Solution

Moved password reset code exchange to server-side middleware that can
access `httpOnly` cookies and properly create authenticated sessions.

### Code Changes

**`middleware.ts`**
- intercepts `/reset-password` URLs containing `code` parameter
- uses helper function to exchange code for session server-side
- redirects with error parameters if exchange fails
- moved `getUser()` call to avoid middleware timing issues

**`reset-password/page.tsx`**
- added toast notifications for password reset errors
- checks URL parameters for error messages on page load

## Checklist 📋

### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Password reset emails send successfully
  - [x] Valid reset codes exchange for sessions server-side
  - [x] Invalid/expired codes show error messages via toast
  - [x] Successfully authenticated users can change passwords
  - [x] URL parameters are cleaned up after error display
  - [x] Middleware doesn't break normal authentication flows
  
 ### For configuration changes:

For this to work we need to configure Supabase with the new
password-reset redirect URL.
```
/api/auth/callback/reset-password
```
- [x] Already added in Supabase dev
- [ ] We need to add it on Supabase prod
This commit is contained in:
Ubbe
2025-07-04 16:09:13 +04:00
committed by GitHub
parent 358ce1d258
commit 01950ccc42
5 changed files with 106 additions and 10 deletions

View File

@@ -27,7 +27,7 @@ export async function sendResetEmail(email: string, turnstileToken: string) {
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/reset-password`,
redirectTo: `${origin}/api/auth/callback/reset-password`,
});
if (error) {

View File

@@ -18,18 +18,23 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import LoadingBox from "@/components/ui/loading";
import { useToast } from "@/components/ui/use-toast";
import { useTurnstile } from "@/hooks/useTurnstile";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { getBehaveAs } from "@/lib/utils";
import { changePasswordFormSchema, sendEmailFormSchema } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { changePassword, sendResetEmail } from "./actions";
export default function ResetPasswordPage() {
const { supabase, user, isUserLoading } = useSupabase();
const { toast } = useToast();
const searchParams = useSearchParams();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
const [isError, setIsError] = useState(false);
@@ -37,6 +42,21 @@ export default function ResetPasswordPage() {
const [sendEmailCaptchaKey, setSendEmailCaptchaKey] = useState(0);
const [changePasswordCaptchaKey, setChangePasswordCaptchaKey] = useState(0);
useEffect(() => {
const error = searchParams.get("error");
if (error) {
toast({
title: "Password Reset Failed",
description: error,
variant: "destructive",
});
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete("error");
router.replace(newUrl.pathname + newUrl.search);
}
}, [searchParams, toast, router]);
const sendEmailTurnstile = useTurnstile({
action: "reset_password",
autoVerify: false,

View File

@@ -0,0 +1,41 @@
import { exchangePasswordResetCode } from "@/lib/supabase/helpers";
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const origin =
process.env.NEXT_PUBLIC_FRONTEND_BASE_URL || "http://localhost:3000";
if (!code) {
return NextResponse.redirect(
`${origin}/reset-password?error=Missing verification code`,
);
}
try {
const supabase = await getServerSupabase();
if (!supabase) {
return NextResponse.redirect(
`${origin}/reset-password?error=no-auth-client`,
);
}
const result = await exchangePasswordResetCode(supabase, code);
if (!result.success) {
return NextResponse.redirect(
`${origin}/reset-password?error=${encodeURIComponent(result.error || "Password reset failed")}`,
);
}
return NextResponse.redirect(`${origin}/reset-password`);
} catch (error) {
console.error("Password reset callback error:", error);
return NextResponse.redirect(
`${origin}/reset-password?error=Password reset failed`,
);
}
}

View File

@@ -1,4 +1,5 @@
import { type CookieOptions } from "@supabase/ssr";
import { SupabaseClient } from "@supabase/supabase-js";
// Detect if we're in a Playwright test environment
const isTest = process.env.NEXT_PUBLIC_PW_TEST === "true";
@@ -98,3 +99,38 @@ export function setupSessionEventListeners(
},
};
}
export interface CodeExchangeResult {
success: boolean;
error?: string;
}
export async function exchangePasswordResetCode(
supabase: SupabaseClient<any, "public", any>,
code: string,
): Promise<CodeExchangeResult> {
try {
const { data, error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
return {
success: false,
error: error.message,
};
}
if (!data.session) {
return {
success: false,
error: "Failed to create session",
};
}
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}

View File

@@ -43,18 +43,17 @@ export async function updateSession(request: NextRequest) {
},
);
const userResponse = await supabase.auth.getUser();
const user = userResponse.data.user;
const userRole = user?.role;
const url = request.nextUrl.clone();
const pathname = request.nextUrl.pathname;
// IMPORTANT: Avoid writing any logic between createServerClient and
// supabase.auth.getUser(). A simple mistake could make it very hard to debug
// issues with users being randomly logged out.
const {
data: { user },
} = await supabase.auth.getUser();
const userRole = user?.role;
const url = request.nextUrl.clone();
const pathname = request.nextUrl.pathname;
// AUTH REDIRECTS
// 1. Check if user is not authenticated but trying to access protected content
if (!user) {