From a58a49aeb6060047f16a18c166d41d55a02b6dba Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Fri, 29 Aug 2025 10:18:02 -0600 Subject: [PATCH] fix: resolve authentication race condition with React Query Previously, API calls were being made before the auth token was configured, causing initial 401 errors that would retry with 200 after token setup. Changes: - Add global auth readiness tracking in apiClient - Create useAuthReady hook that checks both session and token state - Update all API hooks to use isAuthReady instead of just session status - Add AuthWrapper component at layout level for consistent loading UX - Show spinner while authentication initializes across all pages This ensures API calls only fire after authentication is fully configured, eliminating the 401/retry pattern and improving user experience. --- www/app/(app)/AuthWrapper.tsx | 28 ++++++++++ www/app/(app)/layout.tsx | 3 +- .../(app)/transcripts/[transcriptId]/page.tsx | 4 +- www/app/(app)/transcripts/useTranscript.ts | 10 ++++ www/app/lib/api-hooks.ts | 51 +++++++++---------- www/app/lib/apiClient.tsx | 7 ++- www/app/lib/useAuthReady.ts | 43 ++++++++++++++++ 7 files changed, 116 insertions(+), 30 deletions(-) create mode 100644 www/app/(app)/AuthWrapper.tsx create mode 100644 www/app/lib/useAuthReady.ts diff --git a/www/app/(app)/AuthWrapper.tsx b/www/app/(app)/AuthWrapper.tsx new file mode 100644 index 00000000..8af78f81 --- /dev/null +++ b/www/app/(app)/AuthWrapper.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Flex, Spinner } from "@chakra-ui/react"; +import useAuthReady from "../lib/useAuthReady"; + +export default function AuthWrapper({ + children, +}: { + children: React.ReactNode; +}) { + const { isAuthReady, isLoading } = useAuthReady(); + + // Show spinner while auth is loading + if (isLoading || !isAuthReady) { + return ( + + + + ); + } + + return <>{children}; +} diff --git a/www/app/(app)/layout.tsx b/www/app/(app)/layout.tsx index 053df5a7..8822214b 100644 --- a/www/app/(app)/layout.tsx +++ b/www/app/(app)/layout.tsx @@ -5,6 +5,7 @@ import Image from "next/image"; import About from "../(aboutAndPrivacy)/about"; import Privacy from "../(aboutAndPrivacy)/privacy"; import UserInfo from "../(auth)/userInfo"; +import AuthWrapper from "./AuthWrapper"; import { RECORD_A_MEETING_URL } from "../lib/constants"; export default async function AppLayout({ @@ -90,7 +91,7 @@ export default async function AppLayout({ - {children} + {children} ); } diff --git a/www/app/(app)/transcripts/[transcriptId]/page.tsx b/www/app/(app)/transcripts/[transcriptId]/page.tsx index 0a2dba47..3e55f5cb 100644 --- a/www/app/(app)/transcripts/[transcriptId]/page.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/page.tsx @@ -86,7 +86,7 @@ export default function TranscriptDetails(details: TranscriptDetails) { useActiveTopic={useActiveTopic} waveform={waveform.waveform} media={mp3.media} - mediaDuration={transcript.response.duration} + mediaDuration={transcript.response?.duration} /> ) : !mp3.loading && (waveform.error || mp3.error) ? ( @@ -116,7 +116,7 @@ export default function TranscriptDetails(details: TranscriptDetails) { { transcript.reload(); diff --git a/www/app/(app)/transcripts/useTranscript.ts b/www/app/(app)/transcripts/useTranscript.ts index c26ca89c..7872bd09 100644 --- a/www/app/(app)/transcripts/useTranscript.ts +++ b/www/app/(app)/transcripts/useTranscript.ts @@ -46,6 +46,16 @@ const useTranscript = ( }; } + // Check if data is undefined or null + if (!data) { + return { + response: null, + loading: true, + error: false, + reload: refetch, + }; + } + return { response: data as GetTranscript, loading: false, diff --git a/www/app/lib/api-hooks.ts b/www/app/lib/api-hooks.ts index d2565f09..dd27e131 100644 --- a/www/app/lib/api-hooks.ts +++ b/www/app/lib/api-hooks.ts @@ -4,12 +4,12 @@ import { $api } from "./apiClient"; import { useError } from "../(errors)/errorContext"; import { useQueryClient } from "@tanstack/react-query"; import type { paths } from "../reflector-api"; -import useSessionStatus from "./useSessionStatus"; +import useAuthReady from "./useAuthReady"; // Rooms hooks export function useRoomsList(page: number = 1) { const { setError } = useError(); - const { status } = useSessionStatus(); + const { isAuthReady } = useAuthReady(); return $api.useQuery( "get", @@ -20,9 +20,8 @@ export function useRoomsList(page: number = 1) { }, }, { - // Only fetch when authenticated - // Using direct status check to avoid any derived state issues - enabled: status === "authenticated", + // Only fetch when authentication is fully ready (session + token) + enabled: isAuthReady, }, ); } @@ -38,7 +37,7 @@ export function useTranscriptsSearch( } = {}, ) { const { setError } = useError(); - const { status } = useSessionStatus(); + const { isAuthReady } = useAuthReady(); return $api.useQuery( "get", @@ -55,8 +54,8 @@ export function useTranscriptsSearch( }, }, { - // Only fetch when authenticated - enabled: status === "authenticated", + // Only fetch when authentication is fully ready (session + token) + enabled: isAuthReady, }, ); } @@ -92,7 +91,7 @@ export function useTranscriptProcess() { export function useTranscriptGet(transcriptId: string | null) { const { setError } = useError(); - const { status } = useSessionStatus(); + const { isAuthReady } = useAuthReady(); return $api.useQuery( "get", @@ -106,7 +105,7 @@ export function useTranscriptGet(transcriptId: string | null) { }, { // Only fetch when authenticated and transcriptId is provided - enabled: !!transcriptId && status === "authenticated", + enabled: !!transcriptId && isAuthReady, }, ); } @@ -163,7 +162,7 @@ export function useRoomDelete() { // Zulip hooks - NOTE: These endpoints are not in the OpenAPI spec yet export function useZulipStreams() { const { setError } = useError(); - const { status } = useSessionStatus(); + const { isAuthReady } = useAuthReady(); // @ts-ignore - Zulip endpoint not in OpenAPI spec return $api.useQuery( @@ -172,14 +171,14 @@ export function useZulipStreams() { {}, { // Only fetch when authenticated - enabled: status === "authenticated", + enabled: isAuthReady, }, ); } export function useZulipTopics(streamId: number | null) { const { setError } = useError(); - const { status } = useSessionStatus(); + const { isAuthReady } = useAuthReady(); // @ts-ignore - Zulip endpoint not in OpenAPI spec return $api.useQuery( @@ -188,7 +187,7 @@ export function useZulipTopics(streamId: number | null) { {}, { // Only fetch when authenticated and streamId is provided - enabled: !!streamId && status === "authenticated", + enabled: !!streamId && isAuthReady, }, ); } @@ -262,7 +261,7 @@ export function useTranscriptUploadAudio() { // Transcript queries export function useTranscriptWaveform(transcriptId: string | null) { const { setError } = useError(); - const { status } = useSessionStatus(); + const { isAuthReady } = useAuthReady(); return $api.useQuery( "get", @@ -273,14 +272,14 @@ export function useTranscriptWaveform(transcriptId: string | null) { }, }, { - enabled: !!transcriptId && status === "authenticated", + enabled: !!transcriptId && isAuthReady, }, ); } export function useTranscriptMP3(transcriptId: string | null) { const { setError } = useError(); - const { status } = useSessionStatus(); + const { isAuthReady } = useAuthReady(); return $api.useQuery( "get", @@ -291,14 +290,14 @@ export function useTranscriptMP3(transcriptId: string | null) { }, }, { - enabled: !!transcriptId && status === "authenticated", + enabled: !!transcriptId && isAuthReady, }, ); } export function useTranscriptTopics(transcriptId: string | null) { const { setError } = useError(); - const { status } = useSessionStatus(); + const { isAuthReady } = useAuthReady(); return $api.useQuery( "get", @@ -309,14 +308,14 @@ export function useTranscriptTopics(transcriptId: string | null) { }, }, { - enabled: !!transcriptId && status === "authenticated", + enabled: !!transcriptId && isAuthReady, }, ); } export function useTranscriptTopicsWithWords(transcriptId: string | null) { const { setError } = useError(); - const { status } = useSessionStatus(); + const { isAuthReady } = useAuthReady(); return $api.useQuery( "get", @@ -327,7 +326,7 @@ export function useTranscriptTopicsWithWords(transcriptId: string | null) { }, }, { - enabled: !!transcriptId && status === "authenticated", + enabled: !!transcriptId && isAuthReady, }, ); } @@ -337,7 +336,7 @@ export function useTranscriptTopicsWithWordsPerSpeaker( topicId: string | null, ) { const { setError } = useError(); - const { status } = useSessionStatus(); + const { isAuthReady } = useAuthReady(); return $api.useQuery( "get", @@ -351,7 +350,7 @@ export function useTranscriptTopicsWithWordsPerSpeaker( }, }, { - enabled: !!transcriptId && !!topicId && status === "authenticated", + enabled: !!transcriptId && !!topicId && isAuthReady, }, ); } @@ -359,7 +358,7 @@ export function useTranscriptTopicsWithWordsPerSpeaker( // Participant operations export function useTranscriptParticipants(transcriptId: string | null) { const { setError } = useError(); - const { status } = useSessionStatus(); + const { isAuthReady } = useAuthReady(); return $api.useQuery( "get", @@ -370,7 +369,7 @@ export function useTranscriptParticipants(transcriptId: string | null) { }, }, { - enabled: !!transcriptId && status === "authenticated", + enabled: !!transcriptId && isAuthReady, }, ); } diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx index cc1e4ca7..118adcd1 100644 --- a/www/app/lib/apiClient.tsx +++ b/www/app/lib/apiClient.tsx @@ -19,8 +19,12 @@ export const client = createClient({ // Create the React Query client wrapper export const $api = createFetchClient(client); -// Store the current auth token +// Store the current auth token and ready state let currentAuthToken: string | null | undefined = null; +let authConfigured = false; + +// Export function to check if auth is ready +export const isAuthConfigured = () => authConfigured; // Set up authentication middleware once client.use({ @@ -42,6 +46,7 @@ client.use({ // Configure authentication by updating the token export const configureApiAuth = (token: string | null | undefined) => { currentAuthToken = token; + authConfigured = true; }; // Export typed hooks for convenience diff --git a/www/app/lib/useAuthReady.ts b/www/app/lib/useAuthReady.ts new file mode 100644 index 00000000..ac85c808 --- /dev/null +++ b/www/app/lib/useAuthReady.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useState, useEffect } from "react"; +import useSessionStatus from "./useSessionStatus"; +import { isAuthConfigured } from "./apiClient"; + +/** + * Hook to check if authentication is fully ready. + * This ensures both the session is authenticated AND the API client token is configured. + * Prevents race conditions where React Query fires requests before the token is set. + */ +export default function useAuthReady() { + const { status, isAuthenticated } = useSessionStatus(); + const [authReady, setAuthReady] = useState(false); + + useEffect(() => { + // Check if both session is authenticated and token is configured + const checkAuthReady = () => { + const ready = isAuthenticated && isAuthConfigured(); + setAuthReady(ready); + }; + + // Check immediately + checkAuthReady(); + + // Also check periodically for a short time to catch async updates + const interval = setInterval(checkAuthReady, 100); + + // Stop checking after 2 seconds (auth should be ready by then) + const timeout = setTimeout(() => clearInterval(interval), 2000); + + return () => { + clearInterval(interval); + clearTimeout(timeout); + }; + }, [isAuthenticated]); + + return { + isAuthReady: authReady, + isLoading: status === "loading", + isAuthenticated, + }; +}