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:
root
2026-08-20 16:04:05 +00:00
parent c2ad5c1915
commit bef93d159c
10 changed files with 443 additions and 25 deletions
+12 -8
View File
@@ -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>
+102
View File
@@ -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;
}
+87
View File
@@ -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("Не удалось загрузить карточку");
}