mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
fix(frontend): login redirects + onboarding (#11125)
## Changes 🏗️ ### Fix re-direct bugs Sometimes the app will re-direct to a strange URL after login: ``` http://localhost:3000/marketplace,%20/marketplace ``` It looks like a race-condition because the re-direct to `/marketplace` was done on a [server action](https://nextjs.org/docs/14/app/building-your-application/data-fetching/server-actions-and-mutations) rather than in the browser. **✅ Fixed by** Moving the login / signup server actions to Next.js API endpoints. In this way the login/signup pages just call an API endpoint and handle its response without having to hassle with serverless 💆🏽 ### Wallet layout flash <img width="800" height="744" alt="Screenshot 2025-10-08 at 22 52 03" src="https://github.com/user-attachments/assets/7cb85fd5-7dc4-4870-b4e1-173cc8148e51" /> The wallet popover would sometimes flash after login, because it was re-rendering once onboarding and credits data loaded. **✅ Fixed by** Only rendering once we have onboarding and credits data, without the popover is useless and causes flashes. ## 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] Login / Signup to the app with email and Google - [x] Works fine - [x] Onboarding popover does not flash - [x] Onboarding and marketplace re-directs work ### For configuration changes: None
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<typeof loginFormSchema>,
|
||||
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`.
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<typeof signupFormSchema>,
|
||||
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("/");
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<typeof signupFormSchema>) {
|
||||
@@ -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 {
|
||||
|
||||
66
autogpt_platform/frontend/src/app/api/auth/login/route.ts
Normal file
66
autogpt_platform/frontend/src/app/api/auth/login/route.ts
Normal file
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
49
autogpt_platform/frontend/src/app/api/auth/provider/route.ts
Normal file
49
autogpt_platform/frontend/src/app/api/auth/provider/route.ts
Normal file
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
78
autogpt_platform/frontend/src/app/api/auth/signup/route.ts
Normal file
78
autogpt_platform/frontend/src/app/api/auth/signup/route.ts
Normal file
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<T>(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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Popover
|
||||
open={walletOpen}
|
||||
|
||||
Reference in New Issue
Block a user