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>
212 lines
9.2 KiB
JavaScript
212 lines
9.2 KiB
JavaScript
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}`;
|
||
}
|