diff --git a/.gitignore b/.gitignore index e64f907..709fd95 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .env samples/ +/frontend/build/ diff --git a/frontend/src/App.js b/frontend/src/App.js index 202e30c..a22bb7b 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -4,6 +4,9 @@ import AdminDashboard from "./pages/AdminDashboard"; import MobileFormPage from "./pages/MobileFormPage"; import DesktopSARPage from "./pages/DesktopSARPage"; import AnalysisResult from "./pages/analysis/AnalysisResult"; +import Login from "./pages/Login"; +import ProtectedRoute from "./components/ProtectedRoute"; +import { AuthProvider } from "./context/AuthContext"; import "./App.css"; function ThemeProvider({ children }) { @@ -41,20 +44,24 @@ function App() { return ( - - - } /> - - } /> - } /> - - } /> - - } /> - - } /> - - + + + + } /> + + } /> + + } /> + } /> + + } /> + + } /> + + } /> + + + ); } diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..74185fa --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,102 @@ +// Единая точка выхода в API. Подставляет JWT из localStorage в каждый запрос +// и централизованно обрабатывает 401: токен сбрасывается, а слушатели +// (AuthContext) получают событие и уводят пользователя на /login. +// +// Раньше страницы дёргали fetch() напрямую без заголовка Authorization, из-за +// чего защищённые роутеры (require_roles в cases/analyze/admin) отвечали +// 401 {"detail":"Not authenticated"} — форма показывала это как ошибку. + +export const TOKEN_KEY = 'vector_token'; +export const UNAUTHORIZED_EVENT = 'vector:unauthorized'; + +export function getToken() { + try { + return localStorage.getItem(TOKEN_KEY) || ''; + } catch { + // Приватный режим / отключённое хранилище — работаем без сохранения. + return ''; + } +} + +export function setToken(token) { + try { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); + } catch { + /* storage недоступен — токен живёт только в памяти AuthContext */ + } +} + +export function clearToken() { + setToken(''); +} + +function notifyUnauthorized() { + clearToken(); + window.dispatchEvent(new Event(UNAUTHORIZED_EVENT)); +} + +function authHeaders(extra) { + const headers = new Headers(extra || {}); + const token = getToken(); + if (token) headers.set('Authorization', `Bearer ${token}`); + return headers; +} + +/** + * Обёртка над fetch: добавляет Bearer-токен и перехватывает 401. + * Возвращает обычный Response — вызывающий код проверяет response.ok как раньше. + */ +export async function apiFetch(path, options = {}) { + const { headers, body, ...rest } = options; + const finalHeaders = authHeaders(headers); + + // FormData сама выставляет multipart-boundary: Content-Type не трогаем. + const isFormData = typeof FormData !== 'undefined' && body instanceof FormData; + if (body && !isFormData && !finalHeaders.has('Content-Type')) { + finalHeaders.set('Content-Type', 'application/json'); + } + + const response = await fetch(path, { ...rest, body, headers: finalHeaders }); + + if (response.status === 401) { + notifyUnauthorized(); + } + + return response; +} + +/** apiFetch + разбор JSON и подъём detail из тела ошибки. */ +export async function apiJson(path, options = {}) { + const response = await apiFetch(path, options); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload.detail || `Ошибка запроса (${response.status})`); + } + return response.json(); +} + +/** POST /api/v1/auth/login — бэкенд ждёт OAuth2 form-urlencoded, не JSON. */ +export async function login(username, password) { + const form = new URLSearchParams(); + form.set('username', username); + form.set('password', password); + + const response = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: form.toString(), + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.detail || 'Неверный логин или пароль'); + } + setToken(payload.access_token); + return payload.access_token; +} + +/** GET /api/v1/auth/me — проверка токена и получение роли. */ +export function fetchMe() { + return apiJson('/api/v1/auth/me'); +} diff --git a/frontend/src/components/CaseForm/CaseForm.jsx b/frontend/src/components/CaseForm/CaseForm.jsx index aa3fdac..9e3c668 100644 --- a/frontend/src/components/CaseForm/CaseForm.jsx +++ b/frontend/src/components/CaseForm/CaseForm.jsx @@ -5,6 +5,7 @@ import Step2Health from "./Step2Health"; import Step2bPsychotype from "./Step2bPsychotype"; import Step3Circumstances from "./Step3Circumstances"; import Step4Environment from "./Step4Environment"; +import { apiFetch } from "../../api/client"; import "./CaseForm.css"; const INITIAL_DATA = { @@ -106,9 +107,8 @@ const CaseForm = ({ isMobile = false }) => { }; // Создаем карточку - const response = await fetch("/api/v1/cases", { + const response = await apiFetch("/api/v1/cases", { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..7ad0c49 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; + +import { useAuth } from '../context/AuthContext'; + +/** + * Пускает дальше только с валидным токеном. Пока идёт первая проверка + * (/auth/me) — держим заглушку, иначе при живом токене будет ложный редирект. + * roles задаёт минимальные права: бэкенд всё равно проверяет их сам + * (require_roles), это лишь чтобы не показывать заведомо пустую страницу. + */ +function ProtectedRoute({ children, roles }) { + const { isAuthenticated, loading, user } = useAuth(); + const location = useLocation(); + + if (loading) { + return
Проверка сессии…
; + } + + if (!isAuthenticated) { + return ; + } + + if (roles && !roles.includes(user.role)) { + return ( +
+ Недостаточно прав: нужна роль {roles.join(' или ')}, у вас «{user.role}». +
+ ); + } + + return children; +} + +export default ProtectedRoute; diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..3c91d67 --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -0,0 +1,79 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; + +import { + UNAUTHORIZED_EVENT, + clearToken, + fetchMe, + getToken, + login as apiLogin, +} from '../api/client'; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null); + // loading=true до первой проверки токена, иначе ProtectedRoute успеет + // редиректнуть на /login при живом токене в localStorage. + const [loading, setLoading] = useState(true); + + const logout = useCallback(() => { + clearToken(); + setUser(null); + }, []); + + // Стартовая валидация сохранённого токена через /auth/me. + useEffect(() => { + let cancelled = false; + + if (!getToken()) { + setLoading(false); + return undefined; + } + + fetchMe() + .then((me) => { + if (!cancelled) setUser(me); + }) + .catch(() => { + if (!cancelled) logout(); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [logout]); + + // Любой 401 из apiFetch (истёк токен, сменился JWT_SECRET) — выкидываем. + useEffect(() => { + const onUnauthorized = () => setUser(null); + window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized); + return () => window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized); + }, []); + + const signIn = useCallback(async (username, password) => { + await apiLogin(username, password); + const me = await fetchMe(); + setUser(me); + return me; + }, []); + + const value = useMemo( + () => ({ user, loading, signIn, logout, isAuthenticated: Boolean(user) }), + [user, loading, signIn, logout] + ); + + return {children}; +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth должен вызываться внутри '); + } + return context; +} + +export default AuthContext; diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx index 7ffbc50..2b586f4 100644 --- a/frontend/src/pages/AdminDashboard.jsx +++ b/frontend/src/pages/AdminDashboard.jsx @@ -1,5 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; +import { apiFetch } from '../api/client'; +import { useAuth } from '../context/AuthContext'; import './AdminDashboard.css'; const PAGE_SIZE = 10; @@ -28,6 +30,7 @@ function Badge({ children, tone = 'neutral' }) { } function AdminDashboard() { + const { user, logout } = useAuth(); const [cases, setCases] = useState([]); const [dashboard, setDashboard] = useState(null); const [loading, setLoading] = useState(false); @@ -69,7 +72,7 @@ function AdminDashboard() { if (filters.ageMin !== '') params.set('age_min', filters.ageMin); if (filters.ageMax !== '') params.set('age_max', filters.ageMax); - const response = await fetch(`/api/v1/admin/cases?${params.toString()}`, { + const response = await apiFetch(`/api/v1/admin/cases?${params.toString()}`, { signal: controller.signal, }); if (!response.ok) { @@ -106,7 +109,7 @@ function AdminDashboard() { async function loadDashboard() { setDashboardLoading(true); try { - const response = await fetch('/api/v1/admin/dashboard', { signal: controller.signal }); + const response = await apiFetch('/api/v1/admin/dashboard', { signal: controller.signal }); if (!response.ok) { throw new Error(`Не удалось загрузить dashboard (${response.status})`); } @@ -169,7 +172,7 @@ function AdminDashboard() { const formData = new FormData(); formData.append('file', uploadFile); - const response = await fetch('/api/v1/admin/parse-doc', { + const response = await apiFetch('/api/v1/admin/parse-doc', { method: 'POST', body: formData, }); @@ -221,9 +224,8 @@ function AdminDashboard() { note: parseCaseFields.notes ?? null, }; - const response = await fetch('/api/v1/cases', { + const response = await apiFetch('/api/v1/cases', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); @@ -286,9 +288,8 @@ function AdminDashboard() { note: editForm.notes ?? null, }; - const response = await fetch(`/api/v1/admin/cases/${selectedCaseId}`, { + const response = await apiFetch(`/api/v1/admin/cases/${selectedCaseId}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); @@ -303,7 +304,7 @@ function AdminDashboard() { setEditForm(updated); setDashboardLoading(true); try { - const dashboardResponse = await fetch('/api/v1/admin/dashboard'); + const dashboardResponse = await apiFetch('/api/v1/admin/dashboard'); if (dashboardResponse.ok) { setDashboard(await dashboardResponse.json()); } @@ -331,6 +332,9 @@ function AdminDashboard() { + diff --git a/frontend/src/pages/Login.css b/frontend/src/pages/Login.css new file mode 100644 index 0000000..9a0efdb --- /dev/null +++ b/frontend/src/pages/Login.css @@ -0,0 +1,102 @@ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: #0f172a; +} + +.login-loading { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + color: #94a3b8; + background: #0f172a; + font-size: 15px; +} + +.login-card { + width: 100%; + max-width: 380px; + display: flex; + flex-direction: column; + gap: 16px; + padding: 32px 28px; + border-radius: 14px; + background: #1e293b; + border: 1px solid #334155; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); +} + +.login-header h1 { + margin: 0 0 6px; + font-size: 26px; + letter-spacing: 0.14em; + color: #f8fafc; +} + +.login-header p { + margin: 0; + font-size: 13px; + line-height: 1.45; + color: #94a3b8; +} + +.login-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.login-field span { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #94a3b8; +} + +.login-field input { + padding: 11px 12px; + font-size: 15px; + color: #f8fafc; + background: #0f172a; + border: 1px solid #334155; + border-radius: 8px; +} + +.login-field input:focus { + outline: none; + border-color: #38bdf8; +} + +.login-error { + padding: 10px 12px; + font-size: 13px; + color: #fecaca; + background: rgba(153, 27, 27, 0.35); + border: 1px solid #b91c1c; + border-radius: 8px; +} + +.login-button { + margin-top: 4px; + padding: 12px 16px; + font-size: 15px; + font-weight: 600; + color: #04121f; + background: #38bdf8; + border: none; + border-radius: 8px; + cursor: pointer; +} + +.login-button:hover:not(:disabled) { + background: #0ea5e9; +} + +.login-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..53fd08f --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,87 @@ +import React, { useState } from 'react'; +import { Navigate, useLocation, useNavigate } from 'react-router-dom'; + +import { useAuth } from '../context/AuthContext'; +import './Login.css'; + +function Login() { + const { signIn, isAuthenticated, loading } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + // Куда вернуться после входа — ProtectedRoute кладёт исходный путь в state. + const from = location.state?.from || '/'; + + if (loading) { + return
Проверка сессии…
; + } + + if (isAuthenticated) { + return ; + } + + async function handleSubmit(event) { + event.preventDefault(); + setSubmitting(true); + setError(''); + + try { + const me = await signIn(username.trim(), password); + // Оператор/field работают с карточкой, admin — со сводкой. + const target = from !== '/' ? from : me.role === 'admin' ? '/admin' : '/'; + navigate(target, { replace: true }); + } catch (err) { + setError(err.message || 'Не удалось войти'); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
+

ВЕКТОР

+

Система определения приоритетных направлений поиска

+
+ + + + + + {error ?
{error}
: null} + + +
+
+ ); +} + +export default Login; diff --git a/frontend/src/pages/analysis/AnalysisResult.jsx b/frontend/src/pages/analysis/AnalysisResult.jsx index 17ca8e4..e2379c5 100644 --- a/frontend/src/pages/analysis/AnalysisResult.jsx +++ b/frontend/src/pages/analysis/AnalysisResult.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from "react"; import { useParams } from "react-router-dom"; import SearchMap from '../../components/SearchMap'; +import { apiFetch } from "../../api/client"; import "./AnalysisResult.css"; const AnalysisResult = () => { @@ -16,7 +17,7 @@ const AnalysisResult = () => { setLoading(true); setError(null); - const caseResponse = await fetch(`/api/v1/cases/${caseId}`); + const caseResponse = await apiFetch(`/api/v1/cases/${caseId}`); if (!caseResponse.ok) { throw new Error("Не удалось загрузить карточку"); }