mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
fix(frontend): Fix login/logout actions; update credentials cache on state change (#10017)
- Resolves #10008 ### Changes 🏗️ - Update `useSupabase` hook to propagate auth state changes - Refresh `CredentialsProvider` whenever the user login state changes - Add `logOut` callback to `useSupabase` hook that handles (client-side) logout - Remove server-side `logout` action: the Supabase reference implementation does it client-side, and doing both causes a race condition Refactorings to aid implementation of the above: - Move `@/hooks/useSupabase` -> `@/lib/supabase/useSupabase` Other improvements: - Clean up `login` server action based on reference implementation - Make `BackendAPI.isAuthenticated()` more efficient and faster - Remove unused `ProfileDropdown` component - Improve logic and debug logging in `tests/pages/login.page.ts` - Improve playwright test output logging ### 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: - Log out from account (A) - Log in to other account (B) - Open builder, add a block for which account B has (multiple) credentials - [x] Credentials for account B are shown - [x] Credentials for account A are *not* shown **Note: do not reload the page** while going through these steps
This commit is contained in:
committed by
GitHub
parent
fa7fcb3dd4
commit
47adab575b
@@ -8,30 +8,6 @@ import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { loginFormSchema, LoginProvider } from "@/types/auth";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
|
||||
export async function logout() {
|
||||
return await Sentry.withServerActionInstrumentation(
|
||||
"logout",
|
||||
{},
|
||||
async () => {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
if (!supabase) {
|
||||
redirect("/error");
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
console.error("Error logging out", error);
|
||||
return error.message;
|
||||
}
|
||||
|
||||
revalidatePath("/", "layout");
|
||||
redirect("/login");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function shouldShowOnboarding() {
|
||||
const api = new BackendAPI();
|
||||
return (
|
||||
@@ -59,23 +35,21 @@ export async function login(
|
||||
}
|
||||
|
||||
// We are sure that the values are of the correct type because zod validates the form
|
||||
const { data, error } = await supabase.auth.signInWithPassword(values);
|
||||
const { error } = await supabase.auth.signInWithPassword(values);
|
||||
|
||||
if (error) {
|
||||
console.error("Error logging in", error);
|
||||
console.error("Error logging in:", error);
|
||||
return error.message;
|
||||
}
|
||||
|
||||
await api.createUser();
|
||||
|
||||
// Don't onboard if disabled or already onboarded
|
||||
if (await shouldShowOnboarding()) {
|
||||
revalidatePath("/onboarding", "layout");
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
if (data.session) {
|
||||
await supabase.auth.setSession(data.session);
|
||||
}
|
||||
revalidatePath("/", "layout");
|
||||
redirect("/");
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import LoadingBox from "@/components/ui/loading";
|
||||
import {
|
||||
AuthCard,
|
||||
@@ -80,6 +80,7 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
const error = await login(data, turnstile.token as string);
|
||||
await supabase?.auth.refreshSession();
|
||||
setIsLoading(false);
|
||||
if (error) {
|
||||
setFeedback(error);
|
||||
@@ -89,7 +90,7 @@ export default function LoginPage() {
|
||||
}
|
||||
setFeedback(null);
|
||||
},
|
||||
[form, turnstile],
|
||||
[form, turnstile, supabase],
|
||||
);
|
||||
|
||||
if (user) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
StoreSubmissionsResponse,
|
||||
StoreSubmissionRequest,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
|
||||
export default function Page({}: {}) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useContext, useMemo, useState } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { IconKey, IconUser } from "@/components/ui/icons";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
@@ -26,10 +26,10 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import LoadingBox from "@/components/ui/loading";
|
||||
|
||||
export default function PrivatePage() {
|
||||
export default function UserIntegrationsPage() {
|
||||
const { supabase, user, isUserLoading } = useSupabase();
|
||||
const router = useRouter();
|
||||
const providers = useContext(CredentialsProvidersContext);
|
||||
@@ -122,15 +122,15 @@ export default function PrivatePage() {
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isUserLoading) return;
|
||||
if (!user || !supabase) router.push("/login");
|
||||
}, [isUserLoading, user, supabase, router]);
|
||||
|
||||
if (isUserLoading) {
|
||||
return <LoadingBox className="h-[80vh]" />;
|
||||
}
|
||||
|
||||
if (!user || !supabase) {
|
||||
router.push("/login");
|
||||
return null;
|
||||
}
|
||||
|
||||
const allCredentials = providers
|
||||
? Object.values(providers).flatMap((provider) =>
|
||||
provider.savedCredentials
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import { sendEmailFormSchema, changePasswordFormSchema } from "@/types/auth";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import LoadingBox from "@/components/ui/loading";
|
||||
import {
|
||||
AuthCard,
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "./ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
|
||||
const ProfileDropdown = () => {
|
||||
const { supabase, user, isUserLoading } = useSupabase();
|
||||
const router = useRouter();
|
||||
|
||||
if (isUserLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 rounded-full">
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={user?.user_metadata["avatar_url"]}
|
||||
alt="User Avatar"
|
||||
/>
|
||||
<AvatarFallback>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => router.push("/profile")}>
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
{user!.role === "admin" && (
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/dashboard")}>
|
||||
Admin Dashboard
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
supabase?.auth.signOut().then(() => router.replace("/login"))
|
||||
}
|
||||
>
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileDropdown;
|
||||
@@ -1,5 +1,5 @@
|
||||
// components/RoleBasedAccess.tsx
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import React from "react";
|
||||
|
||||
interface RoleBasedAccessProps {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button } from "./Button";
|
||||
import { IconPersonFill } from "@/components/ui/icons";
|
||||
import { ProfileDetails } from "@/lib/autogpt-server-api/types";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
|
||||
export const ProfileInfoForm = ({ profile }: { profile: ProfileDetails }) => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { logout } from "@/app/(platform)/login/actions";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import { IconLogOut } from "@/components/ui/icons";
|
||||
|
||||
export const ProfilePopoutMenuLogoutButton = () => {
|
||||
const supabase = useSupabase();
|
||||
return (
|
||||
<div
|
||||
className="inline-flex w-full items-center justify-start gap-2.5"
|
||||
onClick={() => logout()}
|
||||
onClick={() => supabase.logOut()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import {
|
||||
APIKeyCredentials,
|
||||
CredentialsDeleteNeedConfirmationResponse,
|
||||
@@ -8,7 +10,6 @@ import {
|
||||
UserPasswordCredentials,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
|
||||
// Get keys from CredentialsProviderName type
|
||||
const CREDENTIALS_PROVIDER_NAMES = Object.values(
|
||||
@@ -102,6 +103,7 @@ export default function CredentialsProvider({
|
||||
}) {
|
||||
const [providers, setProviders] =
|
||||
useState<CredentialsProvidersContextType | null>(null);
|
||||
const { isLoggedIn } = useSupabase();
|
||||
const api = useBackendAPI();
|
||||
|
||||
const addCredentials = useCallback(
|
||||
@@ -202,48 +204,50 @@ export default function CredentialsProvider({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
api.isAuthenticated().then((isAuthenticated) => {
|
||||
if (!isAuthenticated) return;
|
||||
if (!isLoggedIn) {
|
||||
if (isLoggedIn == false) setProviders(null);
|
||||
return;
|
||||
}
|
||||
|
||||
api.listCredentials().then((response) => {
|
||||
const credentialsByProvider = response.reduce(
|
||||
(acc, cred) => {
|
||||
if (!acc[cred.provider]) {
|
||||
acc[cred.provider] = [];
|
||||
}
|
||||
acc[cred.provider].push(cred);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<CredentialsProviderName, CredentialsMetaResponse[]>,
|
||||
);
|
||||
api.listCredentials().then((response) => {
|
||||
const credentialsByProvider = response.reduce(
|
||||
(acc, cred) => {
|
||||
if (!acc[cred.provider]) {
|
||||
acc[cred.provider] = [];
|
||||
}
|
||||
acc[cred.provider].push(cred);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<CredentialsProviderName, CredentialsMetaResponse[]>,
|
||||
);
|
||||
|
||||
setProviders((prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(
|
||||
CREDENTIALS_PROVIDER_NAMES.map((provider) => [
|
||||
setProviders((prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(
|
||||
CREDENTIALS_PROVIDER_NAMES.map((provider) => [
|
||||
provider,
|
||||
{
|
||||
provider,
|
||||
{
|
||||
provider,
|
||||
providerName: providerDisplayNames[provider],
|
||||
savedCredentials: credentialsByProvider[provider] ?? [],
|
||||
oAuthCallback: (code: string, state_token: string) =>
|
||||
oAuthCallback(provider, code, state_token),
|
||||
createAPIKeyCredentials: (
|
||||
credentials: APIKeyCredentialsCreatable,
|
||||
) => createAPIKeyCredentials(provider, credentials),
|
||||
createUserPasswordCredentials: (
|
||||
credentials: UserPasswordCredentialsCreatable,
|
||||
) => createUserPasswordCredentials(provider, credentials),
|
||||
deleteCredentials: (id: string, force: boolean = false) =>
|
||||
deleteCredentials(provider, id, force),
|
||||
} satisfies CredentialsProviderData,
|
||||
]),
|
||||
),
|
||||
}));
|
||||
});
|
||||
providerName: providerDisplayNames[provider],
|
||||
savedCredentials: credentialsByProvider[provider] ?? [],
|
||||
oAuthCallback: (code: string, state_token: string) =>
|
||||
oAuthCallback(provider, code, state_token),
|
||||
createAPIKeyCredentials: (
|
||||
credentials: APIKeyCredentialsCreatable,
|
||||
) => createAPIKeyCredentials(provider, credentials),
|
||||
createUserPasswordCredentials: (
|
||||
credentials: UserPasswordCredentialsCreatable,
|
||||
) => createUserPasswordCredentials(provider, credentials),
|
||||
deleteCredentials: (id: string, force: boolean = false) =>
|
||||
deleteCredentials(provider, id, force),
|
||||
} satisfies CredentialsProviderData,
|
||||
]),
|
||||
),
|
||||
}));
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
isLoggedIn,
|
||||
createAPIKeyCredentials,
|
||||
createUserPasswordCredentials,
|
||||
deleteCredentials,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import { OnboardingStep, UserOnboarding } from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { User } from "@supabase/supabase-js";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
export default function useSupabase() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isUserLoading, setIsUserLoading] = useState(true);
|
||||
|
||||
const supabase = useMemo(() => {
|
||||
try {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error creating Supabase client", error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supabase) {
|
||||
setIsUserLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchUser = async () => {
|
||||
const response = await supabase.auth.getUser();
|
||||
|
||||
if (response.error) {
|
||||
// Display error only if it's not about missing auth session (user is not logged in)
|
||||
if (response.error.message !== "Auth session missing!") {
|
||||
console.error("Error fetching user", response.error);
|
||||
}
|
||||
setUser(null);
|
||||
} else {
|
||||
setUser(response.data.user);
|
||||
}
|
||||
setIsUserLoading(false);
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, [supabase]);
|
||||
|
||||
return { supabase, user, isUserLoading };
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export default class BackendAPI {
|
||||
? createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{ isSingleton: true },
|
||||
)
|
||||
: getServerSupabase();
|
||||
}
|
||||
@@ -98,9 +99,9 @@ export default class BackendAPI {
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
if (!this.supabaseClient) return false;
|
||||
const {
|
||||
data: { user },
|
||||
} = await this.supabaseClient?.auth.getUser();
|
||||
return user != null;
|
||||
data: { session },
|
||||
} = await this.supabaseClient.auth.getSession();
|
||||
return session != null;
|
||||
}
|
||||
|
||||
createUser(): Promise<User> {
|
||||
|
||||
65
autogpt_platform/frontend/src/lib/supabase/useSupabase.ts
Normal file
65
autogpt_platform/frontend/src/lib/supabase/useSupabase.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { SignOut, User } from "@supabase/supabase-js";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function useSupabase() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isUserLoading, setIsUserLoading] = useState(true);
|
||||
|
||||
const supabase = useMemo(() => {
|
||||
try {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{ isSingleton: true },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error creating Supabase client", error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supabase) {
|
||||
setIsUserLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync up the current state and listen for changes
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setUser(session?.user ?? null);
|
||||
setIsUserLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [supabase]);
|
||||
|
||||
const logOut = useCallback(
|
||||
async (options?: SignOut) => {
|
||||
if (!supabase) return;
|
||||
|
||||
const { error } = await supabase.auth.signOut({
|
||||
scope: options?.scope ?? "local",
|
||||
});
|
||||
if (error) console.error("Error logging out:", error);
|
||||
|
||||
router.push("/login");
|
||||
},
|
||||
[router, supabase],
|
||||
);
|
||||
|
||||
if (!supabase || isUserLoading) {
|
||||
return { supabase, user: null, isLoggedIn: null, isUserLoading, logOut };
|
||||
}
|
||||
if (!user) {
|
||||
return { supabase, user, isLoggedIn: false, isUserLoading, logOut };
|
||||
}
|
||||
return { supabase, user, isLoggedIn: true, isUserLoading, logOut };
|
||||
}
|
||||
@@ -4,7 +4,10 @@ export class LoginPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
console.log("Attempting login with:", { email, password }); // Debug log
|
||||
console.log(`ℹ️ Attempting login on ${this.page.url()} with`, {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
// Fill email
|
||||
const emailInput = this.page.getByPlaceholder("m@example.com");
|
||||
@@ -33,23 +36,35 @@ export class LoginPage {
|
||||
});
|
||||
await loginButton.waitFor({ state: "visible" });
|
||||
|
||||
// Start waiting for navigation before clicking
|
||||
const navigationPromise = Promise.race([
|
||||
this.page.waitForURL("/", { timeout: 10_000 }), // Wait for home page
|
||||
this.page.waitForURL("/marketplace", { timeout: 10_000 }), // Wait for home page
|
||||
this.page.waitForURL("/onboarding/**", { timeout: 10_000 }), // Wait for onboarding page
|
||||
]);
|
||||
// Attach navigation logger for debug purposes
|
||||
this.page.on("load", (page) => console.log(`ℹ️ Now at URL: ${page.url()}`));
|
||||
|
||||
console.log("About to click login button"); // Debug log
|
||||
// Start waiting for navigation before clicking
|
||||
const leaveLoginPage = this.page
|
||||
.waitForURL(
|
||||
(url) => /^\/(marketplace|onboarding(\/.*)?)?$/.test(url.pathname),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.catch((reason) => {
|
||||
console.error(
|
||||
`🚨 Navigation away from /login timed out (current URL: ${this.page.url()}):`,
|
||||
reason,
|
||||
);
|
||||
throw reason;
|
||||
});
|
||||
|
||||
console.log(`🖱️ Clicking login button...`);
|
||||
await loginButton.click();
|
||||
|
||||
console.log("Waiting for navigation"); // Debug log
|
||||
await navigationPromise;
|
||||
console.log("⏳ Waiting for navigation away from /login ...");
|
||||
await leaveLoginPage;
|
||||
console.log(`⌛ Post-login redirected to ${this.page.url()}`);
|
||||
|
||||
await this.page.goto("/marketplace");
|
||||
|
||||
console.log("Navigation complete, waiting for network idle"); // Debug log
|
||||
await new Promise((resolve) => setTimeout(resolve, 200)); // allow time for client-side redirect
|
||||
await this.page.waitForLoadState("load", { timeout: 10_000 });
|
||||
console.log("Login process complete"); // Debug log
|
||||
|
||||
console.log("➡️ Navigating to /marketplace ...");
|
||||
await this.page.goto("/marketplace", { timeout: 10_000 });
|
||||
console.log("✅ Login process complete");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user