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:
parent
498450b763
commit
d76ca570d8
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user