import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react' const AuthContext = createContext(null) const TOKEN_KEY = 'access_token' const REFRESH_TOKEN_KEY = 'refresh_token' const USER_KEY = 'user' export function AuthProvider({ children }) { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) // Ref для синхронизации параллельных refresh-запросов const refreshPromiseRef = useRef(null) const logout = useCallback(async () => { const token = localStorage.getItem(TOKEN_KEY) if (token) { try { await fetch('/api/auth/logout', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }) } catch (err) { console.error('Logout error:', err) } } localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(REFRESH_TOKEN_KEY) localStorage.removeItem(USER_KEY) setUser(null) }, []) // Внутренняя функция для выполнения refresh const doRefreshToken = useCallback(async () => { const refresh = localStorage.getItem(REFRESH_TOKEN_KEY) if (!refresh) { console.warn('[Auth] No refresh token in localStorage') return { success: false, isNetworkError: false } } console.log('[Auth] Attempting refresh with token:', refresh.substring(0, 10) + '...') try { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout (increased) const response = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refresh }), signal: controller.signal }) clearTimeout(timeoutId) if (!response.ok) { // Логируем тело ответа для диагностики let errorBody = '' try { errorBody = await response.text() } catch (e) { errorBody = 'Could not read error body' } console.error('[Auth] Refresh failed:', response.status, errorBody) // 401 means invalid token (real auth error) // Other errors might be temporary (503, 502, etc.) const isAuthError = response.status === 401 return { success: false, isNetworkError: !isAuthError } } const data = await response.json() // Проверяем что токены действительно пришли if (!data.access_token || !data.refresh_token) { console.error('[Auth] Refresh response missing tokens:', Object.keys(data)) return { success: false, isNetworkError: false } } console.log('[Auth] Refresh successful, saving new tokens') localStorage.setItem(TOKEN_KEY, data.access_token) localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token) localStorage.setItem(USER_KEY, JSON.stringify(data.user)) setUser(data.user) return { success: true, isNetworkError: false } } catch (err) { console.error('[Auth] Refresh error:', err.name, err.message) // Network errors should be treated as temporary if (err.name === 'AbortError' || (err.name === 'TypeError' && (err.message.includes('fetch') || err.message.includes('Failed to fetch')))) { console.warn('[Auth] Refresh token network error, keeping session') return { success: false, isNetworkError: true } } // Other errors might be auth related return { success: false, isNetworkError: false } } }, []) // Синхронизированная функция refresh - предотвращает race condition // Если refresh уже выполняется, все вызовы ждут его завершения const refreshToken = useCallback(async () => { // Если refresh уже выполняется, ждём его завершения if (refreshPromiseRef.current) { console.log('[Auth] Refresh already in progress, waiting...') return refreshPromiseRef.current } // Создаём promise для refresh и сохраняем его console.log('[Auth] Starting token refresh...') refreshPromiseRef.current = doRefreshToken().finally(() => { // Очищаем ref после завершения (успешного или нет) refreshPromiseRef.current = null }) return refreshPromiseRef.current }, [doRefreshToken]) // Initialize from localStorage useEffect(() => { const initAuth = async () => { const token = localStorage.getItem(TOKEN_KEY) const savedUser = localStorage.getItem(USER_KEY) console.log('[Auth] Initializing auth, token exists:', !!token, 'user exists:', !!savedUser) if (token && savedUser) { try { const parsedUser = JSON.parse(savedUser) setUser(parsedUser) // Set user immediately from localStorage console.log('[Auth] User restored from localStorage:', parsedUser.email) // Verify token is still valid with timeout const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 5000) // 5 second timeout const response = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, signal: controller.signal }) clearTimeout(timeoutId) if (response.ok) { const data = await response.json() setUser(data.user) localStorage.setItem(USER_KEY, JSON.stringify(data.user)) console.log('[Auth] Token verified successfully') } else if (response.status === 401) { // Try to refresh token console.log('[Auth] Access token expired, attempting refresh...') const result = await refreshToken() if (!result.success && !result.isNetworkError) { // Only logout on real auth errors, not network errors console.warn('[Auth] Refresh failed with auth error, logging out') logout() } else if (!result.success) { // Network error - keep session, backend might be starting up console.warn('[Auth] Token refresh failed due to network error, keeping session. User remains logged in.') // User is already set from localStorage above, so they stay logged in } else { console.log('[Auth] Token refreshed successfully') } } else { // For other errors (like 503, 502, network errors), don't clear auth // Just log the error and keep the user logged in console.warn('[Auth] Auth check failed with status:', response.status, 'but keeping session. User remains logged in.') // User is already set from localStorage above, so they stay logged in } } catch (err) { // Network errors (e.g., backend not ready) should not clear auth // Only clear if it's a real auth error if (err.name === 'AbortError') { // Timeout - backend might be starting up, keep auth state console.warn('[Auth] Auth check timeout, backend might be starting up. Keeping session. User remains logged in.') // User is already set from localStorage above, so they stay logged in } else if (err.name === 'TypeError' && (err.message.includes('fetch') || err.message.includes('Failed to fetch'))) { // Network error - backend might be starting up, keep auth state console.warn('[Auth] Network error during auth check, keeping session:', err.message, 'User remains logged in.') // User is already set from localStorage above, so they stay logged in } else { // Other errors - might be auth related console.error('[Auth] Auth init error:', err) // Don't automatically logout on unknown errors // User is already set from localStorage above, so they stay logged in } } } else { console.log('[Auth] No saved auth data found') } setLoading(false) } initAuth() }, [refreshToken, logout]) const login = useCallback(async (email, password) => { setError(null) try { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) }) const data = await response.json() if (!response.ok) { throw new Error(data.error || 'Ошибка входа') } localStorage.setItem(TOKEN_KEY, data.access_token) localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token) localStorage.setItem(USER_KEY, JSON.stringify(data.user)) setUser(data.user) return true } catch (err) { setError(err.message) return false } }, []) const register = useCallback(async (email, password, name) => { setError(null) try { const response = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, name: name || undefined }) }) const data = await response.json() if (!response.ok) { throw new Error(data.error || 'Ошибка регистрации') } localStorage.setItem(TOKEN_KEY, data.access_token) localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token) localStorage.setItem(USER_KEY, JSON.stringify(data.user)) setUser(data.user) return true } catch (err) { setError(err.message) return false } }, []) const getToken = useCallback(() => { return localStorage.getItem(TOKEN_KEY) }, []) // Fetch wrapper that handles auth const authFetch = useCallback(async (url, options = {}) => { const token = localStorage.getItem(TOKEN_KEY) // Не устанавливаем Content-Type для FormData - браузер сделает это автоматически const isFormData = options.body instanceof FormData const headers = {} if (!isFormData && !options.headers?.['Content-Type']) { headers['Content-Type'] = 'application/json' } // Добавляем пользовательские заголовки if (options.headers) { Object.assign(headers, options.headers) } if (token) { headers['Authorization'] = `Bearer ${token}` } try { let response = await fetch(url, { ...options, headers }) // If 401, try to refresh token and retry if (response.status === 401) { console.log('[Auth] Got 401 for', url, '- attempting token refresh') const result = await refreshToken() if (result.success) { console.log('[Auth] Token refreshed, retrying request to', url) const newToken = localStorage.getItem(TOKEN_KEY) headers['Authorization'] = `Bearer ${newToken}` response = await fetch(url, { ...options, headers }) console.log('[Auth] Retry response status:', response.status) } else if (!result.isNetworkError) { // Only logout if refresh failed due to auth error (not network error) console.warn('[Auth] Refresh failed with auth error, logging out') logout() } else { console.warn('[Auth] Refresh failed with network error, keeping session but request failed') } // If network error, don't logout - let the caller handle the 401 } return response } catch (err) { // Network errors should not trigger logout // Let the caller handle the error console.error('[Auth] Fetch error for', url, ':', err.message) throw err } }, [refreshToken, logout]) const value = { user, loading, error, login, register, logout, getToken, authFetch, isAuthenticated: !!user } return ( {children} ) } export function useAuth() { const context = useContext(AuthContext) if (!context) { throw new Error('useAuth must be used within an AuthProvider') } return context } export default AuthContext