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 { clearToken, getToken } from '../auth/token';
|
||||||
import { Config } from '../conf/config';
|
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, токен сброшен — интерфейс должен вернуться к входу. */
|
/** Событие `window`: сервер ответил 401, токен сброшен — интерфейс должен вернуться к входу. */
|
||||||
export const UNAUTHORIZED_EVENT = 'armka:unauthorized';
|
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');
|
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) {
|
if (response.status === 401) {
|
||||||
clearToken();
|
clearToken();
|
||||||
@ -84,7 +94,16 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
return undefined as T;
|
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 {
|
function withJsonBody(method: string, body: unknown): RequestInit {
|
||||||
|
|||||||
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
export type BusinessSegment = 'DKB' | 'DMSB' | 'DM';
|
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`). */
|
/** Страница списка верхнего уровня (`schemas/common.py:Page`). */
|
||||||
export interface Page<T> {
|
export interface Page<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
@ -85,8 +89,8 @@ export interface Role {
|
|||||||
/** `PermissionOut`: право из справочника (только чтение). */
|
/** `PermissionOut`: право из справочника (только чтение). */
|
||||||
export interface Permission {
|
export interface Permission {
|
||||||
code: string;
|
code: string;
|
||||||
object_type: string;
|
object_type: PermissionObject;
|
||||||
action: string;
|
action: PermissionAction;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ function DevLoginForm({ onLogin, error }: Props) {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setFailure(null);
|
setFailure(null);
|
||||||
try {
|
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);
|
setToken(access_token);
|
||||||
onLogin();
|
onLogin();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@ -31,7 +31,7 @@ function DevLoginForm({ onLogin, error }: Props) {
|
|||||||
<Form<{ email: string }> layout='vertical' onFinish={submit} style={{ width: 360 }}>
|
<Form<{ email: string }> layout='vertical' onFinish={submit} style={{ width: 360 }}>
|
||||||
<h3>Вход для разработки</h3>
|
<h3>Вход для разработки</h3>
|
||||||
{shownError && <Alert type='error' showIcon message={shownError} style={{ marginBottom: 16 }} />}
|
{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 />
|
<Input placeholder='admin@armka.local' autoFocus />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button type='primary' htmlType='submit' loading={submitting} block>
|
<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) {
|
export function DevLogin(props: Props) {
|
||||||
return (
|
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 rsbuild подставляет при сборке: в production форма вырезается из бандла целиком */}
|
||||||
{process.env.NODE_ENV !== 'production' ? (
|
{process.env.NODE_ENV !== 'production' ? (
|
||||||
<DevLoginForm {...props} />
|
<DevLoginForm {...props} />
|
||||||
) : (
|
) : (
|
||||||
// TODO(OQ): параметры JWT РАИСА (издатель, claim с email) — вход в проде выполняется внешним SSO
|
// TODO(OQ): параметры JWT РАИСА (издатель, claim с email)
|
||||||
<p>Вход выполняется через РАИСА</p>
|
<ProdNotice {...props} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,6 +5,7 @@ const KEY = 'armka.token';
|
|||||||
|
|
||||||
let memoryToken: string | null = null;
|
let memoryToken: string | null = null;
|
||||||
|
|
||||||
|
// Приоритет у sessionStorage (общий для вкладки); токен в памяти — только запасной вариант, когда хранилище недоступно
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
try {
|
try {
|
||||||
return sessionStorage.getItem(KEY) ?? memoryToken;
|
return sessionStorage.getItem(KEY) ?? memoryToken;
|
||||||
|
|||||||
@ -53,6 +53,7 @@ export function useMe(): MeState {
|
|||||||
const onUnauthorized = () => {
|
const onUnauthorized = () => {
|
||||||
setMe(null);
|
setMe(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setError('Сессия истекла');
|
||||||
};
|
};
|
||||||
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
||||||
return () => window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
return () => window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user