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:
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user