fix(web): клиент — сетевые/не-JSON ошибки, ошибка входа в prod, мелочи (T13.1–T13.3, T13.5–T13.10)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 00:39:59 +04:00
parent 498450b763
commit d76ca570d8
5 changed files with 48 additions and 10 deletions

View File

@ -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<T>(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<T>(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 {

View File

@ -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<T> {
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;
}

View File

@ -15,7 +15,7 @@ function DevLoginForm({ onLogin, error }: Props) {
setSubmitting(true);
setFailure(null);
try {
const { access_token } = await api.post<DevTokenResponse>('/dev/token', { email });
const { access_token } = await api.post<DevTokenResponse>('/dev/token', { email: email.trim() });
setToken(access_token);
onLogin();
} catch (e: unknown) {
@ -31,7 +31,7 @@ function DevLoginForm({ onLogin, error }: Props) {
<Form<{ email: string }> layout='vertical' onFinish={submit} style={{ width: 360 }}>
<h3>Вход для разработки</h3>
{shownError && <Alert type='error' showIcon message={shownError} style={{ marginBottom: 16 }} />}
<Form.Item name='email' label='Email' rules={[{ required: true, message: 'Укажите email' }]}>
<Form.Item name='email' label='Email' rules={[{ required: true, whitespace: true, message: 'Укажите email' }]}>
<Input placeholder='admin@armka.local' autoFocus />
</Form.Item>
<Button type='primary' htmlType='submit' loading={submitting} block>
@ -41,15 +41,28 @@ function DevLoginForm({ onLogin, error }: Props) {
);
}
// Production: вход выполняет внешний SSO; если загрузка профиля не удалась, показываем причину и даём повторить запрос
function ProdNotice({ onLogin, error }: Props) {
return (
<div style={{ width: 360 }}>
<p>Вход выполняется через РАИСА</p>
{error && <Alert type='error' showIcon message={error} style={{ marginBottom: 16 }} />}
<Button onClick={onLogin} block>
Повторить
</Button>
</div>
);
}
export function DevLogin(props: Props) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100svh' }}>
{/* Литерал process.env.NODE_ENV rsbuild подставляет при сборке: в production форма вырезается из бандла целиком */}
{process.env.NODE_ENV !== 'production' ? (
<DevLoginForm {...props} />
) : (
// TODO(OQ): параметры JWT РАИСА (издатель, claim с email) — вход в проде выполняется внешним SSO
<p>Вход выполняется через РАИСА</p>
// TODO(OQ): параметры JWT РАИСА (издатель, claim с email)
<ProdNotice {...props} />
)}
</div>
);

View File

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

View File

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