Silent-refresh считал неудачей любой не-2xx ответ и на каждую такую неудачу сбрасывал access-токен с редиректом на /login. Ответ 500 — это «серверу плохо», а не «вы не авторизованы»: 07.08.2026 refresh отвечал 500 из-за исчерпанного пула БД, и фронтенд разлогинивал людей посреди работы, а повторный вход падал тем же 500. `refreshAccessToken` теперь различает причины: `invalid` (backend отверг сессию — 4xx, единственный случай для разлогина), `unavailable` (5xx, таймаут, обрыв сети — сессия цела, токен сохраняется, пользователь получает обычную ошибку запроса) и `ok`. Восстановление сессии при старте приложения на `unavailable` повторяет попытку трижды с задержками 1/2/4 с, вместо того чтобы сразу объявить пользователя неавторизованным.
93 lines
3.3 KiB
TypeScript
93 lines
3.3 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||
import { getMe, login as apiLogin, logout as apiLogout, type CurrentUser } from '@/api/auth'
|
||
import { authStore } from '@/auth/authStore'
|
||
import { refreshAccessToken } from '@/api/client'
|
||
import { AuthContext, type AuthContextValue, type AuthStatus } from '@/auth/authContext'
|
||
|
||
/**
|
||
* Задержки повторов восстановления сессии, если backend отвечает 5xx.
|
||
*
|
||
* Недоступность сервера — не повод объявлять пользователя неавторизованным:
|
||
* refresh-cookie цела, и через несколько секунд сессия обычно поднимается
|
||
* сама. Повторов ровно три (суммарно ~7 с) — дальше показываем страницу
|
||
* входа, потому что бесконечный спиннер хуже честного «войдите заново»:
|
||
* cookie при этом не стирается, и повторная попытка входа сработает, как
|
||
* только backend оживёт.
|
||
*/
|
||
const BOOTSTRAP_RETRY_DELAYS_MS = [1000, 2000, 4000]
|
||
|
||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||
|
||
/**
|
||
* Провайдер сессии пользователя.
|
||
* При монтировании приложения пытается восстановить сессию через
|
||
* silent-refresh (httpOnly refresh-cookie), т.к. access-токен в памяти
|
||
* теряется при перезагрузке страницы.
|
||
*/
|
||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||
const [user, setUser] = useState<CurrentUser | null>(null)
|
||
const [status, setStatus] = useState<AuthStatus>('loading')
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
|
||
async function bootstrap() {
|
||
let outcome = await refreshAccessToken()
|
||
for (const delay of BOOTSTRAP_RETRY_DELAYS_MS) {
|
||
if (cancelled || outcome.result !== 'unavailable') break
|
||
await sleep(delay)
|
||
if (cancelled) return
|
||
outcome = await refreshAccessToken()
|
||
}
|
||
if (cancelled) return
|
||
if (outcome.result !== 'ok') {
|
||
setStatus('unauthenticated')
|
||
return
|
||
}
|
||
try {
|
||
const me = await getMe()
|
||
if (cancelled) return
|
||
setUser(me)
|
||
setStatus('authenticated')
|
||
} catch {
|
||
if (!cancelled) setStatus('unauthenticated')
|
||
}
|
||
}
|
||
|
||
void bootstrap()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [])
|
||
|
||
const login = useCallback(async (email: string, password: string) => {
|
||
const token = await apiLogin(email, password)
|
||
authStore.setAccessToken(token.access_token)
|
||
const me = await getMe()
|
||
setUser(me)
|
||
setStatus('authenticated')
|
||
}, [])
|
||
|
||
const logout = useCallback(async () => {
|
||
try {
|
||
await apiLogout()
|
||
} finally {
|
||
authStore.setAccessToken(null)
|
||
setUser(null)
|
||
setStatus('unauthenticated')
|
||
}
|
||
}, [])
|
||
|
||
const refreshUser = useCallback(async () => {
|
||
const me = await getMe()
|
||
setUser(me)
|
||
}, [])
|
||
|
||
const value = useMemo<AuthContextValue>(
|
||
() => ({ user, status, login, logout, refreshUser }),
|
||
[user, status, login, logout, refreshUser],
|
||
)
|
||
|
||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||
}
|