From 8fe83df899910791e3a5296a3b338e2722ecc2aa Mon Sep 17 00:00:00 2001 From: min Date: Tue, 2 Jun 2026 14:20:37 +0300 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=D0=B0=D0=B2=D1=82=D0=BE-=D1=80?= =?UTF-8?q?=D0=B5=D1=84=D1=80=D0=B5=D1=88=20access=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?401=20+=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20refreshToken=20=D0=B8=D0=B7=20redeem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Студия не умела рефрешить токен (access живёт 24ч) → сохранение проектов падало 401 после суток работы («через раз»). Теперь: AuthContext сохраняет refreshToken из redeem-ticket; axios-интерсептор при 401 обновляет access через /auth/refresh (single-flight) и повторяет запрос. + глобальное правило Hotbar: пустой инвентарь не показывает панель. Co-Authored-By: Claude Opus 4.8 --- src/api/Kubikon3DService.js | 57 ++++++++++++++++++++++++++++++++++++- src/auth/AuthContext.jsx | 4 +++ src/editor/Hotbar.jsx | 7 +++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/api/Kubikon3DService.js b/src/api/Kubikon3DService.js index 144d4b2..f450b6a 100644 --- a/src/api/Kubikon3DService.js +++ b/src/api/Kubikon3DService.js @@ -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с) — для больших карт. diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx index 4794ef1..8bfc26a 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -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 { diff --git a/src/editor/Hotbar.jsx b/src/editor/Hotbar.jsx index 3e1cfa2..1b313de 100644 --- a/src/editor/Hotbar.jsx +++ b/src/editor/Hotbar.jsx @@ -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++) {