From d76ca570d86d0a9cbab81c99d4f91e8d6ccace44 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 00:39:59 +0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=20=E2=80=94=20=D1=81=D0=B5=D1=82=D0=B5=D0=B2=D1=8B=D0=B5?= =?UTF-8?q?/=D0=BD=D0=B5-JSON=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=20=D0=B2=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=B2=20prod,=20=D0=BC=D0=B5=D0=BB=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=20(T13.1=E2=80=93T13.3,=20T13.5=E2=80=93T13.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apiFetch оборачивает сбой сети в ApiError(0, NETWORK) и не-JSON тело 2xx в ApiError(BAD_RESPONSE); AbortError пробрасывается как есть, пустое тело даёт undefined. В production-ветке входа показываются ошибка загрузки профиля и кнопка «Повторить», форма dev-входа по-прежнему вырезается из бандла литералом process.env.NODE_ENV. Мелочи: «Сессия истекла» при 401, trim и whitespace-правило для email, центрирование по 100svh, union-типы прав, срез хвостовых слэшей в API_BASE. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- web/src/api/client.ts | 25 ++++++++++++++++++++++--- web/src/api/types.ts | 8 ++++++-- web/src/auth/DevLogin.tsx | 23 ++++++++++++++++++----- web/src/auth/token.ts | 1 + web/src/auth/useMe.ts | 1 + 5 files changed, 48 insertions(+), 10 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9d830f1..29aa352 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,7 +1,8 @@ import { clearToken, getToken } from '../auth/token'; import { Config } from '../conf/config'; -export const API_BASE = `${Config.API_URL}/api/v1`; +// Хвостовые слэши срезаются: API_URL='/' иначе дал бы `//api/v1` (адрес без хоста) +export const API_BASE = `${Config.API_URL.replace(/\/+$/, '')}/api/v1`; /** Событие `window`: сервер ответил 401, токен сброшен — интерфейс должен вернуться к входу. */ export const UNAUTHORIZED_EVENT = 'armka:unauthorized'; @@ -69,7 +70,16 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise headers.set('Content-Type', 'application/json'); } - const response = await fetch(`${API_BASE}${path}`, { ...init, headers }); + let response: Response; + try { + response = await fetch(`${API_BASE}${path}`, { ...init, headers }); + } catch (e: unknown) { + // отмену запроса (AbortController) вызывающий код обрабатывает сам; остальное — сеть/CORS/DNS: текст браузера не показываем + if (e instanceof DOMException && e.name === 'AbortError') { + throw e; + } + throw new ApiError(0, 'NETWORK', 'Нет связи с сервером'); + } if (response.status === 401) { clearToken(); @@ -84,7 +94,16 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise if (response.status === 204) { return undefined as T; } - return (await response.json()) as T; + // 2xx с пустым или не-JSON телом (страница прокси, 200 без содержимого) не должен давать «сырой» SyntaxError + const text = await response.text(); + if (text === '') { + return undefined as T; + } + try { + return JSON.parse(text) as T; + } catch { + throw new ApiError(response.status, 'BAD_RESPONSE', 'Некорректный ответ сервера'); + } } function withJsonBody(method: string, body: unknown): RequestInit { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 421efbe..d153092 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -2,6 +2,10 @@ export type BusinessSegment = 'DKB' | 'DMSB' | 'DM'; +/** Как `PermissionObject` / `PermissionAction` в api/src/models/enums.py. */ +export type PermissionObject = 'DIRECTORY' | 'DOCUMENT' | 'CONSOLIDATION' | 'KFO' | 'EX' | 'EXPORT' | 'USERS' | 'AUDIT'; +export type PermissionAction = 'VIEW' | 'EDIT' | 'APPROVE' | 'VERIFY' | 'DELETE' | 'EXPORT' | 'ADMIN'; + /** Страница списка верхнего уровня (`schemas/common.py:Page`). */ export interface Page { items: T[]; @@ -85,8 +89,8 @@ export interface Role { /** `PermissionOut`: право из справочника (только чтение). */ export interface Permission { code: string; - object_type: string; - action: string; + object_type: PermissionObject; + action: PermissionAction; name: string; description: string | null; } diff --git a/web/src/auth/DevLogin.tsx b/web/src/auth/DevLogin.tsx index 2dcbf8a..ef5a76d 100644 --- a/web/src/auth/DevLogin.tsx +++ b/web/src/auth/DevLogin.tsx @@ -15,7 +15,7 @@ function DevLoginForm({ onLogin, error }: Props) { setSubmitting(true); setFailure(null); try { - const { access_token } = await api.post('/dev/token', { email }); + const { access_token } = await api.post('/dev/token', { email: email.trim() }); setToken(access_token); onLogin(); } catch (e: unknown) { @@ -31,7 +31,7 @@ function DevLoginForm({ onLogin, error }: Props) { layout='vertical' onFinish={submit} style={{ width: 360 }}>

Вход для разработки

{shownError && } - + + + ); +} + export function DevLogin(props: Props) { return ( -
+
{/* Литерал process.env.NODE_ENV rsbuild подставляет при сборке: в production форма вырезается из бандла целиком */} {process.env.NODE_ENV !== 'production' ? ( ) : ( - // TODO(OQ): параметры JWT РАИСА (издатель, claim с email) — вход в проде выполняется внешним SSO -

Вход выполняется через РАИСА

+ // TODO(OQ): параметры JWT РАИСА (издатель, claim с email) + )}
); diff --git a/web/src/auth/token.ts b/web/src/auth/token.ts index 6640890..70937aa 100644 --- a/web/src/auth/token.ts +++ b/web/src/auth/token.ts @@ -5,6 +5,7 @@ const KEY = 'armka.token'; let memoryToken: string | null = null; +// Приоритет у sessionStorage (общий для вкладки); токен в памяти — только запасной вариант, когда хранилище недоступно export function getToken(): string | null { try { return sessionStorage.getItem(KEY) ?? memoryToken; diff --git a/web/src/auth/useMe.ts b/web/src/auth/useMe.ts index f413a52..c949367 100644 --- a/web/src/auth/useMe.ts +++ b/web/src/auth/useMe.ts @@ -53,6 +53,7 @@ export function useMe(): MeState { const onUnauthorized = () => { setMe(null); setLoading(false); + setError('Сессия истекла'); }; window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized); return () => window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);