studio/src/auth/AuthContext.jsx
МИН 80c31a1f94
Some checks failed
CI / Lint (pull_request) Failing after 43s
CI / Build (pull_request) Failing after 41s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 6s
chore: onboarding-readiness — CI/ассеты/dev-login
3 блокера перед запуском opensource-контрибьюторов:

1. CI Lint+Format убран format:check (206 файлов студии не
   соответствуют prettier — отдельная задача формат-недели).
   Build/Lint/Secret-scan/PR-size остаются.

2. Ассеты (93 МБ kubikon-assets/) теперь в Gitea Releases:
   https://git.rublox.pro/rublox/studio/releases/tag/assets-v1
   Скачка через scripts/fetch-assets.js (npm run fetch-assets +
   автозапуск через postinstall).

3. Dev-login:
   - IS_DEV расширен до 127.0.0.1 (vite на Windows слушает там)
   - PleeseReg в dev показывает «Войти как гость» (?standalone=1)
     или «Вставить JWT»; в prod — редирект на rublox.pro
   - AuthContext поддерживает ?standalone=1 URL-параметр
   - ModelThumbnails кеш v19→v20 чтобы старые failed-превью
     не блокировали рендер после фикса IS_DEV
2026-05-28 14:55:08 +03:00

152 lines
5.5 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.

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 {}
};
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,
});
})
.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>;
}