fix(panels): bypass GoTrueClient lock — expose accessToken via AuthContext, gate panels on auth

Root cause: apiFetch called supabase.auth.getSession() on every API request, acquiring
the GoTrueClient internal lock. On refresh, concurrent mount of panels caused lock
contention — the loadCabinets/loadSessions fetch awaits hung, loading spinner never
cleared.

- AuthContext: add accessToken state, set/clear it alongside user on all auth events
- CCFilesPanel: apiFetch reads accessToken from AuthContext (no lock), loadCabinets
  effect gated on authUser.id (fires only after SIGNED_IN, session guaranteed valid)
- CCTranscriptionPanel: loadSessions effect gated on authUser.id for same reason

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kcar 2026-05-25 15:06:51 +01:00
parent 5284d30f84
commit 6bd85671d2
3 changed files with 27 additions and 10 deletions

View File

@ -9,6 +9,7 @@ import { storageService, StorageKeys } from '../services/auth/localStorageServic
export interface AuthContextType { export interface AuthContextType {
user: CCUser | null; user: CCUser | null;
user_role: string | null; user_role: string | null;
accessToken: string | null;
loading: boolean; loading: boolean;
error: Error | null; error: Error | null;
signIn: (email: string, password: string) => Promise<void>; signIn: (email: string, password: string) => Promise<void>;
@ -19,6 +20,7 @@ export interface AuthContextType {
export const AuthContext = createContext<AuthContextType>({ export const AuthContext = createContext<AuthContextType>({
user: null, user: null,
user_role: null, user_role: null,
accessToken: null,
loading: true, loading: true,
error: null, error: null,
signIn: async () => {}, signIn: async () => {},
@ -30,6 +32,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [user, setUser] = useState<CCUser | null>(null); const [user, setUser] = useState<CCUser | null>(null);
const [user_role, setUserRole] = useState<string | null>(null); const [user_role, setUserRole] = useState<string | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true); // true until INITIAL_SESSION fires const [loading, setLoading] = useState(true); // true until INITIAL_SESSION fires
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
@ -84,15 +87,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user); const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
setUser(resolvedUser); setUser(resolvedUser);
setUserRole(role); setUserRole(role);
setAccessToken(session.access_token ?? null);
} catch (buildError) { } catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError }); logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
setUser(null); setUser(null);
setUserRole(null); setUserRole(null);
setAccessToken(null);
setError(buildError instanceof Error ? buildError : new Error('Failed to load user')); setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
} }
} else { } else {
setUser(null); setUser(null);
setUserRole(null); setUserRole(null);
setAccessToken(null);
} }
// Always clear loading after the first auth event resolves // Always clear loading after the first auth event resolves
setLoading(false); setLoading(false);
@ -102,6 +108,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
persistSession(null); persistSession(null);
setUser(null); setUser(null);
setUserRole(null); setUserRole(null);
setAccessToken(null);
setLoading(false); setLoading(false);
break; break;
} }
@ -132,6 +139,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const { user: resolvedUser, role } = await buildUserFromSupabase(data.user); const { user: resolvedUser, role } = await buildUserFromSupabase(data.user);
setUser(resolvedUser); setUser(resolvedUser);
setUserRole(role); setUserRole(role);
setAccessToken(data.session?.access_token ?? null);
} }
} catch (error) { } catch (error) {
logger.error('auth-context', '❌ Sign in failed', { error }); logger.error('auth-context', '❌ Sign in failed', { error });
@ -165,6 +173,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
value={{ value={{
user, user,
user_role, user_role,
accessToken,
loading, loading,
error, error,
signIn, signIn,

View File

@ -40,7 +40,7 @@ import ImageIcon from '@mui/icons-material/Image';
import DescriptionIcon from '@mui/icons-material/Description'; import DescriptionIcon from '@mui/icons-material/Description';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import { useTLDraw } from '../../../../../contexts/TLDrawContext'; import { useTLDraw } from '../../../../../contexts/TLDrawContext';
import { supabase } from '../../../../../supabaseClient'; import { useAuth } from '../../../../../contexts/AuthContext';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
calculateDirectoryStats, calculateDirectoryStats,
@ -92,7 +92,8 @@ interface FileListResponse {
} }
export const CCFilesPanel: React.FC = () => { export const CCFilesPanel: React.FC = () => {
const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string }; const { tldrawPreferences } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' } };
const { user: authUser, accessToken } = useAuth();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [cabinets, setCabinets] = useState<Cabinet[]>([]); const [cabinets, setCabinets] = useState<Cabinet[]>([]);
const [selectedCabinet, setSelectedCabinet] = useState<string>(''); const [selectedCabinet, setSelectedCabinet] = useState<string>('');
@ -144,14 +145,14 @@ export const CCFilesPanel: React.FC = () => {
const apiFetch = useCallback(async (url: string, init?: RequestInitLike) => { const apiFetch = useCallback(async (url: string, init?: RequestInitLike) => {
const headers: HeadersInitLike = { const headers: HeadersInitLike = {
'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || authToken || ''}`, 'Authorization': `Bearer ${accessToken || ''}`,
...(init?.headers || {}) ...(init?.headers || {})
}; };
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`; const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
const res = await fetch(fullUrl, { ...(init || {}), headers }); const res = await fetch(fullUrl, { ...(init || {}), headers });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
}, [authToken, API_BASE]); }, [accessToken, API_BASE]);
const loadCabinets = useCallback(async () => { const loadCabinets = useCallback(async () => {
setLoading(true); setLoading(true);
@ -207,8 +208,11 @@ export const CCFilesPanel: React.FC = () => {
}, [currentPage, itemsPerPage, sortBy, sortOrder, searchTerm, apiFetch, currentDirectoryId]); }, [currentPage, itemsPerPage, sortBy, sortOrder, searchTerm, apiFetch, currentDirectoryId]);
useEffect(() => { useEffect(() => {
loadCabinets(); if (authUser?.id) {
}, [loadCabinets]); initialSelectionDone.current = false;
loadCabinets();
}
}, [loadCabinets, authUser?.id]);
// Main loading effect - handles pagination, sorting, cabinet changes, directory navigation // Main loading effect - handles pagination, sorting, cabinet changes, directory navigation
useEffect(() => { useEffect(() => {

View File

@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useAuth } from "../../../../../contexts/AuthContext";
import { Mic as MicIcon, Stop as StopIcon, History as HistoryIcon, Settings as SettingsIcon, AutoAwesome as AutoAwesomeIcon, NotificationsActive as KeywordIcon, Close } from "@mui/icons-material"; import { Mic as MicIcon, Stop as StopIcon, History as HistoryIcon, Settings as SettingsIcon, AutoAwesome as AutoAwesomeIcon, NotificationsActive as KeywordIcon, Close } from "@mui/icons-material";
import { useTranscriptionStore, TranscriptionSegment, TranscriptionSession, TimetablePeriod, KeywordWatch, KeywordMatch, ServerSegment } from "../../../../../stores/transcriptionStore"; import { useTranscriptionStore, TranscriptionSegment, TranscriptionSession, TimetablePeriod, KeywordWatch, KeywordMatch, ServerSegment } from "../../../../../stores/transcriptionStore";
import { TranscriptionService } from "../../../cc-base/cc-transcription/transcriptionService"; import { TranscriptionService } from "../../../cc-base/cc-transcription/transcriptionService";
@ -92,6 +93,7 @@ export const CCTranscriptionPanel: React.FC = () => {
// Modal state // Modal state
const [showSettingsModal, setShowSettingsModal] = useState(false); const [showSettingsModal, setShowSettingsModal] = useState(false);
const { user: authUser } = useAuth();
const [showSummaryModal, setShowSummaryModal] = useState(false); const [showSummaryModal, setShowSummaryModal] = useState(false);
const [summaryType, setSummaryType] = useState('full_lesson'); const [summaryType, setSummaryType] = useState('full_lesson');
@ -99,11 +101,13 @@ export const CCTranscriptionPanel: React.FC = () => {
const [newKeyword, setNewKeyword] = useState(''); const [newKeyword, setNewKeyword] = useState('');
const [isAddingKeyword, setIsAddingKeyword] = useState(false); const [isAddingKeyword, setIsAddingKeyword] = useState(false);
// Load sessions and keyword watches on mount // Load sessions when auth is confirmed (avoids GoTrueClient lock on mount)
useEffect(() => { useEffect(() => {
loadSessions().then(setSessions); if (authUser?.id) {
loadKeywordWatches(); loadSessions().then(setSessions);
}, []); loadKeywordWatches();
}
}, [authUser?.id]);
// Auto-detect timetable context on mount // Auto-detect timetable context on mount
useEffect(() => { useEffect(() => {