diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts b/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts index b026416ee9..bff2fd0b68 100644 --- a/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts +++ b/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts @@ -2,14 +2,7 @@ import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase"; import BackendAPI from "@/lib/autogpt-server-api"; import { NextResponse } from "next/server"; import { revalidatePath } from "next/cache"; - -async function shouldShowOnboarding() { - const api = new BackendAPI(); - return ( - (await api.isOnboardingEnabled()) && - !(await api.getUserOnboarding()).completedSteps.includes("CONGRATS") - ); -} +import { shouldShowOnboarding } from "@/app/api/helpers"; // Handle the callback to complete the user session login export async function GET(request: Request) { diff --git a/autogpt_platform/frontend/src/app/(platform)/login/actions.ts b/autogpt_platform/frontend/src/app/(platform)/login/actions.ts deleted file mode 100644 index fb53478f0a..0000000000 --- a/autogpt_platform/frontend/src/app/(platform)/login/actions.ts +++ /dev/null @@ -1,96 +0,0 @@ -"use server"; -import BackendAPI from "@/lib/autogpt-server-api"; -import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase"; -import { verifyTurnstileToken } from "@/lib/turnstile"; -import { loginFormSchema, LoginProvider } from "@/types/auth"; -import * as Sentry from "@sentry/nextjs"; -import { revalidatePath } from "next/cache"; -import { redirect } from "next/navigation"; -import { z } from "zod"; - -async function shouldShowOnboarding() { - const api = new BackendAPI(); - return ( - (await api.isOnboardingEnabled()) && - !(await api.getUserOnboarding()).completedSteps.includes("CONGRATS") - ); -} - -export async function login( - values: z.infer, - turnstileToken: string, -) { - return await Sentry.withServerActionInstrumentation("login", {}, async () => { - const supabase = await getServerSupabase(); - const api = new BackendAPI(); - - if (!supabase) { - redirect("/error"); - } - - // Verify Turnstile token if provided - const success = await verifyTurnstileToken(turnstileToken, "login"); - if (!success) { - return "CAPTCHA verification failed. Please try again."; - } - - // We are sure that the values are of the correct type because zod validates the form - const { error } = await supabase.auth.signInWithPassword(values); - - if (error) { - return error.message; - } - - await api.createUser(); - - // Don't onboard if disabled or already onboarded - if (await shouldShowOnboarding()) { - revalidatePath("/onboarding", "layout"); - redirect("/onboarding"); - } - - revalidatePath("/", "layout"); - redirect("/"); - }); -} - -export async function providerLogin(provider: LoginProvider) { - return await Sentry.withServerActionInstrumentation( - "providerLogin", - {}, - async () => { - const supabase = await getServerSupabase(); - - if (!supabase) { - redirect("/error"); - } - - const { data, error } = await supabase!.auth.signInWithOAuth({ - provider: provider, - options: { - redirectTo: - process.env.AUTH_CALLBACK_URL ?? - `http://localhost:3000/auth/callback`, - }, - }); - - if (error) { - // FIXME: supabase doesn't return the correct error message for this case - if (error.message.includes("P0001")) { - return "not_allowed"; - } - - console.error("Error logging in", error); - return error.message; - } - - // Redirect to the OAuth provider's URL - if (data?.url) { - redirect(data.url); - } - - // Note: api.createUser() and onboarding check happen in the callback handler - // after the session is established. See `auth/callback/route.ts`. - }, - ); -} diff --git a/autogpt_platform/frontend/src/app/(platform)/login/useLoginPage.ts b/autogpt_platform/frontend/src/app/(platform)/login/useLoginPage.ts index fab9a89d5f..906f3241b6 100644 --- a/autogpt_platform/frontend/src/app/(platform)/login/useLoginPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/login/useLoginPage.ts @@ -7,7 +7,6 @@ import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import z from "zod"; -import { login, providerLogin } from "./actions"; import { useToast } from "@/components/molecules/Toast/use-toast"; export function useLoginPage() { @@ -60,18 +59,32 @@ export function useLoginPage() { } try { - const error = await providerLogin(provider); - if (error) throw error; - setFeedback(null); + const response = await fetch("/api/auth/provider", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider }), + }); + + if (!response.ok) { + const { error } = await response.json(); + if (typeof error === "string" && error.includes("not_allowed")) { + setShowNotAllowedModal(true); + } else { + setFeedback(error || "Failed to start OAuth flow"); + } + resetCaptcha(); + setIsGoogleLoading(false); + return; + } + + const { url } = await response.json(); + if (url) window.location.href = url as string; } catch (error) { resetCaptcha(); setIsGoogleLoading(false); - const errorString = JSON.stringify(error); - if (errorString.includes("not_allowed")) { - setShowNotAllowedModal(true); - } else { - setFeedback(errorString); - } + setFeedback( + error instanceof Error ? error.message : "Failed to start OAuth flow", + ); } } @@ -99,21 +112,49 @@ export function useLoginPage() { return; } - const error = await login(data, turnstile.token as string); - await supabase?.auth.refreshSession(); - setIsLoading(false); - if (error) { - toast({ - title: error, - variant: "destructive", + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: data.email, + password: data.password, + turnstileToken: turnstile.token, + }), }); + const result = await response.json(); + + if (!response.ok) { + toast({ + title: result?.error || "Login failed", + variant: "destructive", + }); + setIsLoading(false); + resetCaptcha(); + turnstile.reset(); + return; + } + + await supabase?.auth.refreshSession(); + setIsLoading(false); + setFeedback(null); + + const next = + (result?.next as string) || (result?.onboarding ? "/onboarding" : "/"); + if (next) router.push(next); + } catch (error) { + toast({ + title: + error instanceof Error + ? error.message + : "Unexpected error during login", + variant: "destructive", + }); + setIsLoading(false); resetCaptcha(); - // Always reset the turnstile on any error turnstile.reset(); - return; } - setFeedback(null); } return { diff --git a/autogpt_platform/frontend/src/app/(platform)/signup/actions.ts b/autogpt_platform/frontend/src/app/(platform)/signup/actions.ts deleted file mode 100644 index 9fa41e4510..0000000000 --- a/autogpt_platform/frontend/src/app/(platform)/signup/actions.ts +++ /dev/null @@ -1,59 +0,0 @@ -"use server"; -import { revalidatePath } from "next/cache"; -import { redirect } from "next/navigation"; -import { z } from "zod"; -import * as Sentry from "@sentry/nextjs"; -import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase"; -import { signupFormSchema } from "@/types/auth"; -import BackendAPI from "@/lib/autogpt-server-api"; -import { verifyTurnstileToken } from "@/lib/turnstile"; - -export async function signup( - values: z.infer, - turnstileToken: string, -) { - "use server"; - return await Sentry.withServerActionInstrumentation( - "signup", - {}, - async () => { - const supabase = await getServerSupabase(); - - if (!supabase) { - redirect("/error"); - } - - // Verify Turnstile token if provided - const success = await verifyTurnstileToken(turnstileToken, "signup"); - if (!success) { - return "CAPTCHA verification failed. Please try again."; - } - - // We are sure that the values are of the correct type because zod validates the form - const { data, error } = await supabase.auth.signUp(values); - - if (error) { - console.error("Error signing up", error); - // FIXME: supabase doesn't return the correct error message for this case - if (error.message.includes("P0001")) { - return "not_allowed"; - } - if (error.code === "user_already_exists") { - return "user_already_exists"; - } - return error.message; - } - - if (data.session) { - await supabase.auth.setSession(data.session); - } - // Don't onboard if disabled - if (await new BackendAPI().isOnboardingEnabled()) { - revalidatePath("/onboarding", "layout"); - redirect("/onboarding"); - } - revalidatePath("/", "layout"); - redirect("/"); - }, - ); -} diff --git a/autogpt_platform/frontend/src/app/(platform)/signup/useSignupPage.ts b/autogpt_platform/frontend/src/app/(platform)/signup/useSignupPage.ts index 34fc1d78ad..760a7fe381 100644 --- a/autogpt_platform/frontend/src/app/(platform)/signup/useSignupPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/signup/useSignupPage.ts @@ -7,8 +7,6 @@ import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import z from "zod"; -import { providerLogin } from "../login/actions"; -import { signup } from "./actions"; import { useToast } from "@/components/molecules/Toast/use-toast"; export function useSignupPage() { @@ -61,18 +59,36 @@ export function useSignupPage() { resetCaptcha(); return; } + try { + const response = await fetch("/api/auth/provider", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider }), + }); - const error = await providerLogin(provider); - if (error) { + if (!response.ok) { + const { error } = await response.json(); + setIsGoogleLoading(false); + resetCaptcha(); + toast({ + title: error || "Failed to start OAuth flow", + variant: "destructive", + }); + return; + } + + const { url } = await response.json(); + if (url) window.location.href = url as string; + setFeedback(null); + } catch (error) { setIsGoogleLoading(false); resetCaptcha(); toast({ - title: error, + title: + error instanceof Error ? error.message : "Failed to start OAuth flow", variant: "destructive", }); - return; } - setFeedback(null); } async function handleSignup(data: z.infer) { @@ -100,26 +116,56 @@ export function useSignupPage() { return; } - const error = await signup(data, turnstile.token as string); - setIsLoading(false); - if (error) { - if (error === "user_already_exists") { - setFeedback("User with this email already exists"); - turnstile.reset(); - return; - } else if (error === "not_allowed") { - setShowNotAllowedModal(true); - } else { + try { + const response = await fetch("/api/auth/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: data.email, + password: data.password, + confirmPassword: data.confirmPassword, + agreeToTerms: data.agreeToTerms, + turnstileToken: turnstile.token, + }), + }); + + const result = await response.json(); + setIsLoading(false); + + if (!response.ok) { + if (result?.error === "user_already_exists") { + setFeedback("User with this email already exists"); + turnstile.reset(); + return; + } + if (result?.error === "not_allowed") { + setShowNotAllowedModal(true); + return; + } toast({ - title: error, + title: result?.error || "Signup failed", variant: "destructive", }); resetCaptcha(); turnstile.reset(); + return; } - return; + + setFeedback(null); + const next = (result?.next as string) || "/"; + router.push(next); + } catch (error) { + setIsLoading(false); + toast({ + title: + error instanceof Error + ? error.message + : "Unexpected error during signup", + variant: "destructive", + }); + resetCaptcha(); + turnstile.reset(); } - setFeedback(null); } return { diff --git a/autogpt_platform/frontend/src/app/api/auth/login/route.ts b/autogpt_platform/frontend/src/app/api/auth/login/route.ts new file mode 100644 index 0000000000..e228b50a05 --- /dev/null +++ b/autogpt_platform/frontend/src/app/api/auth/login/route.ts @@ -0,0 +1,66 @@ +import BackendAPI from "@/lib/autogpt-server-api"; +import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase"; +import { verifyTurnstileToken } from "@/lib/turnstile"; +import { loginFormSchema } from "@/types/auth"; +import * as Sentry from "@sentry/nextjs"; +import { NextResponse } from "next/server"; +import { shouldShowOnboarding } from "../../helpers"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + + const parsed = loginFormSchema.safeParse({ + email: body?.email, + password: body?.password, + }); + + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid email or password" }, + { status: 400 }, + ); + } + + const turnstileToken: string | undefined = body?.turnstileToken; + + // Verify Turnstile token if provided + const captchaOk = await verifyTurnstileToken(turnstileToken ?? "", "login"); + if (!captchaOk) { + return NextResponse.json( + { error: "CAPTCHA verification failed. Please try again." }, + { status: 400 }, + ); + } + + const supabase = await getServerSupabase(); + if (!supabase) { + return NextResponse.json( + { error: "Authentication service unavailable" }, + { status: 500 }, + ); + } + + const { error } = await supabase.auth.signInWithPassword(parsed.data); + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + const api = new BackendAPI(); + await api.createUser(); + + const onboarding = await shouldShowOnboarding(); + + return NextResponse.json({ + success: true, + onboarding, + next: onboarding ? "/onboarding" : "/", + }); + } catch (err) { + Sentry.captureException(err); + return NextResponse.json( + { error: "Failed to login. Please try again." }, + { status: 500 }, + ); + } +} diff --git a/autogpt_platform/frontend/src/app/api/auth/provider/route.ts b/autogpt_platform/frontend/src/app/api/auth/provider/route.ts new file mode 100644 index 0000000000..7e754810dd --- /dev/null +++ b/autogpt_platform/frontend/src/app/api/auth/provider/route.ts @@ -0,0 +1,49 @@ +import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase"; +import { NextResponse } from "next/server"; +import { LoginProvider } from "@/types/auth"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const provider: LoginProvider | undefined = body?.provider; + const redirectTo: string | undefined = body?.redirectTo; + + if (!provider) { + return NextResponse.json({ error: "Invalid provider" }, { status: 400 }); + } + + const supabase = await getServerSupabase(); + if (!supabase) { + return NextResponse.json( + { error: "Authentication service unavailable" }, + { status: 500 }, + ); + } + + const { data, error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: + redirectTo || + process.env.AUTH_CALLBACK_URL || + `http://localhost:3000/auth/callback`, + }, + }); + + if (error) { + // FIXME: supabase doesn't return the correct error message for this case + if (error.message.includes("P0001")) { + return NextResponse.json({ error: "not_allowed" }, { status: 403 }); + } + + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return NextResponse.json({ url: data?.url }); + } catch { + return NextResponse.json( + { error: "Failed to initiate OAuth" }, + { status: 500 }, + ); + } +} diff --git a/autogpt_platform/frontend/src/app/api/auth/signup/route.ts b/autogpt_platform/frontend/src/app/api/auth/signup/route.ts new file mode 100644 index 0000000000..352fd8f714 --- /dev/null +++ b/autogpt_platform/frontend/src/app/api/auth/signup/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import * as Sentry from "@sentry/nextjs"; +import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase"; +import { verifyTurnstileToken } from "@/lib/turnstile"; +import { signupFormSchema } from "@/types/auth"; +import { shouldShowOnboarding } from "../../helpers"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + + const parsed = signupFormSchema.safeParse({ + email: body?.email, + password: body?.password, + confirmPassword: body?.confirmPassword, + agreeToTerms: body?.agreeToTerms, + }); + + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid signup payload" }, + { status: 400 }, + ); + } + + const turnstileToken: string | undefined = body?.turnstileToken; + + const captchaOk = await verifyTurnstileToken( + turnstileToken ?? "", + "signup", + ); + if (!captchaOk) { + return NextResponse.json( + { error: "CAPTCHA verification failed. Please try again." }, + { status: 400 }, + ); + } + + const supabase = await getServerSupabase(); + if (!supabase) { + return NextResponse.json( + { error: "Authentication service unavailable" }, + { status: 500 }, + ); + } + + const { data, error } = await supabase.auth.signUp(parsed.data); + + if (error) { + // FIXME: supabase doesn't return the correct error message for this case + if (error.message.includes("P0001")) { + return NextResponse.json({ error: "not_allowed" }, { status: 403 }); + } + if ((error as any).code === "user_already_exists") { + return NextResponse.json( + { error: "user_already_exists" }, + { status: 409 }, + ); + } + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + if (data.session) { + await supabase.auth.setSession(data.session); + } + + const isOnboardingEnabled = await shouldShowOnboarding(); + const next = isOnboardingEnabled ? "/onboarding" : "/"; + + return NextResponse.json({ success: true, next }); + } catch (err) { + Sentry.captureException(err); + return NextResponse.json( + { error: "Failed to sign up. Please try again." }, + { status: 500 }, + ); + } +} diff --git a/autogpt_platform/frontend/src/app/api/helpers.ts b/autogpt_platform/frontend/src/app/api/helpers.ts index 916afad62e..30bca0e1f0 100644 --- a/autogpt_platform/frontend/src/app/api/helpers.ts +++ b/autogpt_platform/frontend/src/app/api/helpers.ts @@ -1,3 +1,5 @@ +import BackendAPI from "@/lib/autogpt-server-api"; + /** * Narrow an orval response to its success payload if and only if it is a `200` status with OK shape. * @@ -23,3 +25,11 @@ export function okData(res: unknown): T | undefined { return (res as { data: T }).data; } + +export async function shouldShowOnboarding() { + const api = new BackendAPI(); + const isEnabled = await api.isOnboardingEnabled(); + const onboarding = await api.getUserOnboarding(); + const isCompleted = onboarding.completedSteps.includes("CONGRATS"); + return isEnabled && !isCompleted; +} diff --git a/autogpt_platform/frontend/src/components/__legacy__/Wallet.tsx b/autogpt_platform/frontend/src/components/__legacy__/Wallet.tsx index 2926a1d9cb..ae2b56538e 100644 --- a/autogpt_platform/frontend/src/components/__legacy__/Wallet.tsx +++ b/autogpt_platform/frontend/src/components/__legacy__/Wallet.tsx @@ -304,6 +304,9 @@ export default function Wallet() { }, 300); }, [credits, prevCredits]); + // Do not render until we have both credits and onboarding data + if (credits === null || !state) return null; + return (