From 3044476a66647031c4adf99fbc56006591acc48b Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Fri, 21 Mar 2025 22:04:12 -0400 Subject: [PATCH 01/12] Fix refresh token --- frontend/src/lib/client.ts | 142 +++++++++++++++++++++++- frontend/src/providers/AuthProvider.tsx | 26 +++-- 2 files changed, 156 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/client.ts b/frontend/src/lib/client.ts index af8fc07c..65acc988 100644 --- a/frontend/src/lib/client.ts +++ b/frontend/src/lib/client.ts @@ -6,15 +6,38 @@ import { ApolloLink, from, split, + Operation, } from '@apollo/client'; import { onError } from '@apollo/client/link/error'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; import { createClient } from 'graphql-ws'; -import { getMainDefinition } from '@apollo/client/utilities'; +import { getMainDefinition, Observable } from '@apollo/client/utilities'; import createUploadLink from 'apollo-upload-client/createUploadLink.mjs'; import { LocalStore } from '@/lib/storage'; import { logger } from '@/app/log/logger'; +// Token refresh state management +let isRefreshing = false; +let pendingRequests: Array<{ + operation: Operation; + forward: any; + observer: any; +}> = []; + +// Function to refresh token - will be set by AuthProvider +let refreshTokenFunction: () => Promise; +let logoutFunction: () => void; + +// Function to register the token refresh function +export const registerRefreshTokenFunction = ( + refreshFn: () => Promise, + logout: () => void +) => { + refreshTokenFunction = refreshFn; + logoutFunction = logout; +}; + + // Create the upload link as the terminating link const uploadLink = createUploadLink({ uri: process.env.NEXT_PUBLIC_GRAPHQL_URL || 'http://localhost:8080/graphql', @@ -72,8 +95,123 @@ const authMiddleware = new ApolloLink((operation, forward) => { return forward(operation); }); +// Function to retry failed operations with new token +const retryFailedOperations = () => { + const requests = [...pendingRequests]; + pendingRequests = []; + + requests.forEach(({ operation, forward, observer }) => { + // Update the authorization header with the new token + const token = localStorage.getItem(LocalStore.accessToken); + if (token) { + operation.setContext(({ headers = {} }) => ({ + headers: { + ...headers, + Authorization: `Bearer ${token}`, + }, + })); + } + + // Retry the operation + forward(operation).subscribe({ + next: observer.next.bind(observer), + error: observer.error.bind(observer), + complete: observer.complete.bind(observer), + }); + }); +}; + // Error Link -const errorLink = onError(({ graphQLErrors, networkError }) => { +// Error Link with Token Refresh Logic +const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => { + const isAuthError = graphQLErrors?.some(error => + error.extensions?.code === 'UNAUTHENTICATED' || + error.message.includes('not authenticated') || + error.message.includes('jwt expired') + ) || networkError?.name === 'ServerError' && (networkError as any).statusCode === 401; + + if (isAuthError) { + // Check if we have a refresh token + const hasRefreshToken = !!localStorage.getItem(LocalStore.refreshToken); + + if (!hasRefreshToken || !refreshTokenFunction) { + // No refresh token or refresh function - logout + if (logoutFunction) { + logoutFunction(); + } + if (typeof window !== 'undefined') { + window.location.href = '/'; + } + return; + } + + // Return a new observable to handle the retry logic + return new Observable(observer => { + // If we're already refreshing, queue this request + if (isRefreshing) { + pendingRequests.push({ operation, forward, observer }); + } else { + isRefreshing = true; + + // Try to refresh the token + refreshTokenFunction() + .then(success => { + isRefreshing = false; + + if (success) { + // Retry this operation + const token = localStorage.getItem(LocalStore.accessToken); + if (token) { + operation.setContext(({ headers = {} }) => ({ + headers: { + ...headers, + Authorization: `Bearer ${token}`, + }, + })); + } + + // Retry all pending operations + retryFailedOperations(); + + // Retry the current operation + forward(operation).subscribe({ + next: observer.next.bind(observer), + error: observer.error.bind(observer), + complete: observer.complete.bind(observer), + }); + } else { + // Refresh failed - redirect to homepage + if (logoutFunction) { + logoutFunction(); + } + if (typeof window !== 'undefined') { + window.location.href = '/'; + } + + // Complete the operation + observer.error(new Error('Session expired. Please log in again.')); + } + }) + .catch(error => { + isRefreshing = false; + logger.error('Token refresh failed:', error); + + // Refresh failed - redirect to homepage + if (logoutFunction) { + logoutFunction(); + } + if (typeof window !== 'undefined') { + window.location.href = '/'; + } + + // Complete the operation + observer.error(new Error('Session expired. Please log in again.')); + }); + } + }); + } + + // Handle other errors if (graphQLErrors) { graphQLErrors.forEach(({ message, locations, path }) => { logger.error( diff --git a/frontend/src/providers/AuthProvider.tsx b/frontend/src/providers/AuthProvider.tsx index b4c5f8ad..114a66d9 100644 --- a/frontend/src/providers/AuthProvider.tsx +++ b/frontend/src/providers/AuthProvider.tsx @@ -14,6 +14,7 @@ import { LocalStore } from '@/lib/storage'; import { LoadingPage } from '@/components/global-loading'; import { User } from '@/graphql/type'; import { logger } from '@/app/log/logger'; +import { registerRefreshTokenFunction } from '@/lib/client'; interface AuthContextValue { isAuthorized: boolean; @@ -121,6 +122,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, [refreshTokenMutation]); + const logout = useCallback(() => { + setToken(null); + setIsAuthorized(false); + setUser(null); + localStorage.removeItem(LocalStore.accessToken); + localStorage.removeItem(LocalStore.refreshToken); + }, []); + const login = useCallback( (accessToken: string, refreshToken: string) => { localStorage.setItem(LocalStore.accessToken, accessToken); @@ -136,13 +145,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { [fetchUserInfo] ); - const logout = useCallback(() => { - setToken(null); - setIsAuthorized(false); - setUser(null); - localStorage.removeItem(LocalStore.accessToken); - localStorage.removeItem(LocalStore.refreshToken); - }, []); + // Register the refresh token function with Apollo Client + useEffect(() => { + registerRefreshTokenFunction(refreshAccessToken, logout); + }, [refreshAccessToken, logout]); useEffect(() => { async function initAuth() { @@ -159,12 +165,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { let isValid = await validateToken(); - // 如果验证失败,再试图刷新 + // If validation fails, try to refresh if (!isValid) { isValid = (await refreshAccessToken()) ? true : false; } - // 最终判断 + // Final check if (isValid) { setIsAuthorized(true); await fetchUserInfo(); @@ -204,4 +210,4 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { export function useAuthContext() { return useContext(AuthContext); -} +} \ No newline at end of file From 8d9135ab57e133a5d672e1331265d19ed7214e50 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Sat, 22 Mar 2025 16:32:00 -0400 Subject: [PATCH 02/12] redirect back to home page --- frontend/src/providers/AuthProvider.tsx | 31 +++++++++++++------------ 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/frontend/src/providers/AuthProvider.tsx b/frontend/src/providers/AuthProvider.tsx index 114a66d9..6c8e4572 100644 --- a/frontend/src/providers/AuthProvider.tsx +++ b/frontend/src/providers/AuthProvider.tsx @@ -14,7 +14,7 @@ import { LocalStore } from '@/lib/storage'; import { LoadingPage } from '@/components/global-loading'; import { User } from '@/graphql/type'; import { logger } from '@/app/log/logger'; -import { registerRefreshTokenFunction } from '@/lib/client'; +import { useRouter } from 'next/navigation'; interface AuthContextValue { isAuthorized: boolean; @@ -45,6 +45,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [isAuthorized, setIsAuthorized] = useState(false); const [isLoading, setIsLoading] = useState(true); const [user, setUser] = useState(null); + const router = useRouter(); const [checkToken] = useLazyQuery<{ checkToken: boolean }>(CHECK_TOKEN_QUERY); const [refreshTokenMutation] = useMutation(REFRESH_TOKEN_MUTATION); @@ -122,14 +123,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, [refreshTokenMutation]); - const logout = useCallback(() => { - setToken(null); - setIsAuthorized(false); - setUser(null); - localStorage.removeItem(LocalStore.accessToken); - localStorage.removeItem(LocalStore.refreshToken); - }, []); - const login = useCallback( (accessToken: string, refreshToken: string) => { localStorage.setItem(LocalStore.accessToken, accessToken); @@ -145,10 +138,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { [fetchUserInfo] ); - // Register the refresh token function with Apollo Client - useEffect(() => { - registerRefreshTokenFunction(refreshAccessToken, logout); - }, [refreshAccessToken, logout]); + const logout = useCallback(() => { + setToken(null); + setIsAuthorized(false); + setUser(null); + localStorage.removeItem(LocalStore.accessToken); + localStorage.removeItem(LocalStore.refreshToken); + + // Redirect to home page after logout + if (typeof window !== 'undefined') { + router.push('/'); + } + }, [router]); useEffect(() => { async function initAuth() { @@ -170,7 +171,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { isValid = (await refreshAccessToken()) ? true : false; } - // Final check + // Final decision if (isValid) { setIsAuthorized(true); await fetchUserInfo(); @@ -210,4 +211,4 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { export function useAuthContext() { return useContext(AuthContext); -} \ No newline at end of file +} From c42e53574a031634e40db740faf5b1c26d6d63e5 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Sat, 22 Mar 2025 16:35:20 -0400 Subject: [PATCH 03/12] make client do auto refresh token --- frontend/src/lib/client.ts | 240 +++++++++++++++++-------------------- 1 file changed, 112 insertions(+), 128 deletions(-) diff --git a/frontend/src/lib/client.ts b/frontend/src/lib/client.ts index 65acc988..ddd0b2dc 100644 --- a/frontend/src/lib/client.ts +++ b/frontend/src/lib/client.ts @@ -6,37 +6,17 @@ import { ApolloLink, from, split, - Operation, + Observable, + FetchResult, } from '@apollo/client'; import { onError } from '@apollo/client/link/error'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; import { createClient } from 'graphql-ws'; -import { getMainDefinition, Observable } from '@apollo/client/utilities'; +import { getMainDefinition } from '@apollo/client/utilities'; import createUploadLink from 'apollo-upload-client/createUploadLink.mjs'; import { LocalStore } from '@/lib/storage'; import { logger } from '@/app/log/logger'; - -// Token refresh state management -let isRefreshing = false; -let pendingRequests: Array<{ - operation: Operation; - forward: any; - observer: any; -}> = []; - -// Function to refresh token - will be set by AuthProvider -let refreshTokenFunction: () => Promise; -let logoutFunction: () => void; - -// Function to register the token refresh function -export const registerRefreshTokenFunction = ( - refreshFn: () => Promise, - logout: () => void -) => { - refreshTokenFunction = refreshFn; - logoutFunction = logout; -}; - +import { REFRESH_TOKEN_MUTATION } from '@/graphql/mutations/auth'; // Create the upload link as the terminating link const uploadLink = createUploadLink({ @@ -95,123 +75,126 @@ const authMiddleware = new ApolloLink((operation, forward) => { return forward(operation); }); -// Function to retry failed operations with new token -const retryFailedOperations = () => { - const requests = [...pendingRequests]; - pendingRequests = []; - - requests.forEach(({ operation, forward, observer }) => { - // Update the authorization header with the new token - const token = localStorage.getItem(LocalStore.accessToken); - if (token) { - operation.setContext(({ headers = {} }) => ({ - headers: { - ...headers, - Authorization: `Bearer ${token}`, - }, - })); +// Refresh Token Handling +const refreshToken = async (): Promise => { + try { + const refreshToken = localStorage.getItem(LocalStore.refreshToken); + if (!refreshToken) { + return null; } - - // Retry the operation - forward(operation).subscribe({ - next: observer.next.bind(observer), - error: observer.error.bind(observer), - complete: observer.complete.bind(observer), + + console.debug('start refreshToken mutate'); + + // Use the main client for the refresh token request + // The tokenRefreshLink will skip refresh attempts for this operation + const result = await client.mutate({ + mutation: REFRESH_TOKEN_MUTATION, + variables: { refreshToken }, }); - }); -}; -// Error Link -// Error Link with Token Refresh Logic -const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => { - const isAuthError = graphQLErrors?.some(error => - error.extensions?.code === 'UNAUTHENTICATED' || - error.message.includes('not authenticated') || - error.message.includes('jwt expired') - ) || networkError?.name === 'ServerError' && (networkError as any).statusCode === 401; + if (result.data?.refreshToken?.accessToken) { + const newAccessToken = result.data.refreshToken.accessToken; + const newRefreshToken = + result.data.refreshToken.refreshToken || refreshToken; - if (isAuthError) { - // Check if we have a refresh token - const hasRefreshToken = !!localStorage.getItem(LocalStore.refreshToken); - - if (!hasRefreshToken || !refreshTokenFunction) { - // No refresh token or refresh function - logout - if (logoutFunction) { - logoutFunction(); - } - if (typeof window !== 'undefined') { - window.location.href = '/'; - } - return; + localStorage.setItem(LocalStore.accessToken, newAccessToken); + localStorage.setItem(LocalStore.refreshToken, newRefreshToken); + + logger.info('Token refreshed successfully'); + return newAccessToken; } - // Return a new observable to handle the retry logic - return new Observable(observer => { - // If we're already refreshing, queue this request - if (isRefreshing) { - pendingRequests.push({ operation, forward, observer }); - } else { - isRefreshing = true; - - // Try to refresh the token - refreshTokenFunction() - .then(success => { - isRefreshing = false; - - if (success) { - // Retry this operation - const token = localStorage.getItem(LocalStore.accessToken); - if (token) { - operation.setContext(({ headers = {} }) => ({ + return null; + } catch (error) { + logger.error('Error refreshing token:', error); + return null; + } +}; + +// Handle token expiration and refresh +const tokenRefreshLink = onError( + ({ graphQLErrors, networkError, operation, forward }) => { + if (graphQLErrors) { + for (const err of graphQLErrors) { + // Check for auth errors (adjust this check based on your API's error structure) + const isAuthError = + err.extensions?.code === 'UNAUTHENTICATED' || + err.message.includes('Unauthorized') || + err.message.includes('token expired'); + + // Don't try to refresh if this operation is the refresh token mutation + // This prevents infinite refresh loops + const operationName = operation.operationName; + const path = err.path; + const isRefreshTokenOperation = + operationName === 'RefreshToken' || + (path && path.includes('refreshToken')); + + if (isAuthError && !isRefreshTokenOperation) { + logger.info('Auth error detected, attempting token refresh'); + + // Return a new observable to handle the token refresh + return new Observable((observer) => { + // Attempt to refresh the token + (async () => { + try { + const newToken = await refreshToken(); + + if (!newToken) { + // If refresh fails, clear tokens and redirect + localStorage.removeItem(LocalStore.accessToken); + localStorage.removeItem(LocalStore.refreshToken); + + // Redirect to home/login page when running in browser + if (typeof window !== 'undefined') { + logger.warn( + 'Token refresh failed, redirecting to home page' + ); + window.location.href = '/'; + } + + // Complete the observer with the original error + observer.error(err); + observer.complete(); + return; + } + + // Retry the operation with the new token + // Clone the operation with the new token + const oldHeaders = operation.getContext().headers; + operation.setContext({ headers: { - ...headers, - Authorization: `Bearer ${token}`, + ...oldHeaders, + Authorization: `Bearer ${newToken}`, }, - })); - } - - // Retry all pending operations - retryFailedOperations(); - - // Retry the current operation - forward(operation).subscribe({ - next: observer.next.bind(observer), - error: observer.error.bind(observer), - complete: observer.complete.bind(observer), - }); - } else { - // Refresh failed - redirect to homepage - if (logoutFunction) { - logoutFunction(); - } - if (typeof window !== 'undefined') { - window.location.href = '/'; + }); + + // Retry the request + forward(operation).subscribe({ + next: observer.next.bind(observer), + error: observer.error.bind(observer), + complete: observer.complete.bind(observer), + }); + } catch (error) { + logger.error('Error in token refresh flow:', error); + observer.error(error); + observer.complete(); } - - // Complete the operation - observer.error(new Error('Session expired. Please log in again.')); - } - }) - .catch(error => { - isRefreshing = false; - logger.error('Token refresh failed:', error); - - // Refresh failed - redirect to homepage - if (logoutFunction) { - logoutFunction(); - } - if (typeof window !== 'undefined') { - window.location.href = '/'; - } - - // Complete the operation - observer.error(new Error('Session expired. Please log in again.')); + })(); }); + } } - }); + } + + if (networkError) { + logger.error(`[Network error]: ${networkError}`); + // Handle network errors if needed + } } +); - // Handle other errors +// Error Link +const errorLink = onError(({ graphQLErrors, networkError }) => { if (graphQLErrors) { graphQLErrors.forEach(({ message, locations, path }) => { logger.error( @@ -226,6 +209,7 @@ const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) // Build the HTTP link chain const httpLinkWithMiddleware = from([ + tokenRefreshLink, // Add token refresh link first errorLink, requestLoggingMiddleware, authMiddleware, From 3133420fab50bdceec78383683e1b082dd597133 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Sat, 22 Mar 2025 21:03:31 -0400 Subject: [PATCH 04/12] handle restul api --- frontend/src/lib/authenticatedFetch.ts | 111 +++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 frontend/src/lib/authenticatedFetch.ts diff --git a/frontend/src/lib/authenticatedFetch.ts b/frontend/src/lib/authenticatedFetch.ts new file mode 100644 index 00000000..4e28456e --- /dev/null +++ b/frontend/src/lib/authenticatedFetch.ts @@ -0,0 +1,111 @@ +import { LocalStore } from '@/lib/storage'; +import { client } from '@/lib/client'; +import { REFRESH_TOKEN_MUTATION } from '@/graphql/mutations/auth'; + +// Prevent multiple simultaneous refresh attempts +let isRefreshing = false; +let refreshPromise: Promise | null = null; + +/** + * Refreshes the access token using the refresh token + * @returns Promise that resolves to the new token or null if refresh failed + */ +export const refreshAccessToken = async (): Promise => { + // If a refresh is already in progress, return that promise + if (isRefreshing && refreshPromise) { + return refreshPromise; + } + + isRefreshing = true; + refreshPromise = (async () => { + try { + const refreshToken = localStorage.getItem(LocalStore.refreshToken); + if (!refreshToken) { + return null; + } + + // Use Apollo client to refresh the token + const result = await client.mutate({ + mutation: REFRESH_TOKEN_MUTATION, + variables: { refreshToken }, + }); + + if (result.data?.refreshToken?.accessToken) { + const newAccessToken = result.data.refreshToken.accessToken; + const newRefreshToken = + result.data.refreshToken.refreshToken || refreshToken; + + localStorage.setItem(LocalStore.accessToken, newAccessToken); + localStorage.setItem(LocalStore.refreshToken, newRefreshToken); + + console.log('Token refreshed successfully'); + return newAccessToken; + } + + return null; + } catch (error) { + console.error('Error refreshing token:', error); + return null; + } finally { + isRefreshing = false; + refreshPromise = null; + } + })(); + + return refreshPromise; +}; + +/** + * Fetch wrapper that handles authentication and token refresh + * @param url The URL to fetch + * @param options Fetch options + * @param retryOnAuth Whether to retry on 401 errors (default: true) + * @returns Response from the fetch request + */ +export const authenticatedFetch = async ( + url: string, + options: RequestInit = {}, + retryOnAuth: boolean = true +): Promise => { + // Get current token + const token = localStorage.getItem(LocalStore.accessToken); + + // Setup headers with authentication + const headers = new Headers(options.headers || {}); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + // Make the request + const response = await fetch(url, { + ...options, + headers, + }); + + // If we get a 401 and we should retry, attempt to refresh the token + if (response.status === 401 && retryOnAuth) { + const newToken = await refreshAccessToken(); + + if (newToken) { + // Update the authorization header with the new token + headers.set('Authorization', `Bearer ${newToken}`); + + // Retry the request with the new token + return fetch(url, { + ...options, + headers, + }); + } else { + // If refresh failed, redirect to home/login + if (typeof window !== 'undefined') { + localStorage.removeItem(LocalStore.accessToken); + localStorage.removeItem(LocalStore.refreshToken); + window.location.href = '/'; + } + } + } + + return response; +}; + +export default authenticatedFetch; From cdbd26669eced97705e76fec671abad45aad5a92 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Sat, 22 Mar 2025 21:23:24 -0400 Subject: [PATCH 05/12] stream support --- frontend/src/lib/authenticatedFetch.ts | 71 ++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/frontend/src/lib/authenticatedFetch.ts b/frontend/src/lib/authenticatedFetch.ts index 4e28456e..e99969b2 100644 --- a/frontend/src/lib/authenticatedFetch.ts +++ b/frontend/src/lib/authenticatedFetch.ts @@ -1,6 +1,7 @@ import { LocalStore } from '@/lib/storage'; import { client } from '@/lib/client'; import { REFRESH_TOKEN_MUTATION } from '@/graphql/mutations/auth'; +import { gql } from '@apollo/client'; // Prevent multiple simultaneous refresh attempts let isRefreshing = false; @@ -108,4 +109,74 @@ export const authenticatedFetch = async ( return response; }; +/** + * Processes a streaming response from a server-sent events endpoint + * @param response Fetch Response object (must be a streaming response) + * @param onChunk Optional callback to process each chunk as it arrives + * @returns Promise with the full aggregated content + */ +export const processStreamResponse = async ( + response: Response, + onChunk?: (chunk: string) => void +): Promise => { + if (!response.body) { + throw new Error('Response has no body'); + } + + const reader = response.body.getReader(); + let fullContent = ''; + let isStreamDone = false; + + try { + // More explicit condition than while(true) + while (!isStreamDone) { + const { done, value } = await reader.read(); + + if (done) { + isStreamDone = true; + continue; + } + + const text = new TextDecoder().decode(value); + const lines = text.split('\n\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6).trim(); + + // Additional exit condition + if (data === '[DONE]') { + isStreamDone = true; + break; + } + + try { + const parsed = JSON.parse(data); + if (parsed.content) { + fullContent += parsed.content; + if (onChunk) { + onChunk(parsed.content); + } + } + } catch (e) { + console.error('Error parsing SSE data:', e); + } + } + } + } + + return fullContent; + } catch (error) { + console.error('Error reading stream:', error); + throw error; + } finally { + // Ensure we clean up the reader if we exit due to an error + if (!isStreamDone) { + reader + .cancel() + .catch((e) => console.error('Error cancelling reader:', e)); + } + } +}; + export default authenticatedFetch; From 2b0104cfacc695f9103431c0e700fc87cb368048 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Sat, 22 Mar 2025 21:23:38 -0400 Subject: [PATCH 06/12] change to auth fetch --- frontend/src/api/ChatStreamAPI.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/api/ChatStreamAPI.ts b/frontend/src/api/ChatStreamAPI.ts index 1caff4c9..6444eb95 100644 --- a/frontend/src/api/ChatStreamAPI.ts +++ b/frontend/src/api/ChatStreamAPI.ts @@ -1,4 +1,5 @@ import { ChatInputType } from '@/graphql/type'; +import authenticatedFetch from '@/lib/authenticatedFetch'; export const startChatStream = async ( input: ChatInputType, @@ -9,7 +10,7 @@ export const startChatStream = async ( throw new Error('Not authenticated'); } const { chatId, message, model } = input; - const response = await fetch('/api/chat', { + const response = await authenticatedFetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', From ab40e6ffb096308f369169ab4e5a3fa61185dfab Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Sat, 22 Mar 2025 21:24:34 -0400 Subject: [PATCH 07/12] add network error handling --- frontend/src/lib/client.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/client.ts b/frontend/src/lib/client.ts index ddd0b2dc..d220cd44 100644 --- a/frontend/src/lib/client.ts +++ b/frontend/src/lib/client.ts @@ -188,7 +188,25 @@ const tokenRefreshLink = onError( if (networkError) { logger.error(`[Network error]: ${networkError}`); - // Handle network errors if needed + + // For network errors related to authentication endpoints, handle logout + const networkErrorOperation = operation.operationName; + if ( + networkErrorOperation === 'RefreshToken' || + networkErrorOperation === 'Login' || + networkErrorOperation === 'ValidateToken' + ) { + // Only redirect for auth-related network errors + if (typeof window !== 'undefined') { + localStorage.removeItem(LocalStore.accessToken); + localStorage.removeItem(LocalStore.refreshToken); + + logger.warn( + 'Network error during authentication, redirecting to home' + ); + window.location.href = '/'; + } + } } } ); From b41d0ecb67e856ea46491e1dc3973f5c3ae40953 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Sat, 22 Mar 2025 21:26:52 -0400 Subject: [PATCH 08/12] add auth fetch --- .../chat/code-engine/responsive-toolbar.tsx | 137 +++++++++++------- 1 file changed, 82 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx index 78fc8416..b4194001 100644 --- a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx +++ b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx @@ -17,6 +17,7 @@ import { logger } from '@/app/log/logger'; import { useMutation, useQuery, gql } from '@apollo/client'; import { toast } from 'sonner'; import { SYNC_PROJECT_TO_GITHUB, GET_PROJECT } from '../../../graphql/request'; +import { authenticatedFetch } from '@/lib/authenticatedFetch'; interface ResponsiveToolbarProps { isLoading: boolean; @@ -37,43 +38,48 @@ const ResponsiveToolbar = ({ const [compactIcons, setCompactIcons] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const { token, user, refreshUserInfo } = useAuthContext(); - + // Poll for GitHub installation status when needed const [isPollingGitHub, setIsPollingGitHub] = useState(false); - + // Apollo mutations and queries - const [syncProject, { loading: isPublishingToGitHub }] = useMutation(SYNC_PROJECT_TO_GITHUB, { - onCompleted: (data) => { - - const syncResult = data.syncProjectToGitHub; - - toast.success('Successfully published to GitHub!'); - - // Offer to open the repo in a new tab - const repoUrl = syncResult.githubRepoUrl; - console.log('GitHub repo URL:', repoUrl); - if (repoUrl) { - const shouldOpen = window.confirm('Would you like to open the GitHub repository?'); - if (shouldOpen) { - window.open(repoUrl, '_blank'); + const [syncProject, { loading: isPublishingToGitHub }] = useMutation( + SYNC_PROJECT_TO_GITHUB, + { + onCompleted: (data) => { + const syncResult = data.syncProjectToGitHub; + + toast.success('Successfully published to GitHub!'); + + // Offer to open the repo in a new tab + const repoUrl = syncResult.githubRepoUrl; + console.log('GitHub repo URL:', repoUrl); + if (repoUrl) { + const shouldOpen = window.confirm( + 'Would you like to open the GitHub repository?' + ); + if (shouldOpen) { + window.open(repoUrl, '_blank'); + } } - } - }, - onError: (error) => { - logger.error('Error publishing to GitHub:', error); - toast.error(`Error publishing to GitHub: ${error.message}`); + }, + onError: (error) => { + logger.error('Error publishing to GitHub:', error); + toast.error(`Error publishing to GitHub: ${error.message}`); + }, } - }); - + ); + // Query to check if the project is already synced const { data: projectData } = useQuery(GET_PROJECT, { variables: { projectId }, skip: !projectId, fetchPolicy: 'cache-and-network', }); - + // Determine if GitHub sync is complete based on query data - const isGithubSyncComplete = projectData?.getProject?.isSyncedWithGitHub || false; + const isGithubSyncComplete = + projectData?.getProject?.isSyncedWithGitHub || false; const githubRepoUrl = projectData?.getProject?.githubRepoUrl || ''; @@ -111,14 +117,14 @@ const ResponsiveToolbar = ({ // Poll for GitHub installation completion useEffect(() => { let pollInterval: NodeJS.Timeout; - + if (isPollingGitHub) { pollInterval = setInterval(async () => { console.log('Polling backend for GitHub installation status...'); try { // Call to refresh user info (from backend) await refreshUserInfo(); - + // Check if user now has installation ID if (user?.githubInstallationId) { console.log('GitHub installation complete!'); @@ -131,7 +137,7 @@ const ResponsiveToolbar = ({ } }, 3000); // Poll every 3s } - + return () => { if (pollInterval) clearInterval(pollInterval); }; @@ -140,7 +146,7 @@ const ResponsiveToolbar = ({ const handlePublishToGitHub = async () => { // If already publishing, do nothing if (isPublishingToGitHub) return; - + // If the user hasn't installed the GitHub App yet if (!user?.githubInstallationId) { try { @@ -148,11 +154,11 @@ const ResponsiveToolbar = ({ const shouldInstall = window.confirm( 'You need to install the GitHub App to publish your project. Would you like to do this now?' ); - + if (shouldInstall) { // Start polling for installation completion setIsPollingGitHub(true); - + // This format ensures GitHub will prompt the user to choose where to install const installUrl = `https://github.com/apps/codefox-project-fork/installations/new`; window.open(installUrl, '_blank'); @@ -161,32 +167,36 @@ const ResponsiveToolbar = ({ } catch (error) { logger.error('Error opening GitHub installation:', error); setIsPollingGitHub(false); - toast.error('Error opening GitHub installation page. Please try again.'); + toast.error( + 'Error opening GitHub installation page. Please try again.' + ); return; } } - + // Ensure we have a project ID if (!projectId) { toast.error('Cannot publish: No project ID available'); return; } - + // If already synced and we have the URL, offer to open it if (isGithubSyncComplete && githubRepoUrl) { - const shouldOpen = window.confirm('This project is already published to GitHub. Would you like to open the repository?'); + const shouldOpen = window.confirm( + 'This project is already published to GitHub. Would you like to open the repository?' + ); if (shouldOpen) { window.open(projectData.getProject.githubRepoUrl, '_blank'); } return; } - + // Execute the mutation try { await syncProject({ variables: { - projectId - } + projectId, + }, }); } catch (error) { // Error is handled by the mutation's onError callback @@ -202,7 +212,8 @@ const ResponsiveToolbar = ({ // Create a hidden anchor element for download const a = document.createElement('a'); - const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8080'; + const backendUrl = + process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8080'; // Set the download URL with credentials included const downloadUrl = `${backendUrl}/download/project/${projectId}`; @@ -212,9 +223,14 @@ const ResponsiveToolbar = ({ } // Fetch with credentials to ensure auth is included - const response = await fetch(downloadUrl, { + // const response = await fetch(downloadUrl, { + // method: 'GET', + // headers: headers, + // }); + + // Use authenticatedFetch which handles token refresh + const response = await authenticatedFetch(downloadUrl, { method: 'GET', - headers: headers, }); if (!response.ok) { @@ -234,9 +250,10 @@ const ResponsiveToolbar = ({ const contentDisposition = response.headers.get('Content-Disposition'); const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; const matches = filenameRegex.exec(contentDisposition || ''); - const filename = matches && matches[1] - ? matches[1].replace(/['"]/g, '') - : `project-${projectId}.zip`; + const filename = + matches && matches[1] + ? matches[1].replace(/['"]/g, '') + : `project-${projectId}.zip`; a.download = filename; @@ -352,9 +369,14 @@ const ResponsiveToolbar = ({ Download )} @@ -371,9 +393,9 @@ const ResponsiveToolbar = ({ - -