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);