fix(auth): авто-рефреш access при 401 + сохранение refreshToken из redeem
Some checks failed
CI / Lint (pull_request) Successful in 1m26s
CI / Build (pull_request) Successful in 2m1s
CI / Secret scan (pull_request) Failing after 8s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped

Студия не умела рефрешить токен (access живёт 24ч) → сохранение проектов
падало 401 после суток работы («через раз»). Теперь: AuthContext сохраняет
refreshToken из redeem-ticket; axios-интерсептор при 401 обновляет access
через /auth/refresh (single-flight) и повторяет запрос. + глобальное правило
Hotbar: пустой инвентарь не показывает панель.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-02 14:20:37 +03:00
parent 71af96d171
commit 8fe83df899
3 changed files with 67 additions and 1 deletions

View File

@ -3,7 +3,7 @@
* Бэкенд: storys-микросервис, префикс /kubikon3d/...
*/
import axios from 'axios';
import { STORYS_addres } from './API';
import { STORYS_addres, USER_addres } from './API';
const api = axios.create({
baseURL: STORYS_addres,
@ -29,6 +29,61 @@ api.interceptors.request.use((config) => {
return config;
});
// ── Авто-рефреш access-токена при 401 (2026-06-02) ──────────────────────
// Access-токен живёт 24ч. Раньше студия не рефрешила его → после суток работы
// сохранение проектов падало 401 «через раз» (зависело от свежести токена).
// Теперь при 401 один раз пытаемся обновить access по refreshToken через
// /api/v1/auth/refresh и повторяем исходный запрос. single-flight: при пачке
// параллельных 401 рефреш идёт ОДИН раз, остальные ждут его.
let _refreshPromise = null;
async function _refreshAccessToken() {
const refreshToken = localStorage.getItem('RefreshToken');
if (!refreshToken) return null;
if (_refreshPromise) return _refreshPromise;
_refreshPromise = (async () => {
try {
// Отдельный axios без интерсепторов — чтобы не зациклить на 401.
const r = await axios.post(`${USER_addres}/api/v1/users/auth/refresh`,
{ refreshToken }, { timeout: 15000 });
const newToken = r.data && r.data.token;
const newRefresh = r.data && r.data.refreshToken;
if (newToken) {
localStorage.setItem('Authorization', newToken);
localStorage.setItem('player_jwt', newToken);
if (newRefresh) localStorage.setItem('RefreshToken', newRefresh);
return newToken;
}
return null;
} catch (e) {
// refresh протух (30 дней) → чистим, юзер перелогинится через минку.
try { localStorage.removeItem('RefreshToken'); } catch (e2) { /* ignore */ }
return null;
} finally {
_refreshPromise = null;
}
})();
return _refreshPromise;
}
api.interceptors.response.use(
(resp) => resp,
async (error) => {
const cfg = error && error.config;
const status = error && error.response && error.response.status;
// Только 401 и только один раз на запрос (флаг _retried).
if (status === 401 && cfg && !cfg._retried) {
cfg._retried = true;
const newToken = await _refreshAccessToken();
if (newToken) {
cfg.headers = cfg.headers || {};
cfg.headers.Authorization = newToken;
return api(cfg); // повторяем исходный запрос со свежим токеном
}
}
return Promise.reject(error);
},
);
// ============ ПРОЕКТЫ ============
// Save-операции с увеличенным таймаутом (120с) — для больших карт.

View File

@ -119,6 +119,10 @@ export function AuthProvider({ children }) {
try {
localStorage.setItem('Authorization', jwt);
localStorage.setItem('player_jwt', jwt);
// 2026-06-02: redeem теперь отдаёт refreshToken сохраняем,
// чтобы axios-интерсептор мог обновить истёкший access (24ч)
// и сохранение проектов не падало 401 после суток работы.
if (data.refreshToken) localStorage.setItem('RefreshToken', data.refreshToken);
} catch {}
startWithJwt(jwt);
} else {

View File

@ -16,6 +16,13 @@ import Icon from './Icon';
function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) {
if (!visible) return null;
// ГЛОБАЛЬНОЕ ПРАВИЛО (для всех игр тулбокса): если в инвентаре нет ни
// одного предмета панель инвентаря НЕ показываем вовсе. Пустой hotbar
// из 5 серых ячеек загромождает экран в играх, где инвентарь не нужен.
// Панель появится автоматически, как только в слот попадёт предмет.
const hasAnyItem = Array.isArray(slots) && slots.some((s) => s != null);
if (!hasAnyItem) return null;
const SLOT_COUNT = 5;
const cells = [];
for (let i = 0; i < SLOT_COUNT; i++) {