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>
64 lines
2.6 KiB
JavaScript
64 lines
2.6 KiB
JavaScript
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;
|
||
}
|
||
});
|