feat(auth): minimal login/logout + ProtectedRoute (B-LOGOUT)
- api/client.js: apiFetch подставляет Bearer-токен, выставляет Content-Type: application/json для не-FormData тел, ловит 401 -> событие - context/AuthContext: валидация токена через /auth/me, loading-гейт, logout(), подписка на 401 - components/ProtectedRoute: guard с ролями и заглушкой на время проверки - pages/Login: форма входа (OAuth2 form-urlencoded), редирект на from - App.js: AuthProvider, /login, ProtectedRoute на /sar /desktop /analysis /admin (admin -> roles=[admin]) /mobile - AdminDashboard/CaseForm/AnalysisResult: переход на apiFetch - AdminDashboard: кнопка "Выйти" в шапке (useAuth().logout) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,3 +4,4 @@ __pycache__/
|
||||
.env
|
||||
|
||||
samples/
|
||||
/frontend/build/
|
||||
|
||||
+12
-5
@@ -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 (
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route path="/" element={<Navigate to={isMobile ? "/mobile" : "/desktop"} replace />} />
|
||||
|
||||
<Route path="/sar" element={<DesktopSARPage />} />
|
||||
<Route path="/desktop" element={<DesktopSARPage />} />
|
||||
<Route path="/sar" element={<ProtectedRoute><DesktopSARPage /></ProtectedRoute>} />
|
||||
<Route path="/desktop" element={<ProtectedRoute><DesktopSARPage /></ProtectedRoute>} />
|
||||
|
||||
<Route path="/analysis/:caseId" element={<AnalysisResult />} />
|
||||
<Route path="/analysis/:caseId" element={<ProtectedRoute><AnalysisResult /></ProtectedRoute>} />
|
||||
|
||||
<Route path="/admin/*" element={<AdminDashboard />} />
|
||||
<Route path="/admin/*" element={<ProtectedRoute roles={["admin"]}><AdminDashboard /></ProtectedRoute>} />
|
||||
|
||||
<Route path="/mobile" element={<MobileFormPage />} />
|
||||
<Route path="/mobile" element={<ProtectedRoute><MobileFormPage /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
|
||||
@@ -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 <div className="login-loading">Проверка сессии…</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
if (roles && !roles.includes(user.role)) {
|
||||
return (
|
||||
<div className="login-loading">
|
||||
Недостаточно прав: нужна роль {roles.join(' или ')}, у вас «{user.role}».
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth должен вызываться внутри <AuthProvider>');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export default AuthContext;
|
||||
@@ -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() {
|
||||
<button type="button" className="admin-secondary-button" onClick={() => setPage(1)}>
|
||||
Обновить
|
||||
</button>
|
||||
<button type="button" className="admin-secondary-button" onClick={logout}>
|
||||
Выйти{user ? ` · ${user.username}` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <div className="login-loading">Проверка сессии…</div>;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to={from} replace />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="login-page">
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<div className="login-header">
|
||||
<h1>ВЕКТОР</h1>
|
||||
<p>Система определения приоритетных направлений поиска</p>
|
||||
</div>
|
||||
|
||||
<label className="login-field">
|
||||
<span>Логин</span>
|
||||
<input
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="login-field">
|
||||
<span>Пароль</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <div className="login-error">{error}</div> : null}
|
||||
|
||||
<button type="submit" className="login-button" disabled={submitting}>
|
||||
{submitting ? 'Вход…' : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
@@ -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("Не удалось загрузить карточку");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user