diff --git a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md index b2e77cc..599e6af 100644 --- a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md +++ b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md @@ -226,9 +226,9 @@ export const api = { get: (p: string) => apiFetch(p), post: (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`. --- diff --git a/openspec/changes/slice-1-directories/tasks.md b/openspec/changes/slice-1-directories/tasks.md index aa2b5bb..be706ab 100644 --- a/openspec/changes/slice-1-directories/tasks.md +++ b/openspec/changes/slice-1-directories/tasks.md @@ -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(path, init)`: базовый URL, Bearer из +- [x] 4.2 **Фронт: API-клиент и dev-вход.** `web/src/api/client.ts` (`apiFetch(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. diff --git a/web/src/App.tsx b/web/src/App.tsx index 9e13510..26d01c2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( - - - - - } /> - - - + + + + + + } /> + + + + ); diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..9d830f1 --- /dev/null +++ b/web/src/api/client.ts @@ -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 { + 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 { + try { + return parseErrorBody(await response.json()); + } catch { + // тело не JSON (например, ответ прокси) — показываем общий текст + return { code: undefined, message: FALLBACK_MESSAGE }; + } +} + +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + 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: (path: string) => apiFetch(path), + post: (path: string, body?: unknown) => apiFetch(path, withJsonBody('POST', body)), + patch: (path: string, body?: unknown) => apiFetch(path, withJsonBody('PATCH', body)), + put: (path: string, body?: unknown) => apiFetch(path, withJsonBody('PUT', body)), + del: (path: string) => apiFetch(path, { method: 'DELETE' }), +}; diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..421efbe --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,102 @@ +// Типы ответов API. Имена полей — как в Pydantic-схемах бэкенда (api/src/schemas/*.py), это источник истины. + +export type BusinessSegment = 'DKB' | 'DMSB' | 'DM'; + +/** Страница списка верхнего уровня (`schemas/common.py:Page`). */ +export interface Page { + 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[]; +} diff --git a/web/src/auth/AuthGate.tsx b/web/src/auth/AuthGate.tsx new file mode 100644 index 0000000..79e2faa --- /dev/null +++ b/web/src/auth/AuthGate.tsx @@ -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 ; + } + if (!me) { + return ; + } + return {children}; +} diff --git a/web/src/auth/DevLogin.tsx b/web/src/auth/DevLogin.tsx new file mode 100644 index 0000000..2dcbf8a --- /dev/null +++ b/web/src/auth/DevLogin.tsx @@ -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(null); + + const submit = async ({ email }: { email: string }) => { + setSubmitting(true); + setFailure(null); + try { + const { access_token } = await api.post('/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 ( + layout='vertical' onFinish={submit} style={{ width: 360 }}> +

Вход для разработки

+ {shownError && } + + + + + + ); +} + +export function DevLogin(props: Props) { + return ( +
+ {/* Литерал process.env.NODE_ENV rsbuild подставляет при сборке: в production форма вырезается из бандла целиком */} + {process.env.NODE_ENV !== 'production' ? ( + + ) : ( + // TODO(OQ): параметры JWT РАИСА (издатель, claim с email) — вход в проде выполняется внешним SSO +

Вход выполняется через РАИСА

+ )} +
+ ); +} diff --git a/web/src/auth/MeContext.ts b/web/src/auth/MeContext.ts new file mode 100644 index 0000000..2bddad6 --- /dev/null +++ b/web/src/auth/MeContext.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; +import type { Me } from '../api/types'; + +export const MeContext = createContext(null); + +/** Текущий пользователь; вызывать только внутри `AuthGate` (вне него профиля нет — это ошибка программиста). */ +export function useCurrentUser(): Me { + const me = useContext(MeContext); + if (!me) { + throw new Error('useCurrentUser вызван вне AuthGate'); + } + return me; +} diff --git a/web/src/auth/token.ts b/web/src/auth/token.ts new file mode 100644 index 0000000..6640890 --- /dev/null +++ b/web/src/auth/token.ts @@ -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 { + // хранилище недоступно: токен в памяти уже сброшен + } +} diff --git a/web/src/auth/useMe.ts b/web/src/auth/useMe.ts new file mode 100644 index 0000000..f413a52 --- /dev/null +++ b/web/src/auth/useMe.ts @@ -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(null); + const [loading, setLoading] = useState(() => getToken() !== null); + const [error, setError] = useState(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') + .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 }; +} diff --git a/web/src/layouts/Main.tsx b/web/src/layouts/Main.tsx index 95571bb..0ea7656 100644 --- a/web/src/layouts/Main.tsx +++ b/web/src/layouts/Main.tsx @@ -1,5 +1,9 @@ +import { useCurrentUser } from '../auth/MeContext'; + // Каркас: стартовый экран. Содержимое — по docs/context/10-user-journey.md. export function Main() { + const me = useCurrentUser(); + return (

АРМ КА

+

{me.full_name}

+

Роли: {me.roles.length > 0 ? me.roles.join(', ') : 'нет'}

); }