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 РАИСА).
|
- `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`).
|
- Ответ 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` и роли.
|
- [x] **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`.
|
- [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`.
|
||||||
- [ ] **Step 3: Commit** `feat(web): API-клиент с Bearer, dev-вход по email, загрузка /me`.
|
- [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`),
|
`roles.py` (`GET roles`, `GET permissions`, `PUT roles/{code}/permissions` с `LAST_ADMIN` и защитой `is_system`),
|
||||||
аудит `USER_CHANGE` / `USER_ROLE_CHANGE` / `ROLE_PERMISSION_CHANGE`. Тесты: сценарии спеки `users-admin` (создание с ролью →
|
аудит `USER_CHANGE` / `USER_ROLE_CHANGE` / `ROLE_PERMISSION_CHANGE`. Тесты: сценарии спеки `users-admin` (создание с ролью →
|
||||||
`/me`, email в другом регистре, замена набора ролей, последний администратор, матрица → `/me` видит новое право).
|
`/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`
|
`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 !==
|
(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.
|
'production'`), `web/src/auth/useMe.ts` (загрузка `/me`, права). Проверка: `make web-check`; ручной сценарий в README.
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import './App.css';
|
|||||||
import { Layout } from 'antd';
|
import { Layout } from 'antd';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import { AuthGate } from './auth/AuthGate';
|
||||||
import { Config } from './conf/config';
|
import { Config } from './conf/config';
|
||||||
import { Main } from './layouts/Main';
|
import { Main } from './layouts/Main';
|
||||||
import { store } from './store/store';
|
import { store } from './store/store';
|
||||||
@ -12,14 +13,16 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<BrowserRouter basename={Config.APP_ADDRESS}>
|
<BrowserRouter basename={Config.APP_ADDRESS}>
|
||||||
<Layout hasSider={true} style={{ height: '100vh' }}>
|
<AuthGate>
|
||||||
<Sidebar />
|
<Layout hasSider={true} style={{ height: '100vh' }}>
|
||||||
<Layout.Content>
|
<Sidebar />
|
||||||
<Routes>
|
<Layout.Content>
|
||||||
<Route path='/' element={<Main />} />
|
<Routes>
|
||||||
</Routes>
|
<Route path='/' element={<Main />} />
|
||||||
</Layout.Content>
|
</Routes>
|
||||||
</Layout>
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</AuthGate>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</Provider>
|
</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.
|
// Каркас: стартовый экран. Содержимое — по docs/context/10-user-journey.md.
|
||||||
export function Main() {
|
export function Main() {
|
||||||
|
const me = useCurrentUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -10,6 +14,8 @@ export function Main() {
|
|||||||
height: 'calc(100% - 20px)',
|
height: 'calc(100% - 20px)',
|
||||||
}}>
|
}}>
|
||||||
<h3>АРМ КА</h3>
|
<h3>АРМ КА</h3>
|
||||||
|
<p>{me.full_name}</p>
|
||||||
|
<p>Роли: {me.roles.length > 0 ? me.roles.join(', ') : 'нет'}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user