studio/src/auth/AuthContext.jsx
min c0613853a2 fix(studio): Team Create — экран загрузки только в плеере, коллаб новой игры, вход по инвайту
1) Стартовый экран загрузки больше НЕ показывается в студии при тестовом
запуске (scene._editorMode), только в плеере на rublox.pro «Играть».
2) Новая игра: коллаб-сессия поднимается сразу после первого сохранения
(без перезагрузки) + кнопка «Пригласить» авто-сохраняет проект.
3) Незалогиненный по collab-ссылке → форма входа rublox.pro/login (origin без
/app, был 404) с ?return → возврат на инвайт-ссылку.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 05:57:55 +03:00

212 lines
9.2 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 { 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 должен использоваться внутри <AuthProvider>');
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=<hex> с минки или другого
// нашего сайта — обмениваем одноразовый 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 <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
}
/**
* Редирект незалогиненного пользователя на форму входа Рублокса (SSO).
* В студии НЕТ собственной страницы /login — вход живёт на КОРНЕ домена
* Рублокса (`https://rublox.pro/login`, НЕ `/app/login` — тот даёт 404).
* После входа лендинг возвращает в студию/плеер с #ticket=<hex>
* (его обменивает AuthProvider выше). `return`-параметр сохраняет ПОЛНЫЙ
* текущий URL (включая ?collab=<token>), чтобы человек попал ровно на ту
* инвайт-ссылку, с которой пришёл.
*/
// Ключ, под которым студия запоминает 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}`;
}