3 блокера перед запуском opensource-контрибьюторов: 1. CI Lint+Format убран format:check (отдельная формат-неделя). Secret-scan переехал с docker run на нативный trufflehog install. 2. Ассеты (106 МБ kubikon-assets/) в Gitea Releases: https://git.rublox.pro/rublox/player/releases/tag/assets-v1 npm run fetch-assets + postinstall. 3. PlayerAuth поддерживает ?standalone=1 URL-параметр (раньше только через VITE_STANDALONE в .env).
211 lines
7.4 KiB
JavaScript
211 lines
7.4 KiB
JavaScript
// PlayerAuth — авторизация плеера.
|
||
//
|
||
// Поток на проде:
|
||
// 1. URL содержит #ticket=<hex> → POST /auth/redeem-ticket → сохраняем JWT
|
||
// 2. Иначе берём JWT из localStorage["player_jwt"], валидируем exp.
|
||
// 3. Если ничего нет — `window.location.assign('https://rublox.pro/app')`.
|
||
//
|
||
// На локалке (localhost) шаг 3 не делаем, чтобы дев-фоллбек по плану
|
||
// (раздел 3.3 PLAN.md) работал: пользователь руками кладёт JWT в
|
||
// localStorage и перезагружает страницу.
|
||
//
|
||
// Контракт useAuth() совпадает с тем, что ожидает скопированный
|
||
// KubikonPlayer.jsx: { user, isAuthenticated, isLoading, jwt }.
|
||
|
||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||
import { jwtDecode } from 'jwt-decode';
|
||
import {
|
||
redeemTicket,
|
||
saveJWT,
|
||
getJWT,
|
||
clearJWT,
|
||
readTicketFromHash,
|
||
readTeamJwtFromHash,
|
||
clearTicketFromUrl,
|
||
} from './ticketExchange';
|
||
import { RUBLOX_HOME } from '../api/API';
|
||
|
||
const PlayerAuthCtx = createContext(null);
|
||
|
||
const IS_LOCAL =
|
||
typeof window !== 'undefined' &&
|
||
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
|
||
|
||
function decodeJwtSafe(raw) {
|
||
try {
|
||
const payload = jwtDecode(raw);
|
||
if (payload.exp && payload.exp * 1000 < Date.now()) return null;
|
||
return payload;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function PlayerAuthProvider({ children }) {
|
||
const [state, setState] = useState({
|
||
user: null,
|
||
isAuthenticated: false,
|
||
isLoading: true,
|
||
jwt: null,
|
||
error: null,
|
||
});
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
// STANDALONE-режим: пропускаем auth и сразу считаем юзера авторизованным
|
||
// под dummy-id 0. Используется для разработки без бэкенда.
|
||
// Включается через VITE_STANDALONE=true или через ?standalone=1 в URL.
|
||
const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
|
||
const urlStandalone = typeof window !== 'undefined' &&
|
||
new URLSearchParams(window.location.search).get('standalone') === '1';
|
||
if (String(env.VITE_STANDALONE).toLowerCase() === 'true' || urlStandalone) {
|
||
setState({
|
||
user: { id: 0, firstName: 'Guest', _standalone: true },
|
||
isAuthenticated: true,
|
||
isLoading: false,
|
||
jwt: 'standalone',
|
||
error: null,
|
||
});
|
||
return;
|
||
}
|
||
|
||
async function bootstrap() {
|
||
// 0. team_jwt в URL — модератор из team.rublox.pro открывает
|
||
// draft/review-игру для проверки. Просто сохраняем JWT, без
|
||
// redeem-flow: team_jwt и user_jwt подписаны одним секретом, у
|
||
// user-сервиса и storys прозрачно работают (storys проверит
|
||
// moderator-роль через team-flask и пропустит к не-published).
|
||
const teamJwt = readTeamJwtFromHash();
|
||
if (teamJwt) {
|
||
try {
|
||
const payload = decodeJwtSafe(teamJwt);
|
||
if (payload) {
|
||
saveJWT(teamJwt);
|
||
clearTicketFromUrl();
|
||
setState({
|
||
user: { id: payload.id, ...payload, _mod_view: true },
|
||
isAuthenticated: true,
|
||
isLoading: false,
|
||
jwt: teamJwt,
|
||
error: null,
|
||
});
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
clearTicketFromUrl();
|
||
console.warn('[auth] team_jwt decode failed:', e?.message || e);
|
||
}
|
||
}
|
||
|
||
// 1. Ticket в URL — самый приоритетный путь.
|
||
const ticket = readTicketFromHash();
|
||
if (ticket) {
|
||
try {
|
||
const res = await redeemTicket(ticket);
|
||
if (cancelled) return;
|
||
if (res && res.jwt) {
|
||
saveJWT(res.jwt);
|
||
clearTicketFromUrl();
|
||
const payload = decodeJwtSafe(res.jwt) || {};
|
||
setState({
|
||
user: res.user || { id: payload.id, ...payload },
|
||
isAuthenticated: true,
|
||
isLoading: false,
|
||
jwt: res.jwt,
|
||
error: null,
|
||
});
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
if (cancelled) return;
|
||
clearTicketFromUrl();
|
||
// Не сваливаемся, а ниже попробуем существующий JWT в localStorage.
|
||
// Если и его нет — отрисуем экран с понятной ошибкой.
|
||
// eslint-disable-next-line no-console
|
||
console.warn('[auth] redeemTicket failed:', e?.message || e);
|
||
}
|
||
}
|
||
|
||
// 2. Существующий JWT в localStorage.
|
||
const existing = getJWT();
|
||
if (existing) {
|
||
const payload = decodeJwtSafe(existing);
|
||
if (payload) {
|
||
// На случай dev-fallback (юзер положил JWT через DevTools прямо
|
||
// в player_jwt) — синхронизируем Authorization-зеркало. Иначе
|
||
// engine (читающий 'Authorization') увидит null.
|
||
saveJWT(existing);
|
||
setState({
|
||
user: { id: payload.id, ...payload },
|
||
isAuthenticated: true,
|
||
isLoading: false,
|
||
jwt: existing,
|
||
error: null,
|
||
});
|
||
return;
|
||
}
|
||
clearJWT();
|
||
}
|
||
|
||
// 3. Ничего нет. На проде — редирект, на локалке — оставляем заглушку.
|
||
if (cancelled) return;
|
||
if (IS_LOCAL) {
|
||
setState({
|
||
user: null,
|
||
isAuthenticated: false,
|
||
isLoading: false,
|
||
jwt: null,
|
||
error: 'no_jwt_local',
|
||
});
|
||
} else {
|
||
// Сохраняем gameId в return-URL, чтобы после логина можно было
|
||
// вернуть юзера на ту же игру (rublox.pro/app сам разрулит).
|
||
const ret = encodeURIComponent(window.location.pathname);
|
||
window.location.assign(`${RUBLOX_HOME}?return=${ret}`);
|
||
}
|
||
}
|
||
|
||
bootstrap();
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
// Реакция на ручную подмену JWT через DevTools (dev-fallback) —
|
||
// меняем стейт, чтобы перерендерить без F5.
|
||
useEffect(() => {
|
||
if (!IS_LOCAL) return;
|
||
const onStorage = (e) => {
|
||
if (e.key !== 'player_jwt') return;
|
||
const raw = getJWT();
|
||
const payload = raw ? decodeJwtSafe(raw) : null;
|
||
if (payload) {
|
||
setState({
|
||
user: { id: payload.id, ...payload },
|
||
isAuthenticated: true,
|
||
isLoading: false,
|
||
jwt: raw,
|
||
error: null,
|
||
});
|
||
} else {
|
||
setState((s) => ({ ...s, user: null, isAuthenticated: false, jwt: null }));
|
||
}
|
||
};
|
||
window.addEventListener('storage', onStorage);
|
||
return () => window.removeEventListener('storage', onStorage);
|
||
}, []);
|
||
|
||
return (
|
||
<PlayerAuthCtx.Provider value={state}>
|
||
{children}
|
||
</PlayerAuthCtx.Provider>
|
||
);
|
||
}
|
||
|
||
export function useAuth() {
|
||
const ctx = useContext(PlayerAuthCtx);
|
||
if (!ctx) {
|
||
return { user: null, isAuthenticated: false, isLoading: false, jwt: null, error: null };
|
||
}
|
||
return ctx;
|
||
}
|