Files
play-life/play-life-web/src/components/auth/AuthContext.jsx
poignatov 3cf3cd4edb
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 49s
fix(auth): improve token refresh with better logging and error handling
- Add detailed logging for token refresh process
- Increase refresh timeout from 5s to 10s
- Log response body on refresh failure for diagnostics
- Verify tokens are present in refresh response
- Improve authFetch logging during retry

Version: 3.9.4
2026-01-12 17:05:19 +03:00

354 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
export default AuthContext