import { createContext, useContext, useState, useEffect } from 'react'; import { jwtDecode } from 'jwt-decode'; import { USER_addres } from '../api/API'; /** * Лёгкий AuthContext для студии Рублокса (opensource-версия). * * Главное отличие от минки: * - НЕТ UserService.validateAndRefreshToken (нет refresh-токенов в opensource). * - Если JWT в localStorage просрочен — просто считаем юзера гостем * и редиректим на VITE_RUBLOX_HOME при попытке зайти в защищённый раздел. * - Profile-данные подгружаются один раз через GET /api-user/api/v1/users/profile. * * Все эндпоинты бэкенда конфигурируются через .env (VITE_API_BASE). */ const AuthContext = createContext(null); export const useAuth = () => { const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuth должен использоваться внутри '); return ctx; }; function decodeJwtSafe(raw) { try { const p = jwtDecode(raw); if (p.exp && p.exp * 1000 < Date.now()) return null; return p; } catch { return null; } } export function AuthProvider({ children }) { const [state, setState] = useState({ isAuthenticated: false, isLoading: true, user: null, role: 'user', jwt: null, }); useEffect(() => { let cancelled = false; const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; // STANDALONE — мокаем гостевого юзера, без запросов. // Включается либо через .env (VITE_STANDALONE=true), либо через ?standalone=1 // в URL (для быстрого dev-доступа без правки env-файлов). const urlStandalone = new URLSearchParams(window.location.search).get('standalone') === '1'; if (String(env.VITE_STANDALONE).toLowerCase() === 'true' || urlStandalone) { setState({ isAuthenticated: true, isLoading: false, user: { id: 0, firstName: 'Гость', _standalone: true }, role: 'admin', jwt: 'standalone', }); return; } // SSO через ticket: если редиректнули с #ticket= с минки или другого // нашего сайта — обмениваем одноразовый ticket на JWT через бэк, кладём // в localStorage и чистим hash. После этого работаем как обычно. const ticketMatch = /(?:^|[#&])ticket=([0-9a-fA-F]{32})/.exec(window.location.hash || ''); const cleanTicketFromUrl = () => { try { window.history.replaceState(null, '', window.location.pathname + window.location.search); } catch {} }; // После успешного входа: если есть запомненная collab-ссылка и мы сейчас // НЕ на ней — переходим. Это и есть «возврат на приглашение» (минкин логин // сам этого не делает). Чистим ключ, чтобы не зациклить. const maybeResumePendingCollab = () => { try { const pending = localStorage.getItem('kbn_pending_collab_url'); if (!pending) return; localStorage.removeItem('kbn_pending_collab_url'); // уже на нужной collab-странице? не дёргаем if (window.location.href === pending) return; const u = new URL(pending); // безопасность: переходим только в пределах этого же origin (студии) if (u.origin !== window.location.origin) return; if (!/[?&]collab=/.test(pending)) return; window.location.replace(pending); } catch {} }; const startWithJwt = (raw) => { const payload = decodeJwtSafe(raw); if (!payload) { localStorage.removeItem('Authorization'); setState({ isAuthenticated: false, isLoading: false, user: null, role: 'user', jwt: null }); return; } fetch(`${USER_addres}/api/v1/users/profile`, { headers: { Authorization: raw }, }) .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (cancelled) return; const profile = data?.data || data; if (!profile) { setState({ isAuthenticated: false, isLoading: false, user: null, role: 'user', jwt: null }); return; } setState({ isAuthenticated: true, isLoading: false, user: profile, role: profile.role || 'user', jwt: raw, }); // Возврат на инвайт-ссылку после входа: минка вернула нас в студию // (на главную/кабинет), но мы запомнили collab-URL — идём на него. maybeResumePendingCollab(); }) .catch(() => { if (cancelled) return; setState({ isAuthenticated: false, isLoading: false, user: null, role: 'user', jwt: null }); }); }; if (ticketMatch) { const ticket = ticketMatch[1]; cleanTicketFromUrl(); fetch(`${USER_addres}/api/v1/auth/redeem-ticket`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticket }), }) .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (cancelled) return; const jwt = data?.jwt; if (jwt) { try { localStorage.setItem('Authorization', jwt); localStorage.setItem('player_jwt', jwt); } catch {} startWithJwt(jwt); } else { // ticket уже использован или просрочен — пробуем существующий JWT const existing = localStorage.getItem('Authorization') || localStorage.getItem('player_jwt'); if (existing) startWithJwt(existing); else setState({ isAuthenticated: false, isLoading: false, user: null, role: 'user', jwt: null }); } }) .catch(() => { if (cancelled) return; setState({ isAuthenticated: false, isLoading: false, user: null, role: 'user', jwt: null }); }); return () => { cancelled = true; }; } const raw = localStorage.getItem('Authorization') || localStorage.getItem('player_jwt'); if (!raw) { setState({ isAuthenticated: false, isLoading: false, user: null, role: 'user', jwt: null }); return; } startWithJwt(raw); return () => { cancelled = true; }; }, []); return {children}; } /** * Редирект незалогиненного пользователя на форму входа Рублокса (SSO). * В студии НЕТ собственной страницы /login — вход живёт на КОРНЕ домена * Рублокса (`https://rublox.pro/login`, НЕ `/app/login` — тот даёт 404). * После входа лендинг возвращает в студию/плеер с #ticket= * (его обменивает AuthProvider выше). `return`-параметр сохраняет ПОЛНЫЙ * текущий URL (включая ?collab=), чтобы человек попал ровно на ту * инвайт-ссылку, с которой пришёл. */ // Ключ, под которым студия запоминает collab-ссылку на время входа. // Основной путь возврата — rublox.pro/login читает наш ?return и сам // редиректит обратно с #ticket (см. rublox-site LoginPage.finishLogin). // Этот pending-ключ — РЕЗЕРВ на случай, если юзер вернулся в студию иным // путём: AuthProvider после входа перекинет на запомненную ссылку. export const PENDING_COLLAB_KEY = 'kbn_pending_collab_url'; export function redirectToLogin() { const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; // VITE_RUBLOX_HOME = "https://rublox.pro/app" — нам нужен ORIGIN (без /app), // т.к. форма входа сидит на корне домена: https://rublox.pro/login. const rawHome = env.VITE_RUBLOX_HOME || 'https://rublox.pro/app'; let origin; try { origin = new URL(rawHome).origin; // https://rublox.pro } catch { origin = rawHome.replace(/\/app\/?$/, '').replace(/\/$/, ''); } // Если уходим со страницы с инвайт-токеном — запомним её, чтобы вернуться // после входа (минка сама вернёт только в кабинет, не на эту ссылку). try { const cur = window.location.href; if (/[?&]collab=/.test(cur)) { localStorage.setItem(PENDING_COLLAB_KEY, cur); } } catch {} const back = encodeURIComponent(window.location.href); window.location.href = `${origin}/login?return=${back}`; }