Andrey Epifancev edaed04a03 fix(auth): session no longer drops after access-token expiry
Access-токен жил 60 минут без обновления: фронт не слал response-interceptor
на 401, refresh-токен лежал в localStorage неиспользуемым, WebSocket-сокеты
(форма, статусы выгрузки) не переподключались после закрытия с auth-кодом,
а после редиректа на /login пользователь терял исходную страницу.

- tokenRefresh.js: чистые getTokenExp/msUntilRefresh/createSingleFlight (TDD).
- auth.js: refreshTokens — обновление токена вне интерсепторов api (без
  рекурсии), централизованно чистит токены и шлёт auth:expired при неудаче.
- client.js: response-interceptor повторяет запрос после обновления токена.
- AuthProvider: при старте и по 'auth:expired'/'auth:refreshed' не выкидывает
  из системы, пока жив refresh; проактивный таймер обновления + visibilitychange.
- Routes/LoginPage: PrivateRoute сохраняет исходный location, вход возвращает
  туда же вместо /tasks; убрана мёртвая ветка loading.
- useWebSocket/exports: переподключение после 1008/4401 с одной попыткой
  refreshTokens() перед тем как сдаться.

Issue: #248 (AURORA-1877, эпик AURORA-1876, Критичный приоритет)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-30 17:18:11 +04:00

64 lines
2.6 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 axios from 'axios';
import { Config } from '../conf/config';
import api from './client';
import { clearAuthTokens } from './utils/token';
import { createSingleFlight } from './utils/tokenRefresh';
export const AuthApi = {
async login(username, password) {
const { data } = await api.post('/auth/login', { username, password });
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
try {
window.dispatchEvent(new CustomEvent('auth:changed', { detail: { loggedIn: true } }));
} catch (error) {
console.error('Ошибка входа', error);
}
return data;
},
async refresh() {
const refresh_token = localStorage.getItem('refresh_token');
if (!refresh_token) {
throw new Error('Нет refresh-токена');
}
// "Голый" axios, в обход клиента api и его интерсепторов — иначе
// 401 на /auth/refresh сам же запустит повторное обновление токена.
const { data } = await axios.post(`${Config.API_URL}/api/v1/auth/refresh`, { refresh_token });
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
try {
window.dispatchEvent(new CustomEvent('auth:refreshed'));
} catch (error) {
console.error('Ошибка обновления токена', error);
}
return data;
},
logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
try {
window.dispatchEvent(new CustomEvent('auth:changed', { detail: { loggedIn: false } }));
} catch (error) {
console.error('Ошибка при выходе', error);
}
},
};
// Пока обновление токена в полёте, все одновременные вызыватели получают
// один и тот же Promise — иначе параллельные 401 запускают по refresh-запросу каждый.
// Неудача означает, что сессию больше не восстановить: токены чистим и шлём
// auth:expired централизованно здесь, чтобы не дублировать это в каждом вызывающем месте.
export const refreshTokens = createSingleFlight(async () => {
try {
return await AuthApi.refresh();
} catch (error) {
clearAuthTokens();
try {
window.dispatchEvent(new CustomEvent('auth:expired'));
} catch (dispatchError) {
console.error('Ошибка при оповещении об истечении сессии', dispatchError);
}
throw error;
}
});