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:
parent
5284d30f84
commit
6bd85671d2
@ -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,
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
if (authUser?.id) {
|
||||||
|
initialSelectionDone.current = false;
|
||||||
loadCabinets();
|
loadCabinets();
|
||||||
}, [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(() => {
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
if (authUser?.id) {
|
||||||
loadSessions().then(setSessions);
|
loadSessions().then(setSessions);
|
||||||
loadKeywordWatches();
|
loadKeywordWatches();
|
||||||
}, []);
|
}
|
||||||
|
}, [authUser?.id]);
|
||||||
|
|
||||||
// Auto-detect timetable context on mount
|
// Auto-detect timetable context on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user