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 (
+
+ );
+}
+
+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("Не удалось загрузить карточку");
}