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:
Reinier van der Leer
2025-06-04 23:42:31 +01:00
committed by GitHub
parent fa7fcb3dd4
commit 47adab575b
16 changed files with 162 additions and 203 deletions

View File

@@ -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("/");
});

View File

@@ -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) {

View File

@@ -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({}: {}) {

View File

@@ -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

View File

@@ -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";

View File

@@ -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,

View File

@@ -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;

View File

@@ -1,5 +1,5 @@
// components/RoleBasedAccess.tsx
import useSupabase from "@/hooks/useSupabase";
import useSupabase from "@/lib/supabase/useSupabase";
import React from "react";
interface RoleBasedAccessProps {

View File

@@ -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 }) => {

View File

@@ -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}
>

View File

@@ -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,

View File

@@ -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";

View File

@@ -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 };
}

View File

@@ -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> {

View 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 };
}

View File

@@ -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");
}
}