// PlayerAuth — авторизация плеера. // // Поток на проде: // 1. URL содержит #ticket= → 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 ( {children} ); } export function useAuth() { const ctx = useContext(PlayerAuthCtx); if (!ctx) { return { user: null, isAuthenticated: false, isLoading: false, jwt: null, error: null }; } return ctx; }