Compare commits
3 Commits
6943e93818
...
9c990bb80c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c990bb80c | ||
|
|
c0613853a2 | ||
| 70731dae31 |
@ -71,6 +71,24 @@ export function AuthProvider({ children }) {
|
|||||||
} catch {}
|
} 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 startWithJwt = (raw) => {
|
||||||
const payload = decodeJwtSafe(raw);
|
const payload = decodeJwtSafe(raw);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
@ -96,6 +114,9 @@ export function AuthProvider({ children }) {
|
|||||||
role: profile.role || 'user',
|
role: profile.role || 'user',
|
||||||
jwt: raw,
|
jwt: raw,
|
||||||
});
|
});
|
||||||
|
// Возврат на инвайт-ссылку после входа: минка вернула нас в студию
|
||||||
|
// (на главную/кабинет), но мы запомнили collab-URL — идём на него.
|
||||||
|
maybeResumePendingCollab();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@ -149,3 +170,42 @@ export function AuthProvider({ children }) {
|
|||||||
|
|
||||||
return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
|
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}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { jwtDecode } from 'jwt-decode';
|
import { jwtDecode } from 'jwt-decode';
|
||||||
import { useAuth } from '../auth/AuthContext.jsx';
|
import { useAuth, redirectToLogin } from '../auth/AuthContext.jsx';
|
||||||
import { useSanctions } from '../auth/SanctionsContext.jsx';
|
import { useSanctions } from '../auth/SanctionsContext.jsx';
|
||||||
import { BabylonScene } from './engine/BabylonScene';
|
import { BabylonScene } from './engine/BabylonScene';
|
||||||
import { StudioCollab } from './engine/StudioCollab';
|
import { StudioCollab } from './engine/StudioCollab';
|
||||||
@ -470,6 +470,9 @@ const KubikonEditor = () => {
|
|||||||
// Team Create — клиент совместного редактирования + presence-overlay.
|
// Team Create — клиент совместного редактирования + presence-overlay.
|
||||||
const collabRef = useRef(null);
|
const collabRef = useRef(null);
|
||||||
const collabOverlayRef = useRef(null);
|
const collabOverlayRef = useRef(null);
|
||||||
|
// ref на initCollab — чтобы вызвать его из doSave (объявлен раньше initCollab)
|
||||||
|
// после создания нового проекта, не дожидаясь перезагрузки страницы.
|
||||||
|
const initCollabRef = useRef(null);
|
||||||
const [collabActive, setCollabActive] = useState(false); // подключены к комнате
|
const [collabActive, setCollabActive] = useState(false); // подключены к комнате
|
||||||
const [collabPeers, setCollabPeers] = useState(0); // сколько ДРУГИХ соавторов
|
const [collabPeers, setCollabPeers] = useState(0); // сколько ДРУГИХ соавторов
|
||||||
// Роль в коллабе: 'owner' (владелец) | 'collab' (приглашённый по ссылке).
|
// Роль в коллабе: 'owner' (владелец) | 'collab' (приглашённый по ссылке).
|
||||||
@ -988,10 +991,12 @@ const KubikonEditor = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
// Доступ открыт всем авторизованным (раньше — только admin)
|
// Доступ открыт всем авторизованным (раньше — только admin)
|
||||||
|
// Нет своей страницы /login — уходим на вход минки (SSO) с возвратом
|
||||||
|
// на текущий URL (важно для инвайт-ссылок ?collab=<token>).
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
navigate('/login', { replace: true });
|
redirectToLogin();
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, isLoading, navigate]);
|
}, [isAuthenticated, isLoading]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Реальное сохранение на сервер.
|
* Реальное сохранение на сервер.
|
||||||
@ -1207,6 +1212,11 @@ const KubikonEditor = () => {
|
|||||||
if (newId) {
|
if (newId) {
|
||||||
currentProjectIdRef.current = newId;
|
currentProjectIdRef.current = newId;
|
||||||
window.history.replaceState({}, '', `/edit/${newId}`);
|
window.history.replaceState({}, '', `/edit/${newId}`);
|
||||||
|
// Team Create: проект только что получил реальный ID — поднимаем
|
||||||
|
// совместную сессию сразу, без перезагрузки страницы. Иначе
|
||||||
|
// индикатор соавторов и инвайт не появлялись, пока юзер не
|
||||||
|
// поставит блок → сохранит → обновит (баг 2026-06-08).
|
||||||
|
try { initCollabRef.current?.(Number(newId)); } catch (_) {}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await Kubikon3DApi.updateProject(currentProjectIdRef.current, payload);
|
await Kubikon3DApi.updateProject(currentProjectIdRef.current, payload);
|
||||||
@ -1318,6 +1328,8 @@ const KubikonEditor = () => {
|
|||||||
console.warn('[collab] init skipped:', e?.message || e);
|
console.warn('[collab] init skipped:', e?.message || e);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
// Держим актуальную ссылку на initCollab для вызова из doSave (см. выше).
|
||||||
|
initCollabRef.current = initCollab;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Team Create: «Пригласить» — запросить collab-токен у realtime, собрать
|
* Team Create: «Пригласить» — запросить collab-токен у realtime, собрать
|
||||||
@ -1325,10 +1337,25 @@ const KubikonEditor = () => {
|
|||||||
*/
|
*/
|
||||||
const handleInvite = useCallback(async () => {
|
const handleInvite = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!/^\d+$/.test(id)) { alert('Сначала сохрани проект.'); return; }
|
// Реальный ID берём из ref (он обновляется после сохранения нового
|
||||||
|
// проекта), а НЕ из URL-параметра id — при создании нового проекта
|
||||||
|
// URL остаётся /edit/new даже после успешного сохранения, поэтому
|
||||||
|
// проверка по id давала вечное «Сначала сохрани» (баг 2026-06-08).
|
||||||
|
let pid = currentProjectIdRef.current;
|
||||||
|
// Не сохранён ещё? Попробуем сохранить прямо сейчас, затем пригласить.
|
||||||
|
if (pid == null) {
|
||||||
|
try {
|
||||||
|
await doSave?.();
|
||||||
|
} catch (_) { /* ignore — проверим pid ниже */ }
|
||||||
|
pid = currentProjectIdRef.current;
|
||||||
|
}
|
||||||
|
if (pid == null || !/^\d+$/.test(String(pid))) {
|
||||||
|
alert('Сначала сохрани проект — добавь хотя бы один объект на сцену и нажми «Сохранить».');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const tokenRaw = localStorage.getItem('Authorization') || localStorage.getItem('jwt') || '';
|
const tokenRaw = localStorage.getItem('Authorization') || localStorage.getItem('jwt') || '';
|
||||||
const base = (REALTIME_HTTP || '').replace(/\/$/, '');
|
const base = (REALTIME_HTTP || '').replace(/\/$/, '');
|
||||||
const res = await fetch(`${base}/studio-invite/${id}`, {
|
const res = await fetch(`${base}/studio-invite/${pid}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: tokenRaw, 'Content-Type': 'application/json' },
|
headers: { Authorization: tokenRaw, 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
@ -1338,7 +1365,7 @@ const KubikonEditor = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { token } = await res.json();
|
const { token } = await res.json();
|
||||||
const link = `${window.location.origin}/edit/${id}?collab=${token}`;
|
const link = `${window.location.origin}/edit/${pid}?collab=${token}`;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(link);
|
await navigator.clipboard.writeText(link);
|
||||||
collabOverlayRef.current?.toast('Ссылка-приглашение скопирована! Отправь другу.');
|
collabOverlayRef.current?.toast('Ссылка-приглашение скопирована! Отправь другу.');
|
||||||
@ -1349,7 +1376,7 @@ const KubikonEditor = () => {
|
|||||||
console.warn('[collab] invite failed', e);
|
console.warn('[collab] invite failed', e);
|
||||||
alert('Не удалось создать приглашение. Realtime недоступен?');
|
alert('Не удалось создать приглашение. Realtime недоступен?');
|
||||||
}
|
}
|
||||||
}, [id]);
|
}, [doSave]);
|
||||||
|
|
||||||
// Инициализация Babylon + загрузка проекта (если редактируем существующий)
|
// Инициализация Babylon + загрузка проекта (если редактируем существующий)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1365,6 +1392,10 @@ const KubikonEditor = () => {
|
|||||||
if (!canvasRef.current) return;
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
const scene = new BabylonScene(canvasRef.current);
|
const scene = new BabylonScene(canvasRef.current);
|
||||||
|
// Студия-редактор: тестовый «Запуск» НЕ должен показывать стартовый
|
||||||
|
// экран загрузки игры (он нужен только в плеере на rublox.pro, чтобы
|
||||||
|
// дать время подгрузить ассеты). В студии всё уже в памяти.
|
||||||
|
scene._editorMode = true;
|
||||||
scene.init();
|
scene.init();
|
||||||
sceneRef.current = scene;
|
sceneRef.current = scene;
|
||||||
|
|
||||||
|
|||||||
@ -5972,6 +5972,10 @@ export class BabylonScene {
|
|||||||
* Зовётся из enterPlayMode; держится минимум `duration` сек либо до готовности сцены.
|
* Зовётся из enterPlayMode; держится минимум `duration` сек либо до готовности сцены.
|
||||||
*/
|
*/
|
||||||
showStartupLoadingScreen() {
|
showStartupLoadingScreen() {
|
||||||
|
// В студии-редакторе тестовый «Запуск» НИКОГДА не показывает стартовый
|
||||||
|
// экран загрузки — он нужен только в плеере (rublox.pro «Играть»),
|
||||||
|
// чтобы дать время подгрузить ассеты. В редакторе всё уже в памяти.
|
||||||
|
if (this._editorMode) return;
|
||||||
const cfg = this._loadingConfig;
|
const cfg = this._loadingConfig;
|
||||||
if (!cfg || cfg.enabled === false) return;
|
if (!cfg || cfg.enabled === false) return;
|
||||||
if (!this.gameRuntime) return;
|
if (!this.gameRuntime) return;
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import ModalOverlay from '../editor/ModalOverlay';
|
|||||||
import SkinShopOverlay from '../editor/SkinShopOverlay';
|
import SkinShopOverlay from '../editor/SkinShopOverlay';
|
||||||
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
|
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
|
||||||
import KubikonChatPanel from './KubikonChatPanel';
|
import KubikonChatPanel from './KubikonChatPanel';
|
||||||
import { useAuth } from '../auth/AuthContext.jsx';
|
import { useAuth, redirectToLogin } from '../auth/AuthContext.jsx';
|
||||||
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
|
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
|
||||||
import useDeviceType from '../hooks/useDeviceType';
|
import useDeviceType from '../hooks/useDeviceType';
|
||||||
import KubikonMobileControls from './KubikonMobileControls';
|
import KubikonMobileControls from './KubikonMobileControls';
|
||||||
@ -96,13 +96,14 @@ const KubikonPlayer = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Только зарегистрированные. Если гость / без токена — на логин.
|
// Только зарегистрированные. Если гость / без токена — на вход минки (SSO)
|
||||||
|
// с возвратом на текущий URL (своей страницы /login в студии нет).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
navigate('/login', { replace: true });
|
redirectToLogin();
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, authLoading, navigate]);
|
}, [isAuthenticated, authLoading]);
|
||||||
|
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const sceneRef = useRef(null);
|
const sceneRef = useRef(null);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user