player/src/auth/PlayerAuth.jsx
МИН fafed7243f
Some checks failed
CI / Lint (pull_request) Failing after 31s
CI / Build (pull_request) Failing after 37s
CI / Secret scan (pull_request) Successful in 2m25s
CI / PR size check (pull_request) Successful in 5s
chore: onboarding-readiness — CI/ассеты/?standalone=1
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).
2026-05-28 14:55:23 +03:00

211 lines
7.4 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.

// 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;
}