mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-09 15:17:59 -05:00
fix(frontend): post login/signup/onboarding redirect clash (#11382)
## Changes 🏗️ ### Issue 1: login/signup redirect conflict There are 2 hooks, both on the login and signup pages, that attempt to call `router.push` once a user logs in or is created. The main offender seems to be this hook: ```tsx useEffect(() => { if (user) router.push("/"); }, [user]); ``` Which is in place on both pages to prevent logged-in users from accessing `/login` or `/signup`. What happens is when a user signs up or logs in, if they need onboarding, there is a `router.push` down the line to redirect them there, which conflicts with the one done in this hook. **Solution** I moved the logic from that hook to the `middleware.ts`, which is a better place for it... It won't conflict anymore with onboarding redirects done in those pages ### Issue 2: onboarding server redirects Potential race condition: both the server component and the client `<OnboardingProvider />` perform redirects. The server component redirects happen first, but if onboarding state changes after mount, the provider can redirect again, causing rapid mount/unmount cycles. **Solution** Make all onboarding redirects central in `/onboarding` which is now a client component do in client redirects only and displaying a spinner while it does so. ## 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] Tested locally login/logout/signup and trying to access `/login` and `/signup` being logged in
This commit is contained in:
@@ -12,22 +12,22 @@ import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { StoreAgentDetails } from "@/lib/autogpt-server-api";
|
||||
import { isEmptyOrWhitespace } from "@/lib/utils";
|
||||
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
|
||||
import { finishOnboarding } from "../6-congrats/actions";
|
||||
import router from "next/router";
|
||||
|
||||
export default function Page() {
|
||||
const { state, updateState } = useOnboarding(4, "INTEGRATIONS");
|
||||
const { state, updateState, completeStep } = useOnboarding(4, "INTEGRATIONS");
|
||||
const [agents, setAgents] = useState<StoreAgentDetails[]>([]);
|
||||
const api = useBackendAPI();
|
||||
|
||||
useEffect(() => {
|
||||
api.getOnboardingAgents().then((agents) => {
|
||||
if (agents.length < 2) {
|
||||
finishOnboarding();
|
||||
completeStep("CONGRATS");
|
||||
router.push("/onboarding");
|
||||
}
|
||||
|
||||
setAgents(agents);
|
||||
});
|
||||
}, [api, setAgents]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Deselect agent if it's not in the list of agents
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"use server";
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export async function finishOnboarding() {
|
||||
const api = new BackendAPI();
|
||||
const onboarding = await api.getUserOnboarding();
|
||||
const listingId = onboarding?.selectedStoreListingVersionId;
|
||||
if (listingId) {
|
||||
const libraryAgent = await api.addMarketplaceAgentToLibrary(listingId);
|
||||
revalidatePath(`/library/agents/${libraryAgent.id}`, "layout");
|
||||
redirect(`/library/agents/${libraryAgent.id}`);
|
||||
} else {
|
||||
revalidatePath("/library", "layout");
|
||||
redirect("/library");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { finishOnboarding } from "./actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
|
||||
import * as party from "party-js";
|
||||
|
||||
export default function Page() {
|
||||
const { completeStep } = useOnboarding(7, "AGENT_INPUT");
|
||||
const router = useRouter();
|
||||
const [showText, setShowText] = useState(false);
|
||||
const [showSubtext, setShowSubtext] = useState(false);
|
||||
const divRef = useRef(null);
|
||||
@@ -31,8 +32,9 @@ export default function Page() {
|
||||
}, 500);
|
||||
|
||||
const timer2 = setTimeout(() => {
|
||||
// Mark CONGRATS as complete - /onboarding page will handle adding agent to library and redirect
|
||||
completeStep("CONGRATS");
|
||||
finishOnboarding();
|
||||
router.push("/onboarding");
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
@@ -40,7 +42,7 @@ export default function Page() {
|
||||
clearTimeout(timer1);
|
||||
clearTimeout(timer2);
|
||||
};
|
||||
}, []);
|
||||
}, [completeStep, router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center bg-violet-100">
|
||||
|
||||
@@ -1,37 +1,90 @@
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { redirect } from "next/navigation";
|
||||
import { finishOnboarding } from "./6-congrats/actions";
|
||||
import { shouldShowOnboarding } from "@/app/api/helpers";
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
|
||||
// Force dynamic rendering to avoid static generation issues with cookies
|
||||
export const dynamic = "force-dynamic";
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const api = useBackendAPI();
|
||||
|
||||
export default async function OnboardingPage() {
|
||||
const api = new BackendAPI();
|
||||
const isOnboardingEnabled = await shouldShowOnboarding();
|
||||
useEffect(() => {
|
||||
async function redirectToStep() {
|
||||
try {
|
||||
// Check if onboarding is enabled
|
||||
const isEnabled = await api.isOnboardingEnabled();
|
||||
if (!isEnabled) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOnboardingEnabled) {
|
||||
redirect("/marketplace");
|
||||
}
|
||||
const onboarding = await api.getUserOnboarding();
|
||||
|
||||
const onboarding = await api.getUserOnboarding();
|
||||
// Handle completed onboarding
|
||||
if (onboarding.completedSteps.includes("GET_RESULTS")) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
// CONGRATS is the last step in intro onboarding
|
||||
if (onboarding.completedSteps.includes("GET_RESULTS"))
|
||||
redirect("/marketplace");
|
||||
else if (onboarding.completedSteps.includes("CONGRATS")) finishOnboarding();
|
||||
else if (onboarding.completedSteps.includes("AGENT_INPUT"))
|
||||
redirect("/onboarding/5-run");
|
||||
else if (onboarding.completedSteps.includes("AGENT_NEW_RUN"))
|
||||
redirect("/onboarding/5-run");
|
||||
else if (onboarding.completedSteps.includes("AGENT_CHOICE"))
|
||||
redirect("/onboarding/5-run");
|
||||
else if (onboarding.completedSteps.includes("INTEGRATIONS"))
|
||||
redirect("/onboarding/4-agent");
|
||||
else if (onboarding.completedSteps.includes("USAGE_REASON"))
|
||||
redirect("/onboarding/3-services");
|
||||
else if (onboarding.completedSteps.includes("WELCOME"))
|
||||
redirect("/onboarding/2-reason");
|
||||
// Handle CONGRATS - add agent to library and redirect
|
||||
if (onboarding.completedSteps.includes("CONGRATS")) {
|
||||
if (onboarding.selectedStoreListingVersionId) {
|
||||
try {
|
||||
const libraryAgent = await api.addMarketplaceAgentToLibrary(
|
||||
onboarding.selectedStoreListingVersionId,
|
||||
);
|
||||
router.push(`/library/agents/${libraryAgent.id}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to add agent to library:", error);
|
||||
router.push("/library");
|
||||
}
|
||||
} else {
|
||||
router.push("/library");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
redirect("/onboarding/1-welcome");
|
||||
// Redirect to appropriate step based on completed steps
|
||||
if (onboarding.completedSteps.includes("AGENT_INPUT")) {
|
||||
router.push("/onboarding/5-run");
|
||||
return;
|
||||
}
|
||||
|
||||
if (onboarding.completedSteps.includes("AGENT_NEW_RUN")) {
|
||||
router.push("/onboarding/5-run");
|
||||
return;
|
||||
}
|
||||
|
||||
if (onboarding.completedSteps.includes("AGENT_CHOICE")) {
|
||||
router.push("/onboarding/5-run");
|
||||
return;
|
||||
}
|
||||
|
||||
if (onboarding.completedSteps.includes("INTEGRATIONS")) {
|
||||
router.push("/onboarding/4-agent");
|
||||
return;
|
||||
}
|
||||
|
||||
if (onboarding.completedSteps.includes("USAGE_REASON")) {
|
||||
router.push("/onboarding/3-services");
|
||||
return;
|
||||
}
|
||||
|
||||
if (onboarding.completedSteps.includes("WELCOME")) {
|
||||
router.push("/onboarding/2-reason");
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: redirect to first step
|
||||
router.push("/onboarding/1-welcome");
|
||||
} catch (error) {
|
||||
console.error("Failed to determine onboarding step:", error);
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
|
||||
redirectToStep();
|
||||
}, [api, router]);
|
||||
|
||||
return <LoadingSpinner size="large" cover />;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import { postV1ResetOnboardingProgress } from "@/app/api/__generated__/endpoints/onboarding/onboarding";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { redirect } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function OnboardingResetPage() {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
postV1ResetOnboardingProgress()
|
||||
@@ -17,7 +18,7 @@ export default function OnboardingResetPage() {
|
||||
variant: "success",
|
||||
});
|
||||
|
||||
redirect("/onboarding/1-welcome");
|
||||
router.push("/onboarding");
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
@@ -26,7 +27,7 @@ export default function OnboardingResetPage() {
|
||||
variant: "destructive",
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
}, [toast, router]);
|
||||
|
||||
return <LoadingSpinner cover />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function computeReturnURL(returnUrl: string | null, result: any) {
|
||||
return returnUrl
|
||||
? returnUrl
|
||||
: (result?.next as string) || (result?.onboarding ? "/onboarding" : "/");
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export default function LoginPage() {
|
||||
turnstile,
|
||||
captchaKey,
|
||||
isLoading,
|
||||
isRedirecting,
|
||||
isCloudEnv,
|
||||
isUserLoading,
|
||||
isGoogleLoading,
|
||||
@@ -30,7 +31,7 @@ export default function LoginPage() {
|
||||
handleCloseNotAllowedModal,
|
||||
} = useLoginPage();
|
||||
|
||||
if (isUserLoading || user) {
|
||||
if (isUserLoading || user || isRedirecting) {
|
||||
return <LoadingLogin />;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ import { environment } from "@/services/environment";
|
||||
import { loginFormSchema, LoginProvider } from "@/types/auth";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import { computeReturnURL } from "./helpers";
|
||||
|
||||
export function useLoginPage() {
|
||||
const { supabase, user, isUserLoading } = useSupabase();
|
||||
@@ -20,6 +21,7 @@ export function useLoginPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
const isCloudEnv = environment.isCloud();
|
||||
const isVercelPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === "preview";
|
||||
|
||||
@@ -42,10 +44,6 @@ export function useLoginPage() {
|
||||
turnstile.reset();
|
||||
}, [turnstile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) router.push("/");
|
||||
}, [user]);
|
||||
|
||||
async function handleProviderLogin(provider: LoginProvider) {
|
||||
setIsGoogleLoading(true);
|
||||
|
||||
@@ -80,7 +78,10 @@ export function useLoginPage() {
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
if (url) window.location.href = url as string;
|
||||
if (url) {
|
||||
setIsRedirecting(true);
|
||||
window.location.href = url as string;
|
||||
}
|
||||
} catch (error) {
|
||||
resetCaptcha();
|
||||
setIsGoogleLoading(false);
|
||||
@@ -143,11 +144,11 @@ export function useLoginPage() {
|
||||
setFeedback(null);
|
||||
|
||||
// Prioritize returnUrl from query params over backend's onboarding logic
|
||||
const next = returnUrl
|
||||
? returnUrl
|
||||
: (result?.next as string) ||
|
||||
(result?.onboarding ? "/onboarding" : "/");
|
||||
if (next) router.push(next);
|
||||
const next = computeReturnURL(returnUrl, result);
|
||||
if (next) {
|
||||
setIsRedirecting(true);
|
||||
router.push(next);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title:
|
||||
@@ -169,6 +170,7 @@ export function useLoginPage() {
|
||||
captchaKey,
|
||||
user,
|
||||
isLoading,
|
||||
isRedirecting,
|
||||
isCloudEnv,
|
||||
isUserLoading,
|
||||
isGoogleLoading,
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function SignupPage() {
|
||||
isLoading,
|
||||
isCloudEnv,
|
||||
isUserLoading,
|
||||
isRedirecting,
|
||||
isGoogleLoading,
|
||||
showNotAllowedModal,
|
||||
isSupabaseAvailable,
|
||||
@@ -40,7 +41,7 @@ export default function SignupPage() {
|
||||
handleCloseNotAllowedModal,
|
||||
} = useSignupPage();
|
||||
|
||||
if (isUserLoading || isLoggedIn) {
|
||||
if (isUserLoading || isLoggedIn || isRedirecting) {
|
||||
return <LoadingSignup />;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { LoginProvider, signupFormSchema } from "@/types/auth";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
@@ -18,7 +18,7 @@ export function useSignupPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
|
||||
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
const isCloudEnv = environment.isCloud();
|
||||
const isVercelPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === "preview";
|
||||
|
||||
@@ -43,10 +43,6 @@ export function useSignupPage() {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) router.push("/");
|
||||
}, [user]);
|
||||
|
||||
async function handleProviderSignup(provider: LoginProvider) {
|
||||
setIsGoogleLoading(true);
|
||||
|
||||
@@ -85,8 +81,11 @@ export function useSignupPage() {
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
if (url) window.location.href = url as string;
|
||||
setFeedback(null);
|
||||
if (url) {
|
||||
setIsRedirecting(true);
|
||||
setFeedback(null);
|
||||
window.location.href = url as string;
|
||||
}
|
||||
} catch (error) {
|
||||
setIsGoogleLoading(false);
|
||||
resetCaptcha();
|
||||
@@ -160,8 +159,12 @@ export function useSignupPage() {
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
const next = (result?.next as string) || "/";
|
||||
router.push(next);
|
||||
|
||||
const next = result?.next || "/";
|
||||
if (next) {
|
||||
setIsRedirecting(true);
|
||||
router.push(next);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
toast({
|
||||
@@ -183,6 +186,7 @@ export function useSignupPage() {
|
||||
captchaKey,
|
||||
isLoggedIn: !!user,
|
||||
isLoading,
|
||||
isRedirecting,
|
||||
isCloudEnv,
|
||||
isUserLoading,
|
||||
isGoogleLoading,
|
||||
|
||||
@@ -14,6 +14,8 @@ export const PROTECTED_PAGES = [
|
||||
|
||||
export const ADMIN_PAGES = ["/admin"] as const;
|
||||
|
||||
export const AUTHENTICATION_PAGES = ["/login", "/signup"] as const;
|
||||
|
||||
export function getCookieSettings(): Partial<CookieOptions> {
|
||||
return {
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
@@ -31,6 +33,12 @@ export function isAdminPage(pathname: string): boolean {
|
||||
return ADMIN_PAGES.some((page) => pathname.startsWith(page));
|
||||
}
|
||||
|
||||
export function isAuthenticationPage(pathname: string): boolean {
|
||||
return AUTHENTICATION_PAGES.some(
|
||||
(page) => pathname === page || pathname.startsWith(`${page}/`),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldRedirectOnLogout(pathname: string): boolean {
|
||||
return isProtectedPage(pathname) || isAdminPage(pathname);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { getCookieSettings, isAdminPage, isProtectedPage } from "./helpers";
|
||||
import {
|
||||
getCookieSettings,
|
||||
isAdminPage,
|
||||
isAuthenticationPage,
|
||||
isProtectedPage,
|
||||
} from "./helpers";
|
||||
import { environment } from "@/services/environment";
|
||||
|
||||
export async function updateSession(request: NextRequest) {
|
||||
@@ -62,7 +67,13 @@ export async function updateSession(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if user is authenticated but lacks admin role when accessing admin pages
|
||||
// 2. Check if user is authenticated but trying to access unauthenticated pages
|
||||
if (user && isAuthenticationPage(pathname)) {
|
||||
url.pathname = "/marketplace";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// 3. Check if user is authenticated but lacks admin role when accessing admin pages
|
||||
if (user && userRole !== "admin" && isAdminPage(pathname)) {
|
||||
url.pathname = "/marketplace";
|
||||
return NextResponse.redirect(url);
|
||||
|
||||
Reference in New Issue
Block a user