mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend): supabase + zustand for speed (#11333)
### Changes 🏗️ This change uses [Zustand](https://github.com/pmndrs/zustand) (a lightweight state management library) to centralize authentication state across the app. Previously, each component mounting `useSupabase()` would create its own local state, causing duplicate API calls and inconsistent user data. Now, user state is cached globally with Zustand - when multiple components need auth data, they share the same cached state instead of each fetching separately. This reduces server load and improves app responsiveness. **File structure:** ``` src/lib/supabase/hooks/ ├── useSupabase.ts # React hook interface (modified) ├── useSupabaseStore.ts # Zustand state management (new) └── helpers.ts # Pure business logic (new) ``` **What was extracted to helpers:** - `ensureSupabaseClient()` - Singleton client initialization - `fetchUser()` - User fetching with error handling - `validateSession()` - Session validation logic - `refreshSession()` - Session refresh logic - `handleStorageEvent()` - Cross-tab logout handling ### 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] Verified no TypeScript errors in modified files - [x] Tested login flow works correctly - [x] Tested logout flow works correctly - [x] Verified session validation on tab focus/visibility - [x] Tested cross-tab logout synchronization - [x] Confirmed WebSocket disconnection on logout
This commit is contained in:
174
autogpt_platform/frontend/src/lib/supabase/hooks/helpers.ts
Normal file
174
autogpt_platform/frontend/src/lib/supabase/hooks/helpers.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type BackendAPI from "@/lib/autogpt-server-api/client";
|
||||
import { environment } from "@/services/environment";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import type { SupabaseClient, User } from "@supabase/supabase-js";
|
||||
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
||||
import {
|
||||
getCurrentUser,
|
||||
refreshSession as refreshSessionAction,
|
||||
validateSession as validateSessionAction,
|
||||
} from "../actions";
|
||||
import {
|
||||
clearWebSocketDisconnectIntent,
|
||||
getRedirectPath,
|
||||
isLogoutEvent,
|
||||
setWebSocketDisconnectIntent,
|
||||
} from "../helpers";
|
||||
|
||||
let supabaseSingleton: SupabaseClient | null = null;
|
||||
|
||||
export function ensureSupabaseClient(): SupabaseClient | null {
|
||||
if (supabaseSingleton) return supabaseSingleton;
|
||||
|
||||
const supabaseUrl = environment.getSupabaseUrl();
|
||||
const supabaseKey = environment.getSupabaseAnonKey();
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) return null;
|
||||
|
||||
try {
|
||||
supabaseSingleton = createBrowserClient(supabaseUrl, supabaseKey, {
|
||||
isSingleton: true,
|
||||
auth: {
|
||||
persistSession: false,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating Supabase client", error);
|
||||
supabaseSingleton = null;
|
||||
}
|
||||
|
||||
return supabaseSingleton;
|
||||
}
|
||||
|
||||
interface FetchUserResult {
|
||||
user: User | null;
|
||||
hasLoadedUser: boolean;
|
||||
isUserLoading: boolean;
|
||||
}
|
||||
|
||||
export async function fetchUser(): Promise<FetchUserResult> {
|
||||
try {
|
||||
const { user, error } = await getCurrentUser();
|
||||
|
||||
if (error || !user) {
|
||||
return {
|
||||
user: null,
|
||||
hasLoadedUser: true,
|
||||
isUserLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
clearWebSocketDisconnectIntent();
|
||||
return {
|
||||
user,
|
||||
hasLoadedUser: true,
|
||||
isUserLoading: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Get user error:", error);
|
||||
return {
|
||||
user: null,
|
||||
hasLoadedUser: true,
|
||||
isUserLoading: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface ValidateSessionParams {
|
||||
pathname: string;
|
||||
currentUser: User | null;
|
||||
}
|
||||
|
||||
interface ValidateSessionResult {
|
||||
isValid: boolean;
|
||||
user?: User | null;
|
||||
redirectPath?: string | null;
|
||||
shouldUpdateUser: boolean;
|
||||
}
|
||||
|
||||
export async function validateSession(
|
||||
params: ValidateSessionParams,
|
||||
): Promise<ValidateSessionResult> {
|
||||
try {
|
||||
const result = await validateSessionAction(params.pathname);
|
||||
|
||||
if (!result.isValid) {
|
||||
return {
|
||||
isValid: false,
|
||||
redirectPath: result.redirectPath,
|
||||
shouldUpdateUser: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.user) {
|
||||
const shouldUpdateUser = params.currentUser?.id !== result.user.id;
|
||||
clearWebSocketDisconnectIntent();
|
||||
return {
|
||||
isValid: true,
|
||||
user: result.user,
|
||||
shouldUpdateUser,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
shouldUpdateUser: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Session validation error:", error);
|
||||
const redirectPath = getRedirectPath(params.pathname);
|
||||
return {
|
||||
isValid: false,
|
||||
redirectPath,
|
||||
shouldUpdateUser: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface RefreshSessionResult {
|
||||
user?: User | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function refreshSession(): Promise<RefreshSessionResult> {
|
||||
const result = await refreshSessionAction();
|
||||
|
||||
if (result.user) {
|
||||
clearWebSocketDisconnectIntent();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
interface StorageEventHandlerParams {
|
||||
event: StorageEvent;
|
||||
api: BackendAPI | null;
|
||||
router: AppRouterInstance | null;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
interface StorageEventHandlerResult {
|
||||
shouldLogout: boolean;
|
||||
redirectPath?: string | null;
|
||||
}
|
||||
|
||||
export function handleStorageEvent(
|
||||
params: StorageEventHandlerParams,
|
||||
): StorageEventHandlerResult {
|
||||
if (!isLogoutEvent(params.event)) {
|
||||
return { shouldLogout: false };
|
||||
}
|
||||
|
||||
setWebSocketDisconnectIntent();
|
||||
|
||||
if (params.api) {
|
||||
params.api.disconnectWebSocket();
|
||||
}
|
||||
|
||||
const redirectPath = getRedirectPath(params.pathname);
|
||||
|
||||
return {
|
||||
shouldLogout: true,
|
||||
redirectPath,
|
||||
};
|
||||
}
|
||||
@@ -1,188 +1,67 @@
|
||||
"use client";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { User } from "@supabase/supabase-js";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import {
|
||||
getCurrentUser,
|
||||
refreshSession,
|
||||
serverLogout,
|
||||
ServerLogoutOptions,
|
||||
validateSession,
|
||||
} from "../actions";
|
||||
import {
|
||||
broadcastLogout,
|
||||
clearWebSocketDisconnectIntent,
|
||||
getRedirectPath,
|
||||
isLogoutEvent,
|
||||
setWebSocketDisconnectIntent,
|
||||
setupSessionEventListeners,
|
||||
} from "../helpers";
|
||||
import { environment } from "@/services/environment";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import type { ServerLogoutOptions } from "../actions";
|
||||
import { useSupabaseStore } from "./useSupabaseStore";
|
||||
|
||||
export function useSupabase() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const api = useBackendAPI();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isUserLoading, setIsUserLoading] = useState(true);
|
||||
const lastValidationRef = useRef<number>(0);
|
||||
const isValidatingRef = useRef(false);
|
||||
const isLoggedIn = Boolean(user);
|
||||
|
||||
const supabase = useMemo(() => {
|
||||
try {
|
||||
return createBrowserClient(
|
||||
environment.getSupabaseUrl(),
|
||||
environment.getSupabaseAnonKey(),
|
||||
{
|
||||
isSingleton: true,
|
||||
auth: {
|
||||
persistSession: false, // Don't persist session on client with httpOnly cookies
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error creating Supabase client", error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function logOut(options: ServerLogoutOptions = {}) {
|
||||
setWebSocketDisconnectIntent();
|
||||
api.disconnectWebSocket();
|
||||
broadcastLogout();
|
||||
|
||||
try {
|
||||
await serverLogout(options);
|
||||
} catch (error) {
|
||||
console.error("Error logging out:", error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function validateSessionServer() {
|
||||
// Prevent concurrent validation calls
|
||||
if (isValidatingRef.current) return true;
|
||||
|
||||
// Simple debounce - only validate if 2 seconds have passed
|
||||
const now = Date.now();
|
||||
if (now - lastValidationRef.current < 2000) {
|
||||
return true;
|
||||
}
|
||||
|
||||
isValidatingRef.current = true;
|
||||
lastValidationRef.current = now;
|
||||
|
||||
try {
|
||||
const result = await validateSession(pathname);
|
||||
|
||||
if (!result.isValid) {
|
||||
setUser(null);
|
||||
if (result.redirectPath) {
|
||||
router.push(result.redirectPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update local state with server user data
|
||||
if (result.user) {
|
||||
setUser((currentUser) => {
|
||||
// Only update if user actually changed to prevent unnecessary re-renders
|
||||
if (currentUser?.id !== result.user?.id) {
|
||||
return result.user;
|
||||
}
|
||||
return currentUser;
|
||||
});
|
||||
clearWebSocketDisconnectIntent();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Session validation error:", error);
|
||||
setUser(null);
|
||||
const redirectPath = getRedirectPath(pathname);
|
||||
if (redirectPath) {
|
||||
router.push(redirectPath);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
isValidatingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserFromServer() {
|
||||
try {
|
||||
const { user: serverUser, error } = await getCurrentUser();
|
||||
|
||||
if (error || !serverUser) {
|
||||
setUser(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
setUser(serverUser);
|
||||
clearWebSocketDisconnectIntent();
|
||||
return serverUser;
|
||||
} catch (error) {
|
||||
console.error("Get user error:", error);
|
||||
setUser(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCrossTabLogout(e: StorageEvent) {
|
||||
if (!isLogoutEvent(e)) return;
|
||||
|
||||
setWebSocketDisconnectIntent();
|
||||
api.disconnectWebSocket();
|
||||
|
||||
// Clear local state immediately
|
||||
setUser(null);
|
||||
router.refresh();
|
||||
|
||||
const redirectPath = getRedirectPath(pathname);
|
||||
if (redirectPath) {
|
||||
router.push(redirectPath);
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "visible") {
|
||||
validateSessionServer();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
validateSessionServer();
|
||||
}
|
||||
const {
|
||||
user,
|
||||
supabase,
|
||||
isUserLoading,
|
||||
initialize,
|
||||
logOut,
|
||||
validateSession,
|
||||
refreshSession,
|
||||
} = useSupabaseStore(
|
||||
useShallow((state) => ({
|
||||
user: state.user,
|
||||
supabase: state.supabase,
|
||||
isUserLoading: state.isUserLoading,
|
||||
initialize: state.initialize,
|
||||
logOut: state.logOut,
|
||||
validateSession: state.validateSession,
|
||||
refreshSession: state.refreshSession,
|
||||
})),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
getUserFromServer().finally(() => {
|
||||
setIsUserLoading(false);
|
||||
void initialize({
|
||||
api,
|
||||
router,
|
||||
pathname,
|
||||
});
|
||||
}, [api, initialize, pathname, router]);
|
||||
|
||||
// Set up event listeners for cross-tab logout, focus, and visibility change
|
||||
const eventListeners = setupSessionEventListeners(
|
||||
handleVisibilityChange,
|
||||
handleFocus,
|
||||
handleCrossTabLogout,
|
||||
);
|
||||
function handleLogout(options: ServerLogoutOptions = {}) {
|
||||
return logOut({
|
||||
options,
|
||||
api,
|
||||
router,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
eventListeners.cleanup();
|
||||
};
|
||||
}, []);
|
||||
function handleValidateSession() {
|
||||
return validateSession({
|
||||
pathname,
|
||||
router,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
supabase, // Available for non-auth operations like real-time subscriptions
|
||||
isLoggedIn,
|
||||
supabase,
|
||||
isLoggedIn: Boolean(user),
|
||||
isUserLoading,
|
||||
logOut,
|
||||
validateSession: validateSessionServer,
|
||||
logOut: handleLogout,
|
||||
validateSession: handleValidateSession,
|
||||
refreshSession,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import type BackendAPI from "@/lib/autogpt-server-api/client";
|
||||
import type { SupabaseClient, User } from "@supabase/supabase-js";
|
||||
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
||||
import { create } from "zustand";
|
||||
import { serverLogout, type ServerLogoutOptions } from "../actions";
|
||||
import {
|
||||
broadcastLogout,
|
||||
setWebSocketDisconnectIntent,
|
||||
setupSessionEventListeners,
|
||||
} from "../helpers";
|
||||
import {
|
||||
ensureSupabaseClient,
|
||||
fetchUser,
|
||||
handleStorageEvent as handleStorageEventHelper,
|
||||
refreshSession as refreshSessionHelper,
|
||||
validateSession as validateSessionHelper,
|
||||
} from "./helpers";
|
||||
|
||||
interface InitializeParams {
|
||||
api: BackendAPI;
|
||||
router: AppRouterInstance;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
interface LogOutParams {
|
||||
api?: BackendAPI;
|
||||
options?: ServerLogoutOptions;
|
||||
router?: AppRouterInstance;
|
||||
}
|
||||
|
||||
interface ValidateParams {
|
||||
force?: boolean;
|
||||
pathname?: string;
|
||||
router?: AppRouterInstance;
|
||||
}
|
||||
|
||||
interface SupabaseStoreState {
|
||||
user: User | null;
|
||||
supabase: SupabaseClient | null;
|
||||
isUserLoading: boolean;
|
||||
isValidating: boolean;
|
||||
hasLoadedUser: boolean;
|
||||
lastValidation: number;
|
||||
initializationPromise: Promise<void> | null;
|
||||
listenersCleanup: (() => void) | null;
|
||||
routerRef: AppRouterInstance | null;
|
||||
apiRef: BackendAPI | null;
|
||||
currentPathname: string;
|
||||
initialize: (params: InitializeParams) => Promise<void>;
|
||||
logOut: (params?: LogOutParams) => Promise<void>;
|
||||
validateSession: (params?: ValidateParams) => Promise<boolean>;
|
||||
refreshSession: () => ReturnType<typeof refreshSessionHelper>;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
export const useSupabaseStore = create<SupabaseStoreState>((set, get) => {
|
||||
async function initialize(params: InitializeParams): Promise<void> {
|
||||
set({
|
||||
routerRef: params.router,
|
||||
apiRef: params.api,
|
||||
currentPathname: params.pathname,
|
||||
});
|
||||
|
||||
const supabaseClient = ensureSupabaseClient();
|
||||
if (supabaseClient !== get().supabase) {
|
||||
set({ supabase: supabaseClient });
|
||||
}
|
||||
|
||||
let initializationPromise = get().initializationPromise;
|
||||
|
||||
if (!initializationPromise) {
|
||||
initializationPromise = (async () => {
|
||||
if (!get().hasLoadedUser) {
|
||||
set({ isUserLoading: true });
|
||||
const result = await fetchUser();
|
||||
set(result);
|
||||
} else {
|
||||
set({ isUserLoading: false });
|
||||
}
|
||||
|
||||
const existingCleanup = get().listenersCleanup;
|
||||
if (existingCleanup) {
|
||||
existingCleanup();
|
||||
}
|
||||
|
||||
const cleanup = setupSessionEventListeners(
|
||||
handleVisibilityChange,
|
||||
handleFocus,
|
||||
handleStorageEventInternal,
|
||||
);
|
||||
set({ listenersCleanup: cleanup.cleanup });
|
||||
})();
|
||||
|
||||
set({ initializationPromise });
|
||||
}
|
||||
|
||||
try {
|
||||
await initializationPromise;
|
||||
} finally {
|
||||
set({ initializationPromise: null });
|
||||
}
|
||||
}
|
||||
|
||||
async function logOut(params?: LogOutParams): Promise<void> {
|
||||
const router = params?.router ?? get().routerRef;
|
||||
const api = params?.api ?? get().apiRef;
|
||||
const options = params?.options ?? {};
|
||||
|
||||
setWebSocketDisconnectIntent();
|
||||
|
||||
if (api) {
|
||||
api.disconnectWebSocket();
|
||||
}
|
||||
|
||||
const existingCleanup = get().listenersCleanup;
|
||||
if (existingCleanup) {
|
||||
existingCleanup();
|
||||
set({ listenersCleanup: null });
|
||||
}
|
||||
|
||||
broadcastLogout();
|
||||
|
||||
try {
|
||||
await serverLogout(options);
|
||||
} catch (error) {
|
||||
console.error("Error logging out:", error);
|
||||
} finally {
|
||||
set({
|
||||
user: null,
|
||||
hasLoadedUser: false,
|
||||
isUserLoading: false,
|
||||
});
|
||||
|
||||
if (router) {
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateSessionInternal(
|
||||
params?: ValidateParams,
|
||||
): Promise<boolean> {
|
||||
const router = params?.router ?? get().routerRef;
|
||||
const pathname = params?.pathname ?? get().currentPathname;
|
||||
|
||||
if (!router || !pathname) return true;
|
||||
if (!params?.force && get().isValidating) return true;
|
||||
|
||||
const now = Date.now();
|
||||
if (!params?.force && now - get().lastValidation < 2000) return true;
|
||||
|
||||
set({
|
||||
isValidating: true,
|
||||
lastValidation: now,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await validateSessionHelper({
|
||||
pathname,
|
||||
currentUser: get().user,
|
||||
});
|
||||
|
||||
if (!result.isValid) {
|
||||
set({
|
||||
user: null,
|
||||
hasLoadedUser: false,
|
||||
isUserLoading: false,
|
||||
});
|
||||
|
||||
if (result.redirectPath) {
|
||||
router.push(result.redirectPath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.user && result.shouldUpdateUser) {
|
||||
set({ user: result.user });
|
||||
}
|
||||
|
||||
if (result.user) {
|
||||
set({
|
||||
hasLoadedUser: true,
|
||||
isUserLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} finally {
|
||||
set({ isValidating: false });
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange(): void {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
void validateSessionInternal();
|
||||
}
|
||||
|
||||
function handleFocus(): void {
|
||||
void validateSessionInternal();
|
||||
}
|
||||
|
||||
function handleStorageEventInternal(event: StorageEvent): void {
|
||||
const result = handleStorageEventHelper({
|
||||
event,
|
||||
api: get().apiRef,
|
||||
router: get().routerRef,
|
||||
pathname: get().currentPathname,
|
||||
});
|
||||
|
||||
if (!result.shouldLogout) return;
|
||||
|
||||
set({
|
||||
user: null,
|
||||
hasLoadedUser: false,
|
||||
isUserLoading: false,
|
||||
});
|
||||
|
||||
const router = get().routerRef;
|
||||
if (router) {
|
||||
router.refresh();
|
||||
if (result.redirectPath) {
|
||||
router.push(result.redirectPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSessionInternal() {
|
||||
const result = await refreshSessionHelper();
|
||||
|
||||
if (result.user) {
|
||||
set({
|
||||
user: result.user,
|
||||
hasLoadedUser: true,
|
||||
isUserLoading: false,
|
||||
});
|
||||
} else if (result.error) {
|
||||
set({
|
||||
user: null,
|
||||
hasLoadedUser: false,
|
||||
isUserLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function cleanup(): void {
|
||||
const existingCleanup = get().listenersCleanup;
|
||||
if (existingCleanup) {
|
||||
existingCleanup();
|
||||
set({ listenersCleanup: null });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user: null,
|
||||
supabase: null,
|
||||
isUserLoading: true,
|
||||
isValidating: false,
|
||||
hasLoadedUser: false,
|
||||
lastValidation: 0,
|
||||
initializationPromise: null,
|
||||
listenersCleanup: null,
|
||||
routerRef: null,
|
||||
apiRef: null,
|
||||
currentPathname: "",
|
||||
initialize,
|
||||
logOut,
|
||||
validateSession: validateSessionInternal,
|
||||
refreshSession: refreshSessionInternal,
|
||||
cleanup,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user