feat(web): API-клиент с Bearer, dev-вход по email, загрузка /me
Токен хранится в sessionStorage (design D-9; без Redux): живёт до закрытия вкладки, все обращения к хранилищу в try/catch с запасным токеном в памяти.
apiFetch подставляет Bearer, на 401 сбрасывает токен и шлёт событие armka:unauthorized, ошибки разбирает по трём формам detail (строка / {code,message} / массив 422).
useMe не ходит в /me без токена и отбрасывает устаревшие ответы (флаг cancelled), AuthGate показывает вход, пока профиля нет.
Форма DevLogin вырезается из production-сборки по литералу process.env.NODE_ENV; через Config это не работает: process.env там заменён объектом REACT_APP_*.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
6217e4e123
commit
498450b763
@ -226,9 +226,9 @@ export const api = { get: <T>(p: string) => apiFetch<T>(p), post: <T>(p: string,
|
||||
- `web/src/auth/DevLogin.tsx`: форма antd (`Input` email + `Button` «Войти (dev)»), `POST /dev/token {email}` → `setToken(token)` → `onLogin()`; рендерится только при `process.env.NODE_ENV !== 'production'` (rsbuild подставляет); в проде — текст «Вход выполняется через РАИСА» (TODO OQ: параметры JWT РАИСА).
|
||||
- Ответ dev-токена: `DevTokenResponse.access_token` (`src/api/endpoints/dev_auth.py:40`).
|
||||
|
||||
- [ ] **Step 1:** написать модули; `App.tsx`: обернуть `Routes` в компонент `AuthGate` (`useMe`; нет токена/`me` → `DevLogin`, иначе дети). `Main.tsx` показывает `me.full_name` и роли.
|
||||
- [ ] **Step 2:** `cd web && npm install && npx biome check src && npx tsc --noEmit && npm run build` → PASS. Ручная проверка (README в Task 15): `make up`, `make migrate && make seed-dev`, `cd api && python run_server.py`, `cd web && npm run start`, вход `admin@armka.local`.
|
||||
- [ ] **Step 3: Commit** `feat(web): API-клиент с Bearer, dev-вход по email, загрузка /me`.
|
||||
- [x] **Step 1:** написать модули; `App.tsx`: обернуть `Routes` в компонент `AuthGate` (`useMe`; нет токена/`me` → `DevLogin`, иначе дети). `Main.tsx` показывает `me.full_name` и роли.
|
||||
- [x] **Step 2:** `cd web && npm install && npx biome check src && npx tsc --noEmit && npm run build` → PASS. Ручная проверка (README в Task 15): `make up`, `make migrate && make seed-dev`, `cd api && python run_server.py`, `cd web && npm run start`, вход `admin@armka.local`.
|
||||
- [x] **Step 3: Commit** `feat(web): API-клиент с Bearer, dev-вход по email, загрузка /me`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -68,7 +68,7 @@ test-integration`). Пути — от корня репозитория. Доп
|
||||
`roles.py` (`GET roles`, `GET permissions`, `PUT roles/{code}/permissions` с `LAST_ADMIN` и защитой `is_system`),
|
||||
аудит `USER_CHANGE` / `USER_ROLE_CHANGE` / `ROLE_PERMISSION_CHANGE`. Тесты: сценарии спеки `users-admin` (создание с ролью →
|
||||
`/me`, email в другом регистре, замена набора ролей, последний администратор, матрица → `/me` видит новое право).
|
||||
- [ ] 4.2 **Фронт: API-клиент и dev-вход.** `web/src/api/client.ts` (`apiFetch<T>(path, init)`: базовый URL, Bearer из
|
||||
- [x] 4.2 **Фронт: API-клиент и dev-вход.** `web/src/api/client.ts` (`apiFetch<T>(path, init)`: базовый URL, Bearer из
|
||||
`sessionStorage`, 401 → очистка токена и переход на вход, ошибка `{detail}` → `Error` с текстом), `web/src/api/types.ts`
|
||||
(DTO `/me`, `/admin/*`), `web/src/auth/DevLogin.tsx` (поле email → `POST /dev/token`, только при `process.env.NODE_ENV !==
|
||||
'production'`), `web/src/auth/useMe.ts` (загрузка `/me`, права). Проверка: `make web-check`; ручной сценарий в README.
|
||||
|
||||
@ -2,6 +2,7 @@ import './App.css';
|
||||
import { Layout } from 'antd';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { AuthGate } from './auth/AuthGate';
|
||||
import { Config } from './conf/config';
|
||||
import { Main } from './layouts/Main';
|
||||
import { store } from './store/store';
|
||||
@ -12,14 +13,16 @@ function App() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<BrowserRouter basename={Config.APP_ADDRESS}>
|
||||
<Layout hasSider={true} style={{ height: '100vh' }}>
|
||||
<Sidebar />
|
||||
<Layout.Content>
|
||||
<Routes>
|
||||
<Route path='/' element={<Main />} />
|
||||
</Routes>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
<AuthGate>
|
||||
<Layout hasSider={true} style={{ height: '100vh' }}>
|
||||
<Sidebar />
|
||||
<Layout.Content>
|
||||
<Routes>
|
||||
<Route path='/' element={<Main />} />
|
||||
</Routes>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</AuthGate>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
100
web/src/api/client.ts
Normal file
100
web/src/api/client.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { clearToken, getToken } from '../auth/token';
|
||||
import { Config } from '../conf/config';
|
||||
|
||||
export const API_BASE = `${Config.API_URL}/api/v1`;
|
||||
|
||||
/** Событие `window`: сервер ответил 401, токен сброшен — интерфейс должен вернуться к входу. */
|
||||
export const UNAUTHORIZED_EVENT = 'armka:unauthorized';
|
||||
|
||||
const FALLBACK_MESSAGE = 'Не удалось выполнить запрос';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string | undefined,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
type ErrorInfo = { code: string | undefined; message: string };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Тело ошибки бэкенда — `{"detail": …}` в одной из трёх форм:
|
||||
* строка (HTTPException) → message; объект `{code, message}` (доменная ошибка) → code + message;
|
||||
* массив (422 валидации pydantic) → `msg` первого элемента.
|
||||
*/
|
||||
function parseErrorBody(body: unknown): ErrorInfo {
|
||||
const detail = isRecord(body) ? body.detail : undefined;
|
||||
if (typeof detail === 'string') {
|
||||
return { code: undefined, message: detail };
|
||||
}
|
||||
if (isRecord(detail)) {
|
||||
const code = typeof detail.code === 'string' ? detail.code : undefined;
|
||||
const message = typeof detail.message === 'string' ? detail.message : FALLBACK_MESSAGE;
|
||||
return { code, message };
|
||||
}
|
||||
if (Array.isArray(detail)) {
|
||||
const first: unknown = detail[0];
|
||||
if (isRecord(first) && typeof first.msg === 'string') {
|
||||
return { code: undefined, message: first.msg };
|
||||
}
|
||||
}
|
||||
return { code: undefined, message: FALLBACK_MESSAGE };
|
||||
}
|
||||
|
||||
async function readErrorInfo(response: Response): Promise<ErrorInfo> {
|
||||
try {
|
||||
return parseErrorBody(await response.json());
|
||||
} catch {
|
||||
// тело не JSON (например, ответ прокси) — показываем общий текст
|
||||
return { code: undefined, message: FALLBACK_MESSAGE };
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
// JSON-заголовок только для строкового тела; FormData и др. отдаём браузеру как есть (он сам задаёт boundary)
|
||||
if (typeof init.body === 'string' && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
|
||||
const info = await readErrorInfo(response);
|
||||
throw new ApiError(401, info.code, info.message);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const info = await readErrorInfo(response);
|
||||
throw new ApiError(response.status, info.code, info.message);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function withJsonBody(method: string, body: unknown): RequestInit {
|
||||
return body === undefined ? { method } : { method, body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => apiFetch<T>(path),
|
||||
post: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('POST', body)),
|
||||
patch: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('PATCH', body)),
|
||||
put: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('PUT', body)),
|
||||
del: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
102
web/src/api/types.ts
Normal file
102
web/src/api/types.ts
Normal file
@ -0,0 +1,102 @@
|
||||
// Типы ответов API. Имена полей — как в Pydantic-схемах бэкенда (api/src/schemas/*.py), это источник истины.
|
||||
|
||||
export type BusinessSegment = 'DKB' | 'DMSB' | 'DM';
|
||||
|
||||
/** Страница списка верхнего уровня (`schemas/common.py:Page`). */
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** Филиал кратко: так он приходит в `GET /me` (без `is_active`). */
|
||||
export interface RegionalOfficeBrief {
|
||||
id: string;
|
||||
name: string;
|
||||
is_head_office: boolean;
|
||||
}
|
||||
|
||||
/** Филиал целиком: `GET /directories/regional-offices` (простой массив, не `Page`) и пользователи администратора. */
|
||||
export interface RegionalOffice extends RegionalOfficeBrief {
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/** `GET /me`: профиль, коды ролей и эффективные права (оба списка отсортированы по алфавиту). */
|
||||
export interface Me {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
regional_office: RegionalOfficeBrief;
|
||||
business_segment: BusinessSegment | null;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
/** `POST /dev/token`: JWT для заголовка `Authorization: Bearer` (только при AUTH_DEV_ISSUER и DEBUG). */
|
||||
export interface DevTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
/** `UserOut`: пользователь для администратора. */
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
email: string | null;
|
||||
full_name: string;
|
||||
employee_no: string | null;
|
||||
regional_office: RegionalOffice;
|
||||
business_segment: BusinessSegment | null;
|
||||
is_active: boolean;
|
||||
roles: string[];
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/** `UserCreate`: тело `POST /admin/users`. */
|
||||
export interface UserCreate {
|
||||
email: string;
|
||||
full_name: string;
|
||||
regional_office_id: string;
|
||||
employee_no?: string | null;
|
||||
business_segment?: BusinessSegment | null;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
/** `UserUpdate`: тело `PATCH /admin/users/{id}` (передаются только изменяемые поля). */
|
||||
export interface UserUpdate {
|
||||
full_name?: string;
|
||||
employee_no?: string | null;
|
||||
regional_office_id?: string;
|
||||
business_segment?: BusinessSegment | null;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
/** `RoleOut`: роль с кодами её прав. */
|
||||
export interface Role {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_system: boolean;
|
||||
is_active: boolean;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
/** `PermissionOut`: право из справочника (только чтение). */
|
||||
export interface Permission {
|
||||
code: string;
|
||||
object_type: string;
|
||||
action: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
/** `RolesSet`: новый набор ролей пользователя. */
|
||||
export interface RolesSet {
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
/** `PermissionsSet`: новый набор прав роли. */
|
||||
export interface PermissionsSet {
|
||||
permissions: string[];
|
||||
}
|
||||
18
web/src/auth/AuthGate.tsx
Normal file
18
web/src/auth/AuthGate.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Spin } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import { DevLogin } from './DevLogin';
|
||||
import { MeContext } from './MeContext';
|
||||
import { useMe } from './useMe';
|
||||
|
||||
// Пока профиль не загружен — вход (или индикатор загрузки); после входа дети получают профиль через `useCurrentUser`.
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { me, loading, error, reload } = useMe();
|
||||
|
||||
if (loading) {
|
||||
return <Spin fullscreen />;
|
||||
}
|
||||
if (!me) {
|
||||
return <DevLogin onLogin={reload} error={error} />;
|
||||
}
|
||||
return <MeContext.Provider value={me}>{children}</MeContext.Provider>;
|
||||
}
|
||||
56
web/src/auth/DevLogin.tsx
Normal file
56
web/src/auth/DevLogin.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { Alert, Button, Form, Input } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { DevTokenResponse } from '../api/types';
|
||||
import { setToken } from './token';
|
||||
|
||||
type Props = { onLogin: () => void; error?: string | null };
|
||||
|
||||
// Вход по email для разработки: бэкенд выпускает токен только при AUTH_DEV_ISSUER=true и DEBUG=true.
|
||||
function DevLoginForm({ onLogin, error }: Props) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [failure, setFailure] = useState<string | null>(null);
|
||||
|
||||
const submit = async ({ email }: { email: string }) => {
|
||||
setSubmitting(true);
|
||||
setFailure(null);
|
||||
try {
|
||||
const { access_token } = await api.post<DevTokenResponse>('/dev/token', { email });
|
||||
setToken(access_token);
|
||||
onLogin();
|
||||
} catch (e: unknown) {
|
||||
setFailure(e instanceof Error ? e.message : 'Не удалось выполнить вход');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const shownError = failure ?? error ?? null;
|
||||
|
||||
return (
|
||||
<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' }]}>
|
||||
<Input placeholder='admin@armka.local' autoFocus />
|
||||
</Form.Item>
|
||||
<Button type='primary' htmlType='submit' loading={submitting} block>
|
||||
Войти (dev)
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export function DevLogin(props: Props) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
{/* Литерал process.env.NODE_ENV rsbuild подставляет при сборке: в production форма вырезается из бандла целиком */}
|
||||
{process.env.NODE_ENV !== 'production' ? (
|
||||
<DevLoginForm {...props} />
|
||||
) : (
|
||||
// TODO(OQ): параметры JWT РАИСА (издатель, claim с email) — вход в проде выполняется внешним SSO
|
||||
<p>Вход выполняется через РАИСА</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
web/src/auth/MeContext.ts
Normal file
13
web/src/auth/MeContext.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { Me } from '../api/types';
|
||||
|
||||
export const MeContext = createContext<Me | null>(null);
|
||||
|
||||
/** Текущий пользователь; вызывать только внутри `AuthGate` (вне него профиля нет — это ошибка программиста). */
|
||||
export function useCurrentUser(): Me {
|
||||
const me = useContext(MeContext);
|
||||
if (!me) {
|
||||
throw new Error('useCurrentUser вызван вне AuthGate');
|
||||
}
|
||||
return me;
|
||||
}
|
||||
32
web/src/auth/token.ts
Normal file
32
web/src/auth/token.ts
Normal file
@ -0,0 +1,32 @@
|
||||
// Токен доступа живёт в sessionStorage: пропадает при закрытии вкладки и не доступен другим вкладкам (design D-9).
|
||||
// Любой доступ к хранилищу обёрнут в try/catch: в приватном режиме или при заблокированных данных сайта оно может
|
||||
// бросать исключение — тогда токен хранится только в памяти страницы, а приложение продолжает работать.
|
||||
const KEY = 'armka.token';
|
||||
|
||||
let memoryToken: string | null = null;
|
||||
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(KEY) ?? memoryToken;
|
||||
} catch {
|
||||
return memoryToken;
|
||||
}
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
memoryToken = token;
|
||||
try {
|
||||
sessionStorage.setItem(KEY, token);
|
||||
} catch {
|
||||
// хранилище недоступно: остаётся токен в памяти
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
memoryToken = null;
|
||||
try {
|
||||
sessionStorage.removeItem(KEY);
|
||||
} catch {
|
||||
// хранилище недоступно: токен в памяти уже сброшен
|
||||
}
|
||||
}
|
||||
64
web/src/auth/useMe.ts
Normal file
64
web/src/auth/useMe.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { UNAUTHORIZED_EVENT, api } from '../api/client';
|
||||
import type { Me } from '../api/types';
|
||||
import { getToken } from './token';
|
||||
|
||||
type MeState = { me: Me | null; loading: boolean; error: string | null; reload: () => void };
|
||||
|
||||
/**
|
||||
* Профиль текущего пользователя (`GET /me`). Запрос уходит только при наличии токена; ответ после размонтирования
|
||||
* или после повторного `reload` (устаревший) отбрасывается. На событие `armka:unauthorized` профиль сбрасывается.
|
||||
*/
|
||||
export function useMe(): MeState {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(() => getToken() !== null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadCount, setReloadCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// reloadCount в зависимостях: его смена перезапускает загрузку
|
||||
void reloadCount;
|
||||
if (!getToken()) {
|
||||
setMe(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.get<Me>('/me')
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setMe(data);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setMe(null);
|
||||
setError(e instanceof Error ? e.message : 'Не удалось загрузить профиль');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [reloadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const onUnauthorized = () => {
|
||||
setMe(null);
|
||||
setLoading(false);
|
||||
};
|
||||
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
||||
return () => window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
||||
}, []);
|
||||
|
||||
const reload = useCallback(() => setReloadCount((n) => n + 1), []);
|
||||
|
||||
return { me, loading, error, reload };
|
||||
}
|
||||
@ -1,5 +1,9 @@
|
||||
import { useCurrentUser } from '../auth/MeContext';
|
||||
|
||||
// Каркас: стартовый экран. Содержимое — по docs/context/10-user-journey.md.
|
||||
export function Main() {
|
||||
const me = useCurrentUser();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@ -10,6 +14,8 @@ export function Main() {
|
||||
height: 'calc(100% - 20px)',
|
||||
}}>
|
||||
<h3>АРМ КА</h3>
|
||||
<p>{me.full_name}</p>
|
||||
<p>Роли: {me.roles.length > 0 ? me.roles.join(', ') : 'нет'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user