mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-10 07:38:04 -05:00
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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user