Files
vidconf/frontend/src/auth/AuthProvider.tsx
Max Ronzhin 296ce60c78 fix(auth): не разлогинивать пользователя, когда серверу плохо
Silent-refresh считал неудачей любой не-2xx ответ и на каждую такую
неудачу сбрасывал access-токен с редиректом на /login. Ответ 500 — это
«серверу плохо», а не «вы не авторизованы»: 07.08.2026 refresh отвечал
500 из-за исчерпанного пула БД, и фронтенд разлогинивал людей посреди
работы, а повторный вход падал тем же 500.

`refreshAccessToken` теперь различает причины: `invalid` (backend отверг
сессию — 4xx, единственный случай для разлогина), `unavailable` (5xx,
таймаут, обрыв сети — сессия цела, токен сохраняется, пользователь
получает обычную ошибку запроса) и `ok`. Восстановление сессии при
старте приложения на `unavailable` повторяет попытку трижды с задержками
1/2/4 с, вместо того чтобы сразу объявить пользователя неавторизованным.
2026-08-09 01:06:15 +03:00

93 lines
3.3 KiB
TypeScript
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 { 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>
}