Compare commits

...

1 Commits

Author SHA1 Message Date
Lluis Agusti
cc85a37305 fix(frontend): account/auth check issues 2025-11-26 23:18:04 +07:00
6 changed files with 271 additions and 69 deletions

View File

@@ -3,15 +3,16 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/__legacy__/ui/popover";
import Link from "next/link";
import * as React from "react";
import { getAccountMenuOptionIcon, MenuItemGroup } from "../../helpers";
import { AccountLogoutOption } from "./components/AccountLogoutOption";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import Link from "next/link";
import * as React from "react";
import { getAccountMenuOptionIcon, MenuItemGroup } from "../../helpers";
import { AccountLogoutOption } from "./components/AccountLogoutOption";
interface Props {
userName?: string;
@@ -19,6 +20,7 @@ interface Props {
avatarSrc?: string;
hideNavBarUsername?: boolean;
menuItemGroups: MenuItemGroup[];
isLoading?: boolean;
}
export function AccountMenu({
@@ -26,6 +28,7 @@ export function AccountMenu({
userEmail,
avatarSrc,
menuItemGroups,
isLoading = false,
}: Props) {
const popupId = React.useId();
@@ -63,15 +66,24 @@ export function AccountMenu({
</AvatarFallback>
</Avatar>
<div className="relative flex h-[47px] w-[173px] flex-col items-start justify-center gap-1">
<div className="max-w-[10.5rem] truncate font-sans text-base font-semibold leading-none text-white dark:text-neutral-200">
{userName}
</div>
<div
data-testid="account-menu-user-email"
className="max-w-[10.5rem] truncate font-sans text-base font-normal leading-none text-white dark:text-neutral-400"
>
{userEmail}
</div>
{isLoading || !userName || !userEmail ? (
<>
<Skeleton className="h-4 w-24 bg-white/40" />
<Skeleton className="h-4 w-32 bg-white/40" />
</>
) : (
<>
<div className="max-w-[10.5rem] truncate font-sans text-base font-semibold leading-none text-white dark:text-neutral-200">
{userName}
</div>
<div
data-testid="account-menu-user-email"
className="max-w-[10.5rem] truncate font-sans text-base font-normal leading-none text-white dark:text-neutral-400"
>
{userEmail}
</div>
</>
)}
</div>
</div>

View File

@@ -28,7 +28,9 @@ export function AccountLogoutOption() {
variant: "destructive",
});
} finally {
setIsLoggingOut(false);
setTimeout(() => {
setIsLoggingOut(false);
}, 3000);
}
}

View File

@@ -26,12 +26,19 @@ export function NavbarView({ isLoggedIn, previewBranchName }: NavbarViewProps) {
const dynamicMenuItems = getAccountMenuItems(user?.role);
const isChatEnabled = useGetFlag(Flag.CHAT);
const { data: profile } = useGetV2GetUserProfile({
query: {
select: (res) => (res.status === 200 ? res.data : null),
enabled: isLoggedIn,
const { data: profile, isLoading: isProfileLoading } = useGetV2GetUserProfile(
{
query: {
select: (res) => (res.status === 200 ? res.data : null),
enabled: isLoggedIn && !!user,
// Include user ID in query key to ensure cache invalidation when user changes
queryKey: ["/api/store/profile", user?.id],
},
},
});
);
const { isUserLoading } = useSupabase();
const isLoadingProfile = isProfileLoading || isUserLoading;
const linksWithChat = useMemo(() => {
const chatLink = { name: "Chat", href: "/chat" };
@@ -84,6 +91,7 @@ export function NavbarView({ isLoggedIn, previewBranchName }: NavbarViewProps) {
userEmail={profile?.name}
avatarSrc={profile?.avatar_url ?? ""}
menuItemGroups={dynamicMenuItems}
isLoading={isLoadingProfile}
/>
</div>
</div>

View File

@@ -3,8 +3,9 @@ import {
RefundRequest,
TransactionHistory,
} from "@/lib/autogpt-server-api/types";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
export default function useCredits({
fetchInitialCredits = false,
@@ -30,6 +31,7 @@ export default function useCredits({
fetchRefundRequests: () => void;
formatCredits: (credit: number | null) => string;
} {
const { isLoggedIn } = useSupabase();
const [credits, setCredits] = useState<number | null>(null);
const [autoTopUpConfig, setAutoTopUpConfig] = useState<{
amount: number;
@@ -40,49 +42,90 @@ export default function useCredits({
const router = useRouter();
const fetchCredits = useCallback(async () => {
const response = await api.getUserCredit();
setCredits(response.credits);
}, [api]);
if (!isLoggedIn) return;
try {
const response = await api.getUserCredit();
if (response) {
setCredits(response.credits);
} else {
setCredits(null);
}
} catch (error) {
console.error("Error fetching credits:", error);
setCredits(null);
}
}, [api, isLoggedIn]);
useEffect(() => {
if (!fetchInitialCredits) return;
if (!fetchInitialCredits || !isLoggedIn) return;
fetchCredits();
}, [fetchCredits, fetchInitialCredits]);
}, [fetchCredits, fetchInitialCredits, isLoggedIn]);
// Clear credits when user logs out
useEffect(() => {
if (!isLoggedIn) {
setCredits(null);
}
}, [isLoggedIn]);
const fetchAutoTopUpConfig = useCallback(async () => {
const response = await api.getAutoTopUpConfig();
setAutoTopUpConfig(response);
}, [api]);
if (!isLoggedIn) return;
try {
const response = await api.getAutoTopUpConfig();
setAutoTopUpConfig(response || null);
} catch (error) {
console.error("Error fetching auto top-up config:", error);
setAutoTopUpConfig(null);
}
}, [api, isLoggedIn]);
useEffect(() => {
if (!fetchInitialAutoTopUpConfig) return;
if (!fetchInitialAutoTopUpConfig || !isLoggedIn) return;
fetchAutoTopUpConfig();
}, [fetchAutoTopUpConfig, fetchInitialAutoTopUpConfig]);
}, [fetchAutoTopUpConfig, fetchInitialAutoTopUpConfig, isLoggedIn]);
// Clear auto top-up config when user logs out
useEffect(() => {
if (!isLoggedIn) {
setAutoTopUpConfig(null);
}
}, [isLoggedIn]);
const updateAutoTopUpConfig = useCallback(
async (amount: number, threshold: number) => {
if (!isLoggedIn) return;
await api.setAutoTopUpConfig({ amount, threshold });
fetchAutoTopUpConfig();
},
[api, fetchAutoTopUpConfig],
[api, fetchAutoTopUpConfig, isLoggedIn],
);
const requestTopUp = useCallback(
async (credit_amount: number) => {
if (!isLoggedIn) return;
const response = await api.requestTopUp(credit_amount);
router.push(response.checkout_url);
},
[api, router],
[api, router, isLoggedIn],
);
const refundTopUp = useCallback(
async (transaction_key: string, reason: string) => {
const refunded_amount = await api.refundTopUp(transaction_key, reason);
await fetchCredits();
setTransactionHistory(await api.getTransactionHistory());
return refunded_amount;
if (!isLoggedIn) return 0;
try {
const refunded_amount = await api.refundTopUp(transaction_key, reason);
await fetchCredits();
const history = await api.getTransactionHistory();
if (history) {
setTransactionHistory(history);
}
return refunded_amount;
} catch (error) {
console.error("Error refunding top-up:", error);
throw error;
}
},
[api, fetchCredits],
[api, fetchCredits, isLoggedIn],
);
const [transactionHistory, setTransactionHistory] =
@@ -92,38 +135,68 @@ export default function useCredits({
});
const fetchTransactionHistory = useCallback(async () => {
const response = await api.getTransactionHistory(
transactionHistory.next_transaction_time,
20,
);
setTransactionHistory({
transactions: [
...transactionHistory.transactions,
...response.transactions,
],
next_transaction_time: response.next_transaction_time,
});
}, [api, transactionHistory]);
if (!isLoggedIn) return;
try {
const response = await api.getTransactionHistory(
transactionHistory.next_transaction_time,
20,
);
if (response) {
setTransactionHistory({
transactions: [
...transactionHistory.transactions,
...response.transactions,
],
next_transaction_time: response.next_transaction_time,
});
}
} catch (error) {
console.error("Error fetching transaction history:", error);
}
}, [api, transactionHistory, isLoggedIn]);
useEffect(() => {
if (!fetchInitialTransactionHistory) return;
if (!fetchInitialTransactionHistory || !isLoggedIn) return;
fetchTransactionHistory();
// Note: We only need to fetch transaction history once.
// Hence, we should avoid `fetchTransactionHistory` to the dependency array.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchInitialTransactionHistory]);
}, [fetchInitialTransactionHistory, isLoggedIn]);
// Clear transaction history when user logs out
useEffect(() => {
if (!isLoggedIn) {
setTransactionHistory({
transactions: [],
next_transaction_time: null,
});
}
}, [isLoggedIn]);
const [refundRequests, setRefundRequests] = useState<RefundRequest[]>([]);
const fetchRefundRequests = useCallback(async () => {
const response = await api.getRefundRequests();
setRefundRequests(response);
}, [api]);
if (!isLoggedIn) return;
try {
const response = await api.getRefundRequests();
setRefundRequests(response || []);
} catch (error) {
console.error("Error fetching refund requests:", error);
setRefundRequests([]);
}
}, [api, isLoggedIn]);
useEffect(() => {
if (!fetchInitialRefundRequests) return;
if (!fetchInitialRefundRequests || !isLoggedIn) return;
fetchRefundRequests();
}, [fetchRefundRequests, fetchInitialRefundRequests]);
}, [fetchRefundRequests, fetchInitialRefundRequests, isLoggedIn]);
// Clear refund requests when user logs out
useEffect(() => {
if (!isLoggedIn) {
setRefundRequests([]);
}
}, [isLoggedIn]);
const formatCredits = useCallback((credit: number | null) => {
if (credit === null) {

View File

@@ -51,9 +51,11 @@ export async function fetchUser(): Promise<FetchUserResult> {
const { user, error } = await getCurrentUser();
if (error || !user) {
// Only mark as loaded if we got an explicit error (not just no user)
// This allows retrying when cookies aren't ready yet after login
return {
user: null,
hasLoadedUser: true,
hasLoadedUser: !!error, // Only true if there was an error, not just no user
isUserLoading: false,
};
}
@@ -68,7 +70,7 @@ export async function fetchUser(): Promise<FetchUserResult> {
console.error("Get user error:", error);
return {
user: null,
hasLoadedUser: true,
hasLoadedUser: true, // Error means we tried and failed, so mark as loaded
isUserLoading: false,
};
}

View File

@@ -7,6 +7,7 @@ import { create } from "zustand";
import { serverLogout, type ServerLogoutOptions } from "../actions";
import {
broadcastLogout,
isProtectedPage,
setWebSocketDisconnectIntent,
setupSessionEventListeners,
} from "../helpers";
@@ -77,11 +78,14 @@ export const useSupabaseStore = create<SupabaseStoreState>((set, get) => {
if (!get().hasLoadedUser || !get().user) {
set({ isUserLoading: true });
const result = await fetchUser();
// Always update state with fetch result
set(result);
// If fetchUser didn't return a user, validate the session to ensure we have the latest state
// This handles race conditions after login where cookies might not be immediately available
if (!result.user) {
if (!result.user && !result.hasLoadedUser) {
// Cookies might not be ready yet, retry validation
const validationResult = await validateSessionHelper({
pathname: params.pathname,
currentUser: null,
@@ -93,10 +97,69 @@ export const useSupabaseStore = create<SupabaseStoreState>((set, get) => {
hasLoadedUser: true,
isUserLoading: false,
});
} else if (!validationResult.isValid) {
// Session is invalid, mark as loaded so we don't keep retrying
set({
hasLoadedUser: true,
isUserLoading: false,
});
} else {
// Validation succeeded but no user - might be cookies not ready
// If we're on a protected page, schedule a retry since we should have a user
const isProtected = isProtectedPage(params.pathname);
if (isProtected && params.router) {
// Retry after a short delay to allow cookies to propagate
// Use router.refresh() to trigger a re-initialization
setTimeout(() => {
const currentState = get();
if (
!currentState.user &&
isProtectedPage(currentState.currentPathname)
) {
// Trigger router refresh to cause re-initialization
params.router.refresh();
}
}, 500);
}
// Don't mark as loaded yet, allow retry on next initialization
set({
isUserLoading: false,
});
}
} else if (!result.user && result.hasLoadedUser) {
// Explicit error or already marked as loaded - don't retry
set({
isUserLoading: false,
});
}
} else {
set({ isUserLoading: false });
// Even if we have a user, validate session to catch account switches
// This ensures that if user logged out and logged in with different account,
// we detect the change immediately
const currentUser = get().user;
if (currentUser) {
const validationResult = await validateSessionHelper({
pathname: params.pathname,
currentUser,
});
// Update user if IDs differ (account switch detected)
if (
validationResult.user &&
validationResult.isValid &&
validationResult.user.id !== currentUser.id
) {
set({
user: validationResult.user,
hasLoadedUser: true,
isUserLoading: false,
});
} else {
set({ isUserLoading: false });
}
} else {
set({ isUserLoading: false });
}
}
const existingCleanup = get().listenersCleanup;
@@ -140,10 +203,20 @@ export const useSupabaseStore = create<SupabaseStoreState>((set, get) => {
broadcastLogout();
// Clear React Query cache to prevent stale data from old user
if (typeof window !== "undefined") {
const { getQueryClient } = await import("@/lib/react-query/queryClient");
const queryClient = getQueryClient();
queryClient.clear();
}
// Reset all state to ensure fresh initialization on next login
set({
user: null,
hasLoadedUser: false,
isUserLoading: false,
initializationPromise: null, // Force fresh initialization
lastValidation: 0, // Reset validation timestamp
});
await serverLogout(options);
@@ -186,15 +259,47 @@ export const useSupabaseStore = create<SupabaseStoreState>((set, get) => {
return false;
}
if (result.user && result.shouldUpdateUser) {
set({ user: result.user });
}
// Always update user if:
// 1. We got a user and current user is null (login scenario)
// 2. We got a user and IDs differ (account switch scenario)
// 3. shouldUpdateUser is true (session validation detected change)
if (result.user) {
set({
hasLoadedUser: true,
isUserLoading: false,
});
const currentUser = get().user;
const shouldUpdate =
!currentUser ||
currentUser.id !== result.user.id ||
result.shouldUpdateUser;
if (shouldUpdate) {
// Invalidate profile query when user changes to ensure fresh data
if (
typeof window !== "undefined" &&
currentUser?.id !== result.user.id
) {
const { getQueryClient } = await import(
"@/lib/react-query/queryClient"
);
const { getGetV2GetUserProfileQueryKey } = await import(
"@/app/api/__generated__/endpoints/store/store"
);
const queryClient = getQueryClient();
queryClient.invalidateQueries({
queryKey: getGetV2GetUserProfileQueryKey(),
});
}
set({
user: result.user,
hasLoadedUser: true,
isUserLoading: false,
});
} else {
// Even if user didn't change, ensure loading state is cleared
set({
hasLoadedUser: true,
isUserLoading: false,
});
}
}
return true;