Initial commit: backend scaffold, auth, frontend login
- FastAPI + SQLAlchemy async + Alembic + Postgres backend - Auth: JWT access + rotating refresh tokens, argon2, roles, audit log - React 19 + Vite frontend: login page, protected route, auth context - Docker Compose: postgres, redis, migrate, api, worker (placeholder), frontend/nginx Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.vite
|
||||||
|
frontend/.env*
|
||||||
|
backend/.venv
|
||||||
|
backend/__pycache__
|
||||||
|
backend/**/__pycache__
|
||||||
|
backend/.pytest_cache
|
||||||
|
backend/.ruff_cache
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# lux_fiscal — пример конфигурации. Скопируйте в .env и заполните.
|
||||||
|
# cp .env.example .env
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# --- Общее --------------------------------------------------------------
|
||||||
|
ENVIRONMENT=local # local | staging | production
|
||||||
|
DEBUG=true
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
PROJECT_NAME=lux_fiscal
|
||||||
|
|
||||||
|
# Базовый публичный URL приложения. Из него строится callback_url для Checkbox,
|
||||||
|
# поэтому в проде он обязан быть реальным https-адресом.
|
||||||
|
BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# Домены, которым разрешён доступ к API из браузера (через запятую).
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:8000
|
||||||
|
|
||||||
|
# --- Postgres -----------------------------------------------------------
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_DB=lux_fiscal
|
||||||
|
POSTGRES_USER=lux_fiscal
|
||||||
|
POSTGRES_PASSWORD=change-me-postgres
|
||||||
|
|
||||||
|
# --- Redis --------------------------------------------------------------
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# --- Криптография -------------------------------------------------------
|
||||||
|
# SECRET_KEY подписывает JWT. ENCRYPTION_KEY шифрует ключи Checkbox/CRM в БД.
|
||||||
|
#
|
||||||
|
# Сгенерировать оба ключа:
|
||||||
|
# python -c "import secrets; print(secrets.token_urlsafe(64))" # SECRET_KEY
|
||||||
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # ENCRYPTION_KEY
|
||||||
|
#
|
||||||
|
# ВАЖНО: при смене ENCRYPTION_KEY все сохранённые ключи касс станут
|
||||||
|
# нечитаемыми — их придётся ввести заново через админку.
|
||||||
|
SECRET_KEY=change-me-generate-a-real-secret-key
|
||||||
|
ENCRYPTION_KEY=change-me-generate-a-real-fernet-key
|
||||||
|
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# --- Первый администратор ----------------------------------------------
|
||||||
|
# Создаётся один раз командой `python -m app.cli bootstrap`.
|
||||||
|
FIRST_ADMIN_EMAIL=admin@example.com
|
||||||
|
FIRST_ADMIN_PASSWORD=change-me-admin-password
|
||||||
|
FIRST_ADMIN_NAME=Администратор
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
|
||||||
|
# Env & secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Data & artifacts
|
||||||
|
/data/
|
||||||
|
/storage/
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
settings.local.json*
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A web app for manually fiscalizing (пробивать фискальні чеки) orders from an in-house CRM. Orders come from a CRM (own API, still unbuilt — see stub pattern below), delivery status and cash-on-delivery amount come from Nova Poshta, and fiscal receipts are created via the Checkbox API. Cashiers manually trigger receipt creation from a queue of "delivered, not yet fiscalized" orders; nothing fiscal happens automatically. Single cash register today, but the schema is built for several.
|
||||||
|
|
||||||
|
The full architecture rationale (why ARQ over Celery, why CRM is behind a `Protocol`, the receipt state machine, the money-units convention, etc.) lives in the project's plan file — read it before making structural changes. If it's not present in this checkout, ask the user for it rather than re-deriving the design from scratch.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Используй краткие ответы, без лишнего кода - только ключевая информация.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/ FastAPI + SQLAlchemy (async) + Alembic + Postgres — see backend/app/
|
||||||
|
frontend/ React 19 + TypeScript + Vite — see frontend/src/
|
||||||
|
docker/ nginx.frontend.conf (SPA + /api reverse proxy to the api container)
|
||||||
|
docker-compose.yml postgres, redis, migrate (one-shot), api, worker, frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend and frontend are independent projects with their own dependency files (`backend/pyproject.toml`, `frontend/package.json`) — always `cd` into the right one before running tooling.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Backend (from `backend/`)
|
||||||
|
|
||||||
|
Uses a local venv at `backend/.venv`, not a global interpreter.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/Scripts/pip install -e ".[dev]" # install deps (first time / after pyproject.toml changes)
|
||||||
|
.venv/Scripts/python -m pytest -q # run all tests
|
||||||
|
.venv/Scripts/python -m pytest tests/test_security.py -q # single file
|
||||||
|
.venv/Scripts/python -m pytest tests/test_security.py::TestAccessToken::test_rejects_expired_token -q # single test
|
||||||
|
.venv/Scripts/python -m ruff check app tests # lint (must be clean — see rules below)
|
||||||
|
.venv/Scripts/alembic upgrade head # apply migrations (needs POSTGRES_HOST=localhost etc. in .env)
|
||||||
|
.venv/Scripts/alembic revision -m "..." # new migration — but see "Migrations" below, usually hand-written
|
||||||
|
.venv/Scripts/uvicorn app.main:app --reload # run API standalone
|
||||||
|
python -m app.cli bootstrap # create first admin from FIRST_ADMIN_* in .env (idempotent)
|
||||||
|
python -m app.cli gen-keys # print a fresh SECRET_KEY + ENCRYPTION_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests require `SECRET_KEY` and `ENCRYPTION_KEY` env vars to be set (any value works for tests; `tests/conftest.py` sets sane defaults via `os.environ.setdefault` before `app.core.config` is imported — order matters, config is read once at import time via `get_settings()`/`lru_cache`).
|
||||||
|
|
||||||
|
Tests that touch `Settings()` directly (e.g. `test_config.py`) need `secret_key` and `encryption_key` passed explicitly since `conftest.py`'s env-var defaults don't retroactively apply to already-`lru_cache`d instances.
|
||||||
|
|
||||||
|
No live Postgres is required for most tests — the migration-vs-models consistency test (`test_migration_matches_models.py`) statically diffs `alembic/versions/0001_*.py` against `Base.metadata` by regex, without a DB connection. Only actually running `alembic upgrade head` or the app against real data needs Postgres.
|
||||||
|
|
||||||
|
### Frontend (from `frontend/`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # Vite dev server on :5173, proxies /api/* to http://localhost:8000 (see vite.config.ts)
|
||||||
|
npm run build # tsc -b && vite build — treat tsc errors as build failures
|
||||||
|
npm run lint # oxlint (not eslint — see .oxlintrc.json)
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker (from repo root)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # then fill SECRET_KEY / ENCRYPTION_KEY via `python -m app.cli gen-keys`
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose run --rm api python -m app.cli bootstrap # create first admin
|
||||||
|
docker compose logs -f api # or: migrate, worker, frontend, postgres, redis
|
||||||
|
docker compose ps # migrate should show Exited(0) — it's a one-shot job, not a bug
|
||||||
|
```
|
||||||
|
|
||||||
|
`migrate` runs `alembic upgrade head` once and exits; `api`/`worker` `depends_on: migrate: condition: service_completed_successfully`. Don't add migration logic to the `api` container's startup — with multiple replicas that would race.
|
||||||
|
|
||||||
|
`worker` currently runs a placeholder `sleep` command (real ARQ worker lands at plan stage 4 — Nova Poshta). Its Dockerfile HEALTHCHECK is explicitly disabled in `docker-compose.yml` because the placeholder doesn't serve HTTP; don't be alarmed it's not "healthy", and don't re-enable the healthcheck without giving it something to check.
|
||||||
|
|
||||||
|
## Backend architecture
|
||||||
|
|
||||||
|
- **Async everywhere.** SQLAlchemy 2.0 async ORM + `asyncpg`, one DSN (`app.core.config.Settings.database_url`) used by both the app and Alembic — no separate sync driver.
|
||||||
|
- **`app/core/`** — cross-cutting, no DB/HTTP knowledge: `config.py` (Pydantic Settings, `.env`-driven), `security.py` (argon2 passwords, JWT access tokens, refresh-token hashing), `crypto.py` (Fernet — for encrypting cash-register secrets in the DB, not yet wired to a model), `logging.py` (structlog, JSON in prod / console locally).
|
||||||
|
- **`app/db/models/`** — SQLAlchemy models. Every model file must be imported in `app/db/models/__init__.py` or Alembic autogenerate silently won't see its table.
|
||||||
|
- **`app/services/`** — business logic that touches the DB (`auth.py`, `audit.py`). Routers in `app/api/v1/` stay thin and call into these.
|
||||||
|
- **`app/api/deps.py`** — `CurrentUser`, `require_admin`/`require_cashier` deps for role gating. Roles: `admin`, `cashier`, `viewer` (`UserRole(enum.StrEnum)` — do NOT use `class X(str, enum.Enum)`, ruff's `UP042` rejects it).
|
||||||
|
|
||||||
|
### Auth model (already built, stage 2 of the plan)
|
||||||
|
|
||||||
|
- Access JWT (15 min) in memory only, on the frontend. Refresh token (7 days) is a random value; only its SHA-256 hash is stored in `refresh_tokens.token_hash` — never the raw value.
|
||||||
|
- Refresh rotates on every use (`services/auth.rotate_refresh_token`): the old row gets `revoked_at` + `replaced_by_id`, a new row is inserted. **Presenting an already-revoked refresh token is treated as theft** — it revokes every active token for that user and logs `AuditAction.TOKEN_REUSE_DETECTED`. Don't "simplify" this into a plain revoke-and-reissue without the reuse check.
|
||||||
|
- `get_current_user` re-reads the user from the DB on every request (not just from JWT claims) so deactivating a user takes effect immediately instead of waiting for the access token to expire.
|
||||||
|
|
||||||
|
### Audit log
|
||||||
|
|
||||||
|
`app/services/audit.py` — every state-changing endpoint should call `audit.record(...)` in the same DB transaction as the change it's logging, not after `commit()`. It auto-redacts a fixed set of key names (`password`, `*token`, `*secret`, `license_key`, `api_key`) — if you add a new secret-shaped field, add its key to `_REDACTED_KEYS` rather than trusting callers to remember.
|
||||||
|
|
||||||
|
### Config gotchas (both bit us for real, in production-shaped values — not hypothetical)
|
||||||
|
|
||||||
|
- **`CORS_ORIGINS`** is stored as a raw string (`cors_origins_raw`) and split in a `@property`, not as `list[str]` on the Settings field directly — pydantic-settings tries to JSON-parse complex-typed env vars before validators run, so a plain `a,b` string blows up at startup. Follow this pattern for any future comma-separated env var.
|
||||||
|
- **DSN building must percent-encode user/password** (`urllib.parse.quote(..., safe="")` in `Settings.database_url`) — `PostgresDsn.build` does not escape special characters itself, and a password containing `,`, `@`, `:`, or `/` silently corrupts the parsed port/host.
|
||||||
|
- **Alembic's `env.py` builds the async engine directly from `settings.database_url`** (`create_async_engine(...)`) instead of round-tripping through `config.set_main_option("sqlalchemy.url", ...)` / `alembic.ini`. `ConfigParser` treats `%` as an interpolation character, and a percent-encoded password (e.g. `%2C` for a comma) breaks `config.set_main_option` before migrations even start. Don't reintroduce the `alembic.ini`-based URL path.
|
||||||
|
- Docker `HEALTHCHECK`s must target `127.0.0.1`, not `localhost` — Alpine's `wget`/musl resolves `localhost` to `::1` first and does not fall back to IPv4 on connection refused (unlike `curl`, which tries all resolved addresses). A container can be fully working and still show `unhealthy` if this is wrong.
|
||||||
|
|
||||||
|
### Migrations
|
||||||
|
|
||||||
|
Hand-written, not autogenerated blindly — `alembic/versions/0001_users_and_audit.py` is deliberately explicit about column nullability, index names, and `ON DELETE` behavior, using `op.f(...)` with the naming convention defined in `app/db/base.py` (`NAMING_CONVENTION`) so constraint names are deterministic and droppable in `downgrade()`. Keep that pattern for new migrations. `tests/test_migration_matches_models.py` will fail if a migration and the models it's supposed to create drift — run it after writing a migration.
|
||||||
|
|
||||||
|
### Money and quantity units
|
||||||
|
|
||||||
|
Not yet enforced by types anywhere in the current code, but is a hard project convention for when order/receipt models land (plan stages 3+): **all money amounts are integer kopecks, all quantities are integer thousandths** (Checkbox API convention: `1 pcs = 1000`, `2.25 kg = 2250`). Never introduce `float`/`Decimal`-as-currency in new models — use `bigint`.
|
||||||
|
|
||||||
|
## Frontend architecture
|
||||||
|
|
||||||
|
- **Path alias `@/`** → `frontend/src/` (configured in both `tsconfig.app.json` `paths` and `vite.config.ts` `resolve.alias`). `tsconfig.app.json` needs `"ignoreDeprecations": "6.0"` alongside `baseUrl` — this TS version deprecates bare `baseUrl` otherwise.
|
||||||
|
- **`verbatimModuleSyntax: true`** — always use `import type { Foo }` for type-only imports, or the build fails.
|
||||||
|
- **`erasableSyntaxOnly: true`** — no TS `enum`. Use string-literal union types (see `api/types.ts` `UserRole`) or `StrEnum`-equivalents. This mirrors the backend's `UserRole` values by hand; there's no generated client yet, so keep `api/types.ts` in sync with `backend/app/schemas/*.py` manually.
|
||||||
|
- **No CORS anywhere, by design.** The frontend always calls relative `/api/v1/...` paths. In dev, Vite's `server.proxy` forwards `/api` to `http://localhost:8000`. In Docker/prod, `docker/nginx.frontend.conf` proxies `/api/` to `http://api:8000/api/` inside the compose network. If you ever need to call the API from a different origin, that's a sign something about this setup broke — don't just add CORS headers as a patch.
|
||||||
|
- **Token handling is split from React on purpose:** `api/tokenStore.ts` is a plain module-level singleton (not a React context) holding the in-memory access token and localStorage-backed refresh token, with a pub-sub `subscribeToTokens`. `api/client.ts` reads/writes it directly to do silent-refresh-and-retry on a 401 without importing React or `AuthContext` — importing `AuthContext` from `client.ts` would create an import cycle (`client → auth-context → client`). `features/auth/AuthContext.tsx` subscribes to the store for React state. Keep this separation when extending auth.
|
||||||
|
- **`oxlint`, not `eslint`.** Rule names and disable-comment syntax differ (`// oxlint-disable-next-line react/only-export-components`, not `eslint-disable-next-line react-refresh/only-export-components`). Config is `.oxlintrc.json`.
|
||||||
|
- Routing: `react-router-dom`. `features/auth/ProtectedRoute.tsx` gates authenticated-only routes via `<Outlet />`; `app/routes.tsx` is the single place routes are declared.
|
||||||
|
- Data fetching: `@tanstack/react-query`, provider set up in `app/App.tsx` with `refetchOnWindowFocus: false` (order/receipt data will be polled explicitly, not refetched on focus).
|
||||||
|
|
||||||
|
## Project status (see the plan for the full roadmap)
|
||||||
|
|
||||||
|
Stages 1–2 (scaffolding, auth/roles/audit log) and a minimal frontend shell (login page + protected placeholder dashboard) are done. Stage 3 (orders + a `CrmClient` `Protocol` with a fixture-backed `StubCrmClient`, since the real CRM API doesn't exist yet) is next. Don't build order/receipt/shipment features against a guessed CRM shape — the stub pattern exists specifically so this can proceed without the real API.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# lux_fiscal
|
||||||
|
|
||||||
|
Веб-приложение для ручного пробития фискальных чеков по заказам: заказы из собственной CRM, статусы и сумма послеоплаты из Новой Пошты, фискализация через [Checkbox](https://checkbox.ua).
|
||||||
|
|
||||||
|
Архитектура целиком описана в плане проекта; здесь — только то, что нужно для запуска.
|
||||||
|
|
||||||
|
## Текущее состояние
|
||||||
|
|
||||||
|
| Этап | Что входит | Статус |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Каркас: FastAPI, Postgres, Alembic, Docker, логи, health-check | ✅ готово |
|
||||||
|
| 2 | Аутентификация, роли, журнал аудита | ✅ готово |
|
||||||
|
| 3 | Заказы + заглушка CRM | ⏳ |
|
||||||
|
| 4 | Нова Пошта: статусы и сумма послеоплаты | ⏳ |
|
||||||
|
| 5 | Checkbox: смены, чеки, PDF | ⏳ |
|
||||||
|
| 6 | Очередь и редактор чека (фронтенд) | ⏳ |
|
||||||
|
| 7 | Админка | ⏳ |
|
||||||
|
| 8 | Реальный API CRM | ⏳ |
|
||||||
|
| 9 | Продакшен-обвязка | ⏳ |
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Docker и Docker Compose — основной способ запуска
|
||||||
|
- Python 3.12+ — если хочется запускать бэкенд без контейнеров
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Сгенерируйте оба ключа и впишите их в `.env` — со значениями-заглушками приложение не поднимется:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm api python -m app.cli gen-keys
|
||||||
|
```
|
||||||
|
|
||||||
|
Поднимите стек (миграции накатятся автоматически отдельным контейнером `migrate`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Создайте первого администратора из `FIRST_ADMIN_*` в `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm api python -m app.cli bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
|
API доступен на `http://localhost:8000`, интерактивная документация — на `/docs` (в продакшене отключена).
|
||||||
|
|
||||||
|
## Проверка работоспособности
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s localhost:8000/api/v1/health/ready
|
||||||
|
```
|
||||||
|
|
||||||
|
Вход и запрос своего профиля:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST localhost:8000/api/v1/auth/login -H 'Content-Type: application/json' -d '{"email":"admin@example.com","password":"<пароль>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s localhost:8000/api/v1/auth/me -H 'Authorization: Bearer <access_token>'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Разработка без Docker
|
||||||
|
|
||||||
|
Нужен доступный Postgres — схема использует `JSONB` и частичные уникальные индексы, поэтому SQLite не подойдёт.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && python -m venv .venv && .venv/Scripts/pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
Пропишите в `.env` `POSTGRES_HOST=localhost`, `REDIS_HOST=localhost` и запускайте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && .venv/Scripts/alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && .venv/Scripts/uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тесты и линтер
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && .venv/Scripts/python -m pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && .venv/Scripts/python -m ruff check app tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- `SECRET_KEY` подписывает JWT, `ENCRYPTION_KEY` шифрует ключи касс Checkbox в БД. Оба обязательны и оба должны быть настоящими.
|
||||||
|
- **Смена `ENCRYPTION_KEY` делает ранее сохранённые ключи касс нечитаемыми** — их придётся ввести заново через админку. Ключ стоит забэкапить отдельно от дампа БД.
|
||||||
|
- Postgres и Redis не публикуют порты наружу; `api` слушает только `127.0.0.1` — наружу его выставляет nginx с TLS.
|
||||||
|
- После первого входа смените пароль администратора и удалите `FIRST_ADMIN_PASSWORD` из `.env`.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Зависимости ставятся отдельным слоем от кода: правка исходников
|
||||||
|
# не должна приводить к переустановке пакетов.
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
RUN pip install --no-cache-dir "setuptools>=75" && pip install --no-cache-dir .
|
||||||
|
|
||||||
|
COPY alembic.ini ./
|
||||||
|
COPY alembic ./alembic
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
# Процесс не должен работать от root.
|
||||||
|
RUN useradd --create-home --uid 1000 appuser && chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD curl -fsS http://127.0.0.1:8000/api/v1/health || exit 1
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# URL берётся из настроек приложения в alembic/env.py — здесь оставлен пустым,
|
||||||
|
# чтобы пароль от БД не оказался в файле, который попадает в git.
|
||||||
|
sqlalchemy.url =
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Окружение Alembic (асинхронное, на том же asyncpg, что и приложение)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
# Импорт реестра моделей обязателен: без него target_metadata пуста
|
||||||
|
# и автогенерация «увидит» удаление всех таблиц.
|
||||||
|
from app.db.models import Base # noqa: F401
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# DSN идёт напрямую в create_async_engine(), а не через
|
||||||
|
# config.set_main_option()/alembic.ini: Alembic хранит sqlalchemy.url в
|
||||||
|
# ConfigParser, для которого "%" — служебный символ интерполяции. Пароль
|
||||||
|
# от Postgres после percent-encoding как раз может содержать "%XX" и ломает
|
||||||
|
# ConfigParser.set() ещё до запуска миграций.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _configure(connection: Connection) -> None:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True, # ловить смену типа колонки
|
||||||
|
compare_server_default=True,
|
||||||
|
render_as_batch=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Генерация SQL без подключения к БД (`alembic upgrade head --sql`)."""
|
||||||
|
context.configure(
|
||||||
|
url=settings.database_url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_sync(connection: Connection) -> None:
|
||||||
|
_configure(connection)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations_online() -> None:
|
||||||
|
connectable = create_async_engine(settings.database_url, poolclass=pool.NullPool)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(_run_sync)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
asyncio.run(run_migrations_online())
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
${imports if imports else ""}
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: str | None = ${repr(down_revision)}
|
||||||
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Пользователи, refresh-токены и журнал аудита
|
||||||
|
|
||||||
|
Revision ID: 0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-09-22
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0001"
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
user_role = postgresql.ENUM("admin", "cashier", "viewer", name="user_role", create_type=False)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
user_role.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("email", sa.String(length=320), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("full_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("role", user_role, nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_users")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"refresh_tokens",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("replaced_by_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column("user_agent", sa.String(length=512), nullable=True),
|
||||||
|
sa.Column("ip", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_refresh_tokens_user_id_users"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["replaced_by_id"],
|
||||||
|
["refresh_tokens.id"],
|
||||||
|
name=op.f("fk_refresh_tokens_replaced_by_id_refresh_tokens"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_refresh_tokens")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_refresh_tokens_user_id"), "refresh_tokens", ["user_id"])
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_refresh_tokens_token_hash"), "refresh_tokens", ["token_hash"], unique=True
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"audit_log",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column("actor_label", sa.String(length=320), nullable=True),
|
||||||
|
sa.Column("action", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("entity_type", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("entity_id", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("ip", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("user_agent", sa.String(length=512), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"], ["users.id"], name=op.f("fk_audit_log_user_id_users"), ondelete="SET NULL"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_log")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_audit_log_user_id"), "audit_log", ["user_id"])
|
||||||
|
op.create_index(op.f("ix_audit_log_action"), "audit_log", ["action"])
|
||||||
|
op.create_index(op.f("ix_audit_log_created_at"), "audit_log", ["created_at"])
|
||||||
|
op.create_index("ix_audit_log_entity", "audit_log", ["entity_type", "entity_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("audit_log")
|
||||||
|
op.drop_table("refresh_tokens")
|
||||||
|
op.drop_table("users")
|
||||||
|
user_role.drop(op.get_bind(), checkfirst=True)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Зависимости FastAPI: сессия БД, текущий пользователь, проверка ролей."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.security import TokenError, decode_access_token
|
||||||
|
from app.db.models.user import User, UserRole
|
||||||
|
from app.db.session import get_session
|
||||||
|
|
||||||
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
|
|
||||||
|
_UNAUTHORIZED = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Требуется аутентификация",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
session: SessionDep,
|
||||||
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
|
||||||
|
) -> User:
|
||||||
|
if credentials is None:
|
||||||
|
raise _UNAUTHORIZED
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = decode_access_token(credentials.credentials)
|
||||||
|
except TokenError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=str(exc),
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = uuid.UUID(payload["sub"])
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
raise _UNAUTHORIZED from exc
|
||||||
|
|
||||||
|
# Пользователь читается из БД на каждый запрос, а не берётся из токена:
|
||||||
|
# отключённая учётка должна терять доступ немедленно, не дожидаясь
|
||||||
|
# истечения access-токена.
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise _UNAUTHORIZED
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
def require_roles(*roles: UserRole) -> Callable[..., Coroutine[Any, Any, User]]:
|
||||||
|
"""Ограничивает эндпоинт набором ролей.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
@router.post("/", dependencies=[Depends(require_roles(UserRole.ADMIN))])
|
||||||
|
"""
|
||||||
|
allowed = set(roles)
|
||||||
|
|
||||||
|
async def _guard(user: CurrentUser) -> User:
|
||||||
|
if user.role not in allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Недостаточно прав для этого действия",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
return _guard
|
||||||
|
|
||||||
|
|
||||||
|
# Готовые зависимости под роли из плана.
|
||||||
|
require_admin = require_roles(UserRole.ADMIN)
|
||||||
|
require_cashier = require_roles(UserRole.ADMIN, UserRole.CASHIER)
|
||||||
|
require_any = require_roles(UserRole.ADMIN, UserRole.CASHIER, UserRole.VIEWER)
|
||||||
|
|
||||||
|
AdminUser = Annotated[User, Depends(require_admin)]
|
||||||
|
CashierUser = Annotated[User, Depends(require_cashier)]
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Эндпоинты аутентификации."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
|
from app.db.models.audit import AuditAction
|
||||||
|
from app.schemas.auth import (
|
||||||
|
ChangePasswordRequest,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
RefreshRequest,
|
||||||
|
TokenPair,
|
||||||
|
UserOut,
|
||||||
|
)
|
||||||
|
from app.services import audit, auth
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=LoginResponse)
|
||||||
|
async def login(payload: LoginRequest, request: Request, session: SessionDep) -> LoginResponse:
|
||||||
|
try:
|
||||||
|
user = await auth.authenticate(
|
||||||
|
session, email=payload.email, password=payload.password, request=request
|
||||||
|
)
|
||||||
|
except auth.AuthError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
pair = await auth.issue_token_pair(session, user, request=request)
|
||||||
|
await audit.record(session, action=AuditAction.LOGIN_SUCCESS, user=user, request=request)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return LoginResponse(**pair.model_dump(), user=UserOut.model_validate(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenPair)
|
||||||
|
async def refresh(payload: RefreshRequest, request: Request, session: SessionDep) -> TokenPair:
|
||||||
|
try:
|
||||||
|
_, pair = await auth.rotate_refresh_token(
|
||||||
|
session, raw_token=payload.refresh_token, request=request
|
||||||
|
)
|
||||||
|
except auth.AuthError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return pair
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def logout(
|
||||||
|
payload: RefreshRequest, request: Request, session: SessionDep, user: CurrentUser
|
||||||
|
) -> Response:
|
||||||
|
await auth.revoke_refresh_token(session, raw_token=payload.refresh_token)
|
||||||
|
await audit.record(session, action=AuditAction.LOGOUT, user=user, request=request)
|
||||||
|
await session.commit()
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
async def me(user: CurrentUser) -> UserOut:
|
||||||
|
return UserOut.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def change_password(
|
||||||
|
payload: ChangePasswordRequest, request: Request, session: SessionDep, user: CurrentUser
|
||||||
|
) -> Response:
|
||||||
|
if not verify_password(payload.current_password, user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Текущий пароль указан неверно"
|
||||||
|
)
|
||||||
|
|
||||||
|
user.password_hash = hash_password(payload.new_password)
|
||||||
|
|
||||||
|
# Смена пароля завершает все остальные сессии: если пароль меняют из-за
|
||||||
|
# подозрения на компрометацию, чужой refresh-токен обязан перестать работать.
|
||||||
|
await auth.revoke_all_for_user(session, user.id)
|
||||||
|
|
||||||
|
await audit.record(
|
||||||
|
session,
|
||||||
|
action=AuditAction.USER_UPDATED,
|
||||||
|
user=user,
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
payload={"change": "password"},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Проверки живости и готовности."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import APIRouter, Response, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.api.deps import SessionDep
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
status: Literal["ok", "degraded"]
|
||||||
|
environment: str
|
||||||
|
database: bool
|
||||||
|
redis: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health", response_model=HealthResponse)
|
||||||
|
async def health() -> HealthResponse:
|
||||||
|
"""Liveness: процесс жив. Внешние зависимости не проверяются.
|
||||||
|
|
||||||
|
Оркестратор не должен перезапускать контейнер из-за недоступного Postgres —
|
||||||
|
перезапуск приложения проблему БД не решает.
|
||||||
|
"""
|
||||||
|
return HealthResponse(
|
||||||
|
status="ok", environment=settings.environment, database=True, redis=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/ready", response_model=HealthResponse)
|
||||||
|
async def readiness(session: SessionDep, response: Response) -> HealthResponse:
|
||||||
|
"""Readiness: приложение способно обслуживать запросы."""
|
||||||
|
db_ok = True
|
||||||
|
redis_ok = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
await session.execute(text("SELECT 1"))
|
||||||
|
except Exception as exc: # noqa: BLE001 — health-check не должен падать сам
|
||||||
|
db_ok = False
|
||||||
|
log.warning("readiness_db_failed", error=str(exc))
|
||||||
|
|
||||||
|
client = aioredis.from_url(settings.redis_url)
|
||||||
|
try:
|
||||||
|
await client.ping()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
redis_ok = False
|
||||||
|
log.warning("readiness_redis_failed", error=str(exc))
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
healthy = db_ok and redis_ok
|
||||||
|
if not healthy:
|
||||||
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
|
return HealthResponse(
|
||||||
|
status="ok" if healthy else "degraded",
|
||||||
|
environment=settings.environment,
|
||||||
|
database=db_ok,
|
||||||
|
redis=redis_ok,
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Сборка роутеров версии v1."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1 import auth, health, users
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(health.router)
|
||||||
|
api_router.include_router(auth.router)
|
||||||
|
api_router.include_router(users.router)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Управление пользователями. Доступно только администратору."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep, require_admin
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.db.models.audit import AuditAction
|
||||||
|
from app.db.models.user import User, UserRole
|
||||||
|
from app.schemas.auth import UserOut
|
||||||
|
from app.services import audit, auth
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/users", tags=["users"], dependencies=[Depends(require_admin)]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
full_name: str = Field(min_length=1, max_length=255)
|
||||||
|
password: str = Field(min_length=10, max_length=128)
|
||||||
|
role: UserRole = UserRole.CASHIER
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
full_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
role: UserRole | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
password: str | None = Field(default=None, min_length=10, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[UserOut])
|
||||||
|
async def list_users(session: SessionDep) -> list[UserOut]:
|
||||||
|
users = await session.scalars(select(User).order_by(User.created_at.desc()))
|
||||||
|
return [UserOut.model_validate(user) for user in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_user(
|
||||||
|
payload: UserCreate, request: Request, session: SessionDep, actor: CurrentUser
|
||||||
|
) -> UserOut:
|
||||||
|
user = User(
|
||||||
|
email=payload.email.strip().lower(),
|
||||||
|
full_name=payload.full_name,
|
||||||
|
password_hash=hash_password(payload.password),
|
||||||
|
role=payload.role,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
|
||||||
|
await audit.record(
|
||||||
|
session,
|
||||||
|
action=AuditAction.USER_CREATED,
|
||||||
|
user=actor,
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
payload={"email": user.email, "role": user.role.value},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Пользователь с таким email уже существует",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return UserOut.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}", response_model=UserOut)
|
||||||
|
async def update_user(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
payload: UserUpdate,
|
||||||
|
request: Request,
|
||||||
|
session: SessionDep,
|
||||||
|
actor: CurrentUser,
|
||||||
|
) -> UserOut:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
|
||||||
|
|
||||||
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Администратор, снявший с себя роль или отключивший себя, потеряет доступ
|
||||||
|
# к админке — и вернуть его будет некому.
|
||||||
|
if user.id == actor.id:
|
||||||
|
if changes.get("is_active") is False:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Нельзя отключить собственную учётную запись",
|
||||||
|
)
|
||||||
|
if "role" in changes and changes["role"] != UserRole.ADMIN:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Нельзя снять с себя роль администратора",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (new_password := changes.pop("password", None)) is not None:
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
await auth.revoke_all_for_user(session, user.id)
|
||||||
|
|
||||||
|
for field, value in changes.items():
|
||||||
|
setattr(user, field, value)
|
||||||
|
|
||||||
|
# Отключение учётки обязано немедленно завершить её активные сессии.
|
||||||
|
if changes.get("is_active") is False:
|
||||||
|
await auth.revoke_all_for_user(session, user.id)
|
||||||
|
|
||||||
|
await audit.record(
|
||||||
|
session,
|
||||||
|
action=AuditAction.USER_UPDATED,
|
||||||
|
user=actor,
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
payload={
|
||||||
|
"fields": sorted(changes),
|
||||||
|
"password_changed": new_password is not None,
|
||||||
|
},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return UserOut.model_validate(user)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Служебные команды.
|
||||||
|
|
||||||
|
python -m app.cli bootstrap # создать первого администратора
|
||||||
|
python -m app.cli gen-keys # сгенерировать SECRET_KEY и ENCRYPTION_KEY
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.db.models.user import User, UserRole
|
||||||
|
from app.db.session import session_scope
|
||||||
|
|
||||||
|
|
||||||
|
async def bootstrap() -> int:
|
||||||
|
"""Создаёт первого администратора из переменных окружения.
|
||||||
|
|
||||||
|
Идемпотентна: повторный запуск ничего не меняет и пароль не сбрасывает.
|
||||||
|
"""
|
||||||
|
if not settings.first_admin_password:
|
||||||
|
print("FIRST_ADMIN_PASSWORD не задан в .env", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
email = settings.first_admin_email.strip().lower()
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
existing = await session.scalar(select(User).where(User.email == email))
|
||||||
|
if existing is not None:
|
||||||
|
print(f"Пользователь {email} уже существует — ничего не изменено.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
User(
|
||||||
|
email=email,
|
||||||
|
full_name=settings.first_admin_name,
|
||||||
|
password_hash=hash_password(settings.first_admin_password),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Администратор {email} создан.")
|
||||||
|
print("Смените пароль после первого входа и уберите FIRST_ADMIN_PASSWORD из .env.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def gen_keys() -> int:
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
print(f"SECRET_KEY={secrets.token_urlsafe(64)}")
|
||||||
|
print(f"ENCRYPTION_KEY={Fernet.generate_key().decode()}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="app.cli", description="Служебные команды lux_fiscal")
|
||||||
|
parser.add_argument("command", choices=["bootstrap", "gen-keys"])
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "bootstrap":
|
||||||
|
return asyncio.run(bootstrap())
|
||||||
|
return gen_keys()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Конфигурация приложения. Единственное место, читающее окружение."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from pydantic import Field, PostgresDsn
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
Environment = Literal["local", "staging", "production"]
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=(".env", "../.env"),
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
case_sensitive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Общее ---
|
||||||
|
project_name: str = "lux_fiscal"
|
||||||
|
environment: Environment = "local"
|
||||||
|
debug: bool = False
|
||||||
|
log_level: str = "INFO"
|
||||||
|
base_url: str = "http://localhost:8000"
|
||||||
|
|
||||||
|
# Хранится строкой, а не list[str]: для сложных типов pydantic-settings
|
||||||
|
# пытается разобрать значение как JSON до валидаторов, и привычная запись
|
||||||
|
# "a,b" из .env приводит к ошибке запуска. Разбор — в свойстве ниже.
|
||||||
|
cors_origins_raw: str = Field(default="", alias="CORS_ORIGINS")
|
||||||
|
|
||||||
|
# --- Postgres ---
|
||||||
|
postgres_host: str = "postgres"
|
||||||
|
postgres_port: int = 5432
|
||||||
|
postgres_db: str = "lux_fiscal"
|
||||||
|
postgres_user: str = "lux_fiscal"
|
||||||
|
postgres_password: str = "postgres"
|
||||||
|
|
||||||
|
# --- Redis ---
|
||||||
|
redis_host: str = "redis"
|
||||||
|
redis_port: int = 6379
|
||||||
|
redis_db: int = 0
|
||||||
|
|
||||||
|
# --- Криптография ---
|
||||||
|
secret_key: str
|
||||||
|
encryption_key: str
|
||||||
|
access_token_expire_minutes: int = 15
|
||||||
|
refresh_token_expire_days: int = 7
|
||||||
|
|
||||||
|
# --- Первый администратор (только для команды bootstrap) ---
|
||||||
|
first_admin_email: str = "admin@example.com"
|
||||||
|
first_admin_password: str = ""
|
||||||
|
first_admin_name: str = "Администратор"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origins(self) -> list[str]:
|
||||||
|
"""Список разрешённых origin'ов из строки через запятую."""
|
||||||
|
return [item.strip() for item in self.cors_origins_raw.split(",") if item.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database_url(self) -> str:
|
||||||
|
"""DSN для асинхронного драйвера (приложение и воркер).
|
||||||
|
|
||||||
|
Логин и пароль экранируются вручную: `PostgresDsn.build` не делает
|
||||||
|
percent-encoding сам, и любой спецсимвол в пароле (`,`, `@`, `:`, `/`)
|
||||||
|
ломает разбор URL — в т.ч. молча указывая неверный порт.
|
||||||
|
"""
|
||||||
|
return str(
|
||||||
|
PostgresDsn.build(
|
||||||
|
scheme="postgresql+asyncpg",
|
||||||
|
username=quote(self.postgres_user, safe=""),
|
||||||
|
password=quote(self.postgres_password, safe=""),
|
||||||
|
host=self.postgres_host,
|
||||||
|
port=self.postgres_port,
|
||||||
|
path=self.postgres_db,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def redis_url(self) -> str:
|
||||||
|
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_production(self) -> bool:
|
||||||
|
return self.environment == "production"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings() # type: ignore[call-arg]
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Симметричное шифрование секретов, хранимых в БД.
|
||||||
|
|
||||||
|
Ключи лицензий Checkbox и токены CRM попадают в таблицы. В открытом виде
|
||||||
|
они там лежать не должны: дамп базы не обязан давать возможность пробивать
|
||||||
|
чеки от лица клиента.
|
||||||
|
|
||||||
|
Используется Fernet (AES-128-CBC + HMAC-SHA256) из `cryptography`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class DecryptionError(Exception):
|
||||||
|
"""Значение не расшифровывается — как правило, сменился ENCRYPTION_KEY."""
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def _fernet() -> Fernet:
|
||||||
|
try:
|
||||||
|
return Fernet(settings.encryption_key.encode())
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"ENCRYPTION_KEY некорректен. Сгенерируйте валидный ключ: "
|
||||||
|
"python -c \"from cryptography.fernet import Fernet; "
|
||||||
|
'print(Fernet.generate_key().decode())"'
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(value: str) -> str:
|
||||||
|
return _fernet().encrypt(value.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(value: str) -> str:
|
||||||
|
try:
|
||||||
|
return _fernet().decrypt(value.encode()).decode()
|
||||||
|
except InvalidToken as exc:
|
||||||
|
raise DecryptionError(
|
||||||
|
"Не удалось расшифровать значение. Вероятная причина — ENCRYPTION_KEY "
|
||||||
|
"изменился с момента сохранения. Секрет нужно ввести заново."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def mask(value: str, visible: int = 4) -> str:
|
||||||
|
"""Маска для показа секрета в интерфейсе: `****ab12`.
|
||||||
|
|
||||||
|
Наружу секреты отдаются только в таком виде — расшифрованное значение
|
||||||
|
не покидает бэкенд.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if len(value) <= visible:
|
||||||
|
return "*" * len(value)
|
||||||
|
return "*" * (len(value) - visible) + value[-visible:]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Структурное логирование.
|
||||||
|
|
||||||
|
Локально — человекочитаемый вывод с цветом, в проде — JSON: логи с VPS
|
||||||
|
уходят в агрегатор, и парсить их глазами никто не будет.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||||
|
)
|
||||||
|
|
||||||
|
shared_processors: list[structlog.typing.Processor] = [
|
||||||
|
structlog.contextvars.merge_contextvars,
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.stdlib.add_logger_name,
|
||||||
|
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.processors.format_exc_info,
|
||||||
|
]
|
||||||
|
|
||||||
|
renderer: structlog.typing.Processor = (
|
||||||
|
structlog.processors.JSONRenderer()
|
||||||
|
if settings.is_production
|
||||||
|
else structlog.dev.ConsoleRenderer(colors=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
structlog.configure(
|
||||||
|
processors=[*shared_processors, renderer],
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Uvicorn дублирует каждый запрос своим access-логом — глушим, свой
|
||||||
|
# middleware пишет то же самое в структурном виде.
|
||||||
|
logging.getLogger("uvicorn.access").handlers.clear()
|
||||||
|
logging.getLogger("uvicorn.access").propagate = False
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str = "lux_fiscal") -> structlog.stdlib.BoundLogger:
|
||||||
|
return structlog.get_logger(name)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Пароли, JWT и refresh-токены."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
TokenType = Literal["access", "refresh"]
|
||||||
|
|
||||||
|
_hasher = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
class TokenError(Exception):
|
||||||
|
"""Токен отсутствует, просрочен, повреждён или имеет неверный тип."""
|
||||||
|
|
||||||
|
|
||||||
|
# --- Пароли -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _hasher.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
_hasher.verify(password_hash, password)
|
||||||
|
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def password_needs_rehash(password_hash: str) -> bool:
|
||||||
|
"""True, если хеш создан старыми параметрами argon2 и его стоит обновить."""
|
||||||
|
try:
|
||||||
|
return _hasher.check_needs_rehash(password_hash)
|
||||||
|
except InvalidHashError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Access-токены ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user_id: uuid.UUID, role: str) -> str:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"role": role,
|
||||||
|
"type": "access",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
|
||||||
|
"jti": secrets.token_urlsafe(16),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
|
||||||
|
except jwt.ExpiredSignatureError as exc:
|
||||||
|
raise TokenError("Срок действия токена истёк") from exc
|
||||||
|
except jwt.InvalidTokenError as exc:
|
||||||
|
raise TokenError("Некорректный токен") from exc
|
||||||
|
|
||||||
|
# Без этой проверки refresh-токен можно было бы предъявить как access.
|
||||||
|
if payload.get("type") != "access":
|
||||||
|
raise TokenError("Ожидался access-токен")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
# --- Refresh-токены ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def generate_refresh_token() -> tuple[str, str]:
|
||||||
|
"""Возвращает (сырой токен для клиента, sha256-хеш для хранения в БД).
|
||||||
|
|
||||||
|
Сырое значение не сохраняется нигде: в БД лежит только хеш.
|
||||||
|
"""
|
||||||
|
raw = secrets.token_urlsafe(48)
|
||||||
|
return raw, hash_refresh_token(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_refresh_token(raw: str) -> str:
|
||||||
|
return hashlib.sha256(raw.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_token_expiry() -> datetime:
|
||||||
|
return datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Базовый класс моделей и общие типы колонок."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, MetaData, func
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
# Явные шаблоны имён — без них Alembic генерирует безымянные constraint'ы,
|
||||||
|
# которые потом невозможно удалить в миграции отката.
|
||||||
|
NAMING_CONVENTION = {
|
||||||
|
"ix": "ix_%(column_0_label)s",
|
||||||
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||||
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||||
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||||
|
"pk": "pk_%(table_name)s",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDPrimaryKeyMixin:
|
||||||
|
"""PK-идентификатор, генерируемый приложением.
|
||||||
|
|
||||||
|
Генерируем на стороне Python, а не в БД: для чеков UUID должен быть известен
|
||||||
|
ДО обращения к Checkbox — он служит ключом идемпотентности.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Реестр моделей.
|
||||||
|
|
||||||
|
Alembic автогенерирует миграции по `Base.metadata`, поэтому каждая модель
|
||||||
|
обязана быть импортирована здесь — иначе её таблица молча не попадёт в миграцию.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.db.models.audit import AuditAction, AuditLog
|
||||||
|
from app.db.models.user import RefreshToken, User, UserRole
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuditAction",
|
||||||
|
"AuditLog",
|
||||||
|
"Base",
|
||||||
|
"RefreshToken",
|
||||||
|
"User",
|
||||||
|
"UserRole",
|
||||||
|
]
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Журнал аудита.
|
||||||
|
|
||||||
|
Для фискального приложения это не опциональная возможность: по каждому чеку
|
||||||
|
должно быть видно, кто и когда его инициировал. Записи только добавляются —
|
||||||
|
API на изменение и удаление не существует.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class AuditAction(str):
|
||||||
|
"""Значения действий. Намеренно обычные строки, а не Enum.
|
||||||
|
|
||||||
|
Добавление нового типа события не должно требовать миграции БД.
|
||||||
|
"""
|
||||||
|
|
||||||
|
LOGIN_SUCCESS = "auth.login.success"
|
||||||
|
LOGIN_FAILED = "auth.login.failed"
|
||||||
|
LOGOUT = "auth.logout"
|
||||||
|
TOKEN_REFRESH = "auth.token.refresh"
|
||||||
|
TOKEN_REUSE_DETECTED = "auth.token.reuse_detected"
|
||||||
|
USER_CREATED = "user.created"
|
||||||
|
USER_UPDATED = "user.updated"
|
||||||
|
USER_DEACTIVATED = "user.deactivated"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(UUIDPrimaryKeyMixin, Base):
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
|
# Пользователь может быть удалён, журнал — нет: ondelete=SET NULL,
|
||||||
|
# а человекочитаемый актор дублируется в actor_label.
|
||||||
|
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
actor_label: Mapped[str | None] = mapped_column(String(320))
|
||||||
|
action: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
entity_type: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
entity_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
ip: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
created_at: Mapped[Any] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# Основной запрос админки: «покажи историю по этой сущности».
|
||||||
|
Index("ix_audit_log_entity", "entity_type", "entity_id"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Пользователи, роли и refresh-токены."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(enum.StrEnum):
|
||||||
|
"""Роли фиксированы кодом: набор прав завязан на фискальную ответственность."""
|
||||||
|
|
||||||
|
ADMIN = "admin" # всё, включая админку, кассы и ключи
|
||||||
|
CASHIER = "cashier" # очередь и пробитие чеков
|
||||||
|
VIEWER = "viewer" # только чтение
|
||||||
|
|
||||||
|
|
||||||
|
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
role: Mapped[UserRole] = mapped_column(
|
||||||
|
Enum(UserRole, name="user_role", values_callable=lambda e: [i.value for i in e]),
|
||||||
|
nullable=False,
|
||||||
|
default=UserRole.CASHIER,
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
refresh_tokens: Mapped[list[RefreshToken]] = relationship(
|
||||||
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User {self.email} ({self.role.value})>"
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshToken(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
"""Выданные refresh-токены.
|
||||||
|
|
||||||
|
Храним SHA-256 хеш, а не сам токен: утечка дампа БД не должна давать
|
||||||
|
возможность войти. Ротация — выдача нового токена с проставлением
|
||||||
|
`replaced_by_id` у старого, что позволяет обнаружить повторное
|
||||||
|
использование уже израсходованного токена.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "refresh_tokens"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
|
||||||
|
)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
replaced_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("refresh_tokens.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
ip: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(back_populates="refresh_tokens")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
from datetime import UTC
|
||||||
|
|
||||||
|
return self.revoked_at is None and self.expires_at > datetime.now(UTC)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Фабрика асинхронных сессий SQLAlchemy."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=False,
|
||||||
|
pool_pre_ping=True, # VPS-соединения умеют отваливаться молча
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionFactory = async_sessionmaker(
|
||||||
|
bind=engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False, # объекты остаются пригодными после commit
|
||||||
|
autoflush=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Зависимость FastAPI. Откатывает транзакцию при любом исключении."""
|
||||||
|
async with SessionFactory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def session_scope() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Сессия для воркеров и CLI, где механизма зависимостей FastAPI нет."""
|
||||||
|
async with SessionFactory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Точка входа FastAPI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import FastAPI, Request, Response
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from app.api.v1.router import api_router
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.logging import configure_logging, get_logger
|
||||||
|
from app.db.session import engine
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
|
configure_logging()
|
||||||
|
log.info("app_starting", environment=settings.environment, version=app.version)
|
||||||
|
yield
|
||||||
|
await engine.dispose()
|
||||||
|
log.info("app_stopped")
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Присваивает каждому запросу идентификатор и пишет структурный access-лог.
|
||||||
|
|
||||||
|
request_id возвращается заголовком `X-Request-ID`: по нему оператор,
|
||||||
|
столкнувшийся с ошибкой, находится в логах за одну команду.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||||
|
) -> Response:
|
||||||
|
request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
|
||||||
|
structlog.contextvars.clear_contextvars()
|
||||||
|
structlog.contextvars.bind_contextvars(request_id=request_id)
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"request_failed",
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
duration_ms=round((time.perf_counter() - started) * 1000, 2),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
duration_ms = round((time.perf_counter() - started) * 1000, 2)
|
||||||
|
# Health-check'и опрашиваются постоянно и засоряют лог.
|
||||||
|
if not request.url.path.startswith("/api/v1/health"):
|
||||||
|
log.info(
|
||||||
|
"request",
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
status=response.status_code,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
configure_logging()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.project_name,
|
||||||
|
version="0.1.0",
|
||||||
|
description="Фискализация заказов через Checkbox",
|
||||||
|
lifespan=lifespan,
|
||||||
|
# В проде интерактивная документация закрыта: схема API — лишняя
|
||||||
|
# подсказка для того, кто ищет незащищённый эндпоинт.
|
||||||
|
docs_url=None if settings.is_production else "/docs",
|
||||||
|
redoc_url=None if settings.is_production else "/redoc",
|
||||||
|
openapi_url=None if settings.is_production else "/openapi.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(RequestContextMiddleware)
|
||||||
|
|
||||||
|
if settings.cors_origins:
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
expose_headers=["X-Request-ID"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Схемы запросов и ответов аутентификации."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
|
||||||
|
from app.db.models.user import UserRole
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPair(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int = Field(description="Время жизни access-токена в секундах")
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
email: EmailStr
|
||||||
|
full_name: str
|
||||||
|
role: UserRole
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(TokenPair):
|
||||||
|
user: UserOut
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: str = Field(min_length=1, max_length=128)
|
||||||
|
new_password: str = Field(min_length=10, max_length=128)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Запись событий в журнал аудита."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.models.audit import AuditLog
|
||||||
|
from app.db.models.user import User
|
||||||
|
|
||||||
|
# Ключи, значения которых не должны попасть в журнал даже случайно.
|
||||||
|
_REDACTED_KEYS = {
|
||||||
|
"password",
|
||||||
|
"new_password",
|
||||||
|
"current_password",
|
||||||
|
"token",
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
"secret",
|
||||||
|
"license_key",
|
||||||
|
"api_key",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Журнал аудита читают люди и он живёт годами — секретам там не место."""
|
||||||
|
return {
|
||||||
|
key: ("***" if key.lower() in _REDACTED_KEYS else value) for key, value in payload.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request | None) -> str | None:
|
||||||
|
if request is None:
|
||||||
|
return None
|
||||||
|
# За nginx реальный адрес приходит в X-Forwarded-For; первый элемент — клиент.
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else None
|
||||||
|
|
||||||
|
|
||||||
|
async def record(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
user: User | None = None,
|
||||||
|
actor_label: str | None = None,
|
||||||
|
entity_type: str | None = None,
|
||||||
|
entity_id: str | uuid.UUID | None = None,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
request: Request | None = None,
|
||||||
|
) -> AuditLog:
|
||||||
|
"""Добавляет запись в журнал.
|
||||||
|
|
||||||
|
Без commit: вызывающий код сам решает границы транзакции, чтобы событие
|
||||||
|
и изменение состояния фиксировались вместе либо не фиксировались вовсе.
|
||||||
|
"""
|
||||||
|
entry = AuditLog(
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
actor_label=actor_label or (user.email if user else None),
|
||||||
|
action=action,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=str(entity_id) if entity_id is not None else None,
|
||||||
|
payload=_sanitize(payload or {}),
|
||||||
|
ip=client_ip(request),
|
||||||
|
user_agent=(request.headers.get("user-agent") if request else None),
|
||||||
|
)
|
||||||
|
session.add(entry)
|
||||||
|
return entry
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Логика аутентификации: вход, ротация refresh-токенов, выход."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.core.security import (
|
||||||
|
create_access_token,
|
||||||
|
generate_refresh_token,
|
||||||
|
hash_password,
|
||||||
|
hash_refresh_token,
|
||||||
|
password_needs_rehash,
|
||||||
|
refresh_token_expiry,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from app.db.models.audit import AuditAction
|
||||||
|
from app.db.models.user import RefreshToken, User
|
||||||
|
from app.schemas.auth import TokenPair
|
||||||
|
from app.services import audit
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(Exception):
|
||||||
|
"""Вход или обновление токена отклонены."""
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate(
|
||||||
|
session: AsyncSession, *, email: str, password: str, request: Request | None = None
|
||||||
|
) -> User:
|
||||||
|
"""Проверяет учётные данные.
|
||||||
|
|
||||||
|
Все отказы — с одинаковым текстом: различающиеся сообщения позволили бы
|
||||||
|
перебором выяснить, какие адреса зарегистрированы.
|
||||||
|
"""
|
||||||
|
normalized = email.strip().lower()
|
||||||
|
user = await session.scalar(select(User).where(User.email == normalized))
|
||||||
|
|
||||||
|
if user is None or not verify_password(password, user.password_hash):
|
||||||
|
await audit.record(
|
||||||
|
session,
|
||||||
|
action=AuditAction.LOGIN_FAILED,
|
||||||
|
user=user,
|
||||||
|
actor_label=normalized,
|
||||||
|
payload={"reason": "bad_credentials"},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
raise AuthError("Неверный email или пароль")
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
await audit.record(
|
||||||
|
session,
|
||||||
|
action=AuditAction.LOGIN_FAILED,
|
||||||
|
user=user,
|
||||||
|
payload={"reason": "inactive"},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
raise AuthError("Учётная запись отключена")
|
||||||
|
|
||||||
|
# Параметры argon2 со временем ужесточаются — обновляем хеш на живом пароле.
|
||||||
|
if password_needs_rehash(user.password_hash):
|
||||||
|
user.password_hash = hash_password(password)
|
||||||
|
|
||||||
|
user.last_login_at = datetime.now(UTC)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def issue_token_pair(
|
||||||
|
session: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
request: Request | None = None,
|
||||||
|
replaces: RefreshToken | None = None,
|
||||||
|
) -> TokenPair:
|
||||||
|
"""Выдаёт пару токенов и сохраняет хеш refresh-токена."""
|
||||||
|
raw_refresh, token_hash = generate_refresh_token()
|
||||||
|
|
||||||
|
stored = RefreshToken(
|
||||||
|
user_id=user.id,
|
||||||
|
token_hash=token_hash,
|
||||||
|
expires_at=refresh_token_expiry(),
|
||||||
|
user_agent=(request.headers.get("user-agent") if request else None),
|
||||||
|
ip=audit.client_ip(request),
|
||||||
|
)
|
||||||
|
session.add(stored)
|
||||||
|
await session.flush() # нужен stored.id для ссылки replaced_by_id
|
||||||
|
|
||||||
|
if replaces is not None:
|
||||||
|
replaces.revoked_at = datetime.now(UTC)
|
||||||
|
replaces.replaced_by_id = stored.id
|
||||||
|
|
||||||
|
return TokenPair(
|
||||||
|
access_token=create_access_token(user.id, user.role.value),
|
||||||
|
refresh_token=raw_refresh,
|
||||||
|
expires_in=settings.access_token_expire_minutes * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def rotate_refresh_token(
|
||||||
|
session: AsyncSession, *, raw_token: str, request: Request | None = None
|
||||||
|
) -> tuple[User, TokenPair]:
|
||||||
|
"""Обменивает refresh-токен на новую пару, отзывая предъявленный.
|
||||||
|
|
||||||
|
Если предъявлен уже отозванный токен, это признак кражи: отзываем всю
|
||||||
|
цепочку сессий пользователя и требуем полноценного входа.
|
||||||
|
"""
|
||||||
|
token_hash = hash_refresh_token(raw_token)
|
||||||
|
stored = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
|
||||||
|
|
||||||
|
if stored is None:
|
||||||
|
raise AuthError("Некорректный refresh-токен")
|
||||||
|
|
||||||
|
if stored.revoked_at is not None:
|
||||||
|
await revoke_all_for_user(session, stored.user_id)
|
||||||
|
await audit.record(
|
||||||
|
session,
|
||||||
|
action=AuditAction.TOKEN_REUSE_DETECTED,
|
||||||
|
entity_type="refresh_token",
|
||||||
|
entity_id=stored.id,
|
||||||
|
payload={"user_id": str(stored.user_id)},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
log.warning("refresh_token_reuse_detected", user_id=str(stored.user_id))
|
||||||
|
raise AuthError("Сессия отозвана, требуется повторный вход")
|
||||||
|
|
||||||
|
if stored.expires_at <= datetime.now(UTC):
|
||||||
|
raise AuthError("Срок действия refresh-токена истёк")
|
||||||
|
|
||||||
|
user = await session.get(User, stored.user_id)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise AuthError("Учётная запись недоступна")
|
||||||
|
|
||||||
|
pair = await issue_token_pair(session, user, request=request, replaces=stored)
|
||||||
|
await audit.record(
|
||||||
|
session, action=AuditAction.TOKEN_REFRESH, user=user, request=request
|
||||||
|
)
|
||||||
|
return user, pair
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_refresh_token(session: AsyncSession, *, raw_token: str) -> None:
|
||||||
|
"""Выход. Отсутствующий или уже отозванный токен ошибкой не считается."""
|
||||||
|
token_hash = hash_refresh_token(raw_token)
|
||||||
|
await session.execute(
|
||||||
|
update(RefreshToken)
|
||||||
|
.where(RefreshToken.token_hash == token_hash, RefreshToken.revoked_at.is_(None))
|
||||||
|
.values(revoked_at=datetime.now(UTC))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_all_for_user(session: AsyncSession, user_id: uuid.UUID) -> None:
|
||||||
|
"""Отзывает все активные refresh-токены пользователя (выход со всех устройств)."""
|
||||||
|
await session.execute(
|
||||||
|
update(RefreshToken)
|
||||||
|
.where(RefreshToken.user_id == user_id, RefreshToken.revoked_at.is_(None))
|
||||||
|
.values(revoked_at=datetime.now(UTC))
|
||||||
|
)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
[project]
|
||||||
|
name = "lux-fiscal-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Фискализация заказов через Checkbox: CRM + Нова Пошта + Checkbox"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn[standard]>=0.32",
|
||||||
|
"pydantic>=2.9",
|
||||||
|
"pydantic-settings>=2.6",
|
||||||
|
"email-validator>=2.2",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.36",
|
||||||
|
"asyncpg>=0.30",
|
||||||
|
"alembic>=1.14",
|
||||||
|
"httpx>=0.27",
|
||||||
|
"redis>=5.2",
|
||||||
|
"arq>=0.26",
|
||||||
|
"argon2-cffi>=23.1",
|
||||||
|
"pyjwt>=2.10",
|
||||||
|
"cryptography>=43.0",
|
||||||
|
"python-multipart>=0.0.12",
|
||||||
|
"structlog>=24.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
"respx>=0.21",
|
||||||
|
"ruff>=0.7",
|
||||||
|
"mypy>=1.13",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=75"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["app*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
warn_unused_ignores = true
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Общая настройка тестов.
|
||||||
|
|
||||||
|
Переменные окружения проставляются до импорта `app.core.config`, иначе
|
||||||
|
Settings прочитает рабочий .env (или упадёт на его отсутствии).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
os.environ.setdefault("ENVIRONMENT", "local")
|
||||||
|
os.environ.setdefault("SECRET_KEY", "test-secret-key-not-for-production")
|
||||||
|
os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||||
|
os.environ.setdefault("POSTGRES_HOST", "localhost")
|
||||||
|
os.environ.setdefault("POSTGRES_PASSWORD", "test")
|
||||||
|
os.environ.setdefault("CORS_ORIGINS", "")
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Сборка DSN из настроек.
|
||||||
|
|
||||||
|
Регрессия: `PostgresDsn.build` не экранирует username/password сам —
|
||||||
|
спецсимвол в пароле (запятая, `@`, `:`, `/`) ломает разбор URL. Баг
|
||||||
|
воспроизводится только с «настоящим» паролем и незаметен на тестовых
|
||||||
|
значениях вроде `test` или `postgres`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**overrides: str) -> Settings:
|
||||||
|
defaults = {
|
||||||
|
"secret_key": "test-secret-key",
|
||||||
|
"encryption_key": "dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
|
||||||
|
"postgres_host": "postgres",
|
||||||
|
"postgres_port": 5432,
|
||||||
|
"postgres_db": "lux_fiscal",
|
||||||
|
"postgres_user": "lux_fiscal",
|
||||||
|
"postgres_password": "postgres",
|
||||||
|
}
|
||||||
|
return Settings(**{**defaults, **overrides}) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"password",
|
||||||
|
[
|
||||||
|
"Jvth,fqkjn2020", # запятая — реальный пароль из этого проекта
|
||||||
|
"p@ss:word/with#special?chars",
|
||||||
|
"простой-пароль-с-кириллицей",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_database_url_survives_special_characters_in_password(password: str) -> None:
|
||||||
|
settings = _settings(postgres_password=password)
|
||||||
|
|
||||||
|
url = settings.database_url
|
||||||
|
|
||||||
|
assert url.startswith("postgresql+asyncpg://")
|
||||||
|
# Порт обязан остаться числом 5432 — при поломанном парсинге он
|
||||||
|
# «съезжает» вместе с частью пароля или пропадает совсем.
|
||||||
|
assert "@postgres:5432/lux_fiscal" in url
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_url_roundtrips_via_sqlalchemy_make_url() -> None:
|
||||||
|
"""URL должен оставаться валидным для драйвера, а не только для pydantic."""
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
|
|
||||||
|
settings = _settings(postgres_password="Jvth,fqkjn2020")
|
||||||
|
parsed = make_url(settings.database_url)
|
||||||
|
|
||||||
|
assert parsed.password == "Jvth,fqkjn2020"
|
||||||
|
assert parsed.port == 5432
|
||||||
|
assert parsed.database == "lux_fiscal"
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Миграция 0001 обязана точно соответствовать моделям.
|
||||||
|
|
||||||
|
Расхождение между `Base.metadata` и миграцией обнаруживается только на живой
|
||||||
|
БД и обычно уже в проде. Проверка статическая: сравниваем таблицы, колонки,
|
||||||
|
nullability и индексы, которые создаёт миграция, с тем, что описано моделями.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.db.models import Base
|
||||||
|
|
||||||
|
MIGRATION = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0001_users_and_audit.py"
|
||||||
|
SOURCE = MIGRATION.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Миграция 0001 создаёт схему с нуля, поэтому в ней обязаны быть все таблицы.
|
||||||
|
MODEL_TABLES = set(Base.metadata.tables)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_block(table: str) -> str:
|
||||||
|
"""Тело вызова op.create_table для указанной таблицы.
|
||||||
|
|
||||||
|
Границей служит следующий op.create_table; для последней таблицы — конец файла.
|
||||||
|
"""
|
||||||
|
start = SOURCE.index(f'op.create_table(\n "{table}",')
|
||||||
|
next_table = SOURCE.find("op.create_table(", start + 1)
|
||||||
|
end = next_table if next_table != -1 else len(SOURCE)
|
||||||
|
return SOURCE[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_creates_every_model_table() -> None:
|
||||||
|
created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', SOURCE))
|
||||||
|
assert created == MODEL_TABLES, (
|
||||||
|
f"Миграция и модели разошлись. Только в моделях: {MODEL_TABLES - created}; "
|
||||||
|
f"только в миграции: {created - MODEL_TABLES}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_downgrade_drops_every_created_table() -> None:
|
||||||
|
downgrade = SOURCE[SOURCE.index("def downgrade()") :]
|
||||||
|
dropped = set(re.findall(r'op\.drop_table\("(\w+)"\)', downgrade))
|
||||||
|
assert dropped == MODEL_TABLES, f"downgrade не удаляет: {MODEL_TABLES - dropped}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("table_name", sorted(MODEL_TABLES))
|
||||||
|
def test_columns_match(table_name: str) -> None:
|
||||||
|
block = _migration_block(table_name)
|
||||||
|
in_migration = set(re.findall(r'sa\.Column\(\n?\s*"(\w+)"', block))
|
||||||
|
in_model = {column.name for column in Base.metadata.tables[table_name].columns}
|
||||||
|
|
||||||
|
assert in_migration == in_model, (
|
||||||
|
f"Таблица {table_name}: только в модели {in_model - in_migration}, "
|
||||||
|
f"только в миграции {in_migration - in_model}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("table_name", sorted(MODEL_TABLES))
|
||||||
|
def test_nullability_matches(table_name: str) -> None:
|
||||||
|
block = _migration_block(table_name)
|
||||||
|
model_table = Base.metadata.tables[table_name]
|
||||||
|
|
||||||
|
for column in model_table.columns:
|
||||||
|
match = re.search(rf'sa\.Column\(\n?\s*"{column.name}".*?\n', block)
|
||||||
|
assert match, f"{table_name}.{column.name} отсутствует в миграции"
|
||||||
|
|
||||||
|
migration_nullable = "nullable=True" in match.group(0)
|
||||||
|
assert migration_nullable == column.nullable, (
|
||||||
|
f"{table_name}.{column.name}: в модели nullable={column.nullable}, "
|
||||||
|
f"в миграции nullable={migration_nullable}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("table_name", sorted(MODEL_TABLES))
|
||||||
|
def test_indexes_match(table_name: str) -> None:
|
||||||
|
model_indexes = {index.name for index in Base.metadata.tables[table_name].indexes}
|
||||||
|
migration_indexes = set(re.findall(r'op\.create_index\(\s*(?:op\.f\()?"(\w+)"', SOURCE))
|
||||||
|
|
||||||
|
missing = model_indexes - migration_indexes
|
||||||
|
assert not missing, f"Таблица {table_name}: миграция не создаёт индексы {missing}"
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Тесты паролей, JWT и шифрования секретов. Без БД и без сети."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core import crypto
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import (
|
||||||
|
ALGORITHM,
|
||||||
|
TokenError,
|
||||||
|
create_access_token,
|
||||||
|
decode_access_token,
|
||||||
|
generate_refresh_token,
|
||||||
|
hash_password,
|
||||||
|
hash_refresh_token,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasswords:
|
||||||
|
def test_verifies_correct_password(self) -> None:
|
||||||
|
assert verify_password("correct horse battery", hash_password("correct horse battery"))
|
||||||
|
|
||||||
|
def test_rejects_wrong_password(self) -> None:
|
||||||
|
assert not verify_password("wrong", hash_password("correct horse battery"))
|
||||||
|
|
||||||
|
def test_hash_is_salted(self) -> None:
|
||||||
|
"""Одинаковые пароли обязаны давать разные хеши."""
|
||||||
|
assert hash_password("same") != hash_password("same")
|
||||||
|
|
||||||
|
def test_rejects_garbage_hash_without_raising(self) -> None:
|
||||||
|
"""Повреждённый хеш в БД не должен ронять вход с 500."""
|
||||||
|
assert not verify_password("anything", "not-a-valid-argon2-hash")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccessToken:
|
||||||
|
def test_roundtrip_carries_identity(self) -> None:
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
payload = decode_access_token(create_access_token(user_id, "cashier"))
|
||||||
|
assert payload["sub"] == str(user_id)
|
||||||
|
assert payload["role"] == "cashier"
|
||||||
|
|
||||||
|
def test_rejects_expired_token(self) -> None:
|
||||||
|
expired = jwt.encode(
|
||||||
|
{
|
||||||
|
"sub": str(uuid.uuid4()),
|
||||||
|
"role": "admin",
|
||||||
|
"type": "access",
|
||||||
|
"exp": datetime.now(UTC) - timedelta(minutes=1),
|
||||||
|
},
|
||||||
|
settings.secret_key,
|
||||||
|
algorithm=ALGORITHM,
|
||||||
|
)
|
||||||
|
with pytest.raises(TokenError):
|
||||||
|
decode_access_token(expired)
|
||||||
|
|
||||||
|
def test_rejects_token_signed_with_other_key(self) -> None:
|
||||||
|
forged = jwt.encode(
|
||||||
|
{
|
||||||
|
"sub": str(uuid.uuid4()),
|
||||||
|
"role": "admin",
|
||||||
|
"type": "access",
|
||||||
|
"exp": datetime.now(UTC) + timedelta(hours=1),
|
||||||
|
},
|
||||||
|
"attacker-key",
|
||||||
|
algorithm=ALGORITHM,
|
||||||
|
)
|
||||||
|
with pytest.raises(TokenError):
|
||||||
|
decode_access_token(forged)
|
||||||
|
|
||||||
|
def test_refresh_token_is_not_accepted_as_access(self) -> None:
|
||||||
|
"""Ключевая проверка: подмена типа токена не должна давать доступ."""
|
||||||
|
refresh_shaped = jwt.encode(
|
||||||
|
{
|
||||||
|
"sub": str(uuid.uuid4()),
|
||||||
|
"role": "admin",
|
||||||
|
"type": "refresh",
|
||||||
|
"exp": datetime.now(UTC) + timedelta(days=7),
|
||||||
|
},
|
||||||
|
settings.secret_key,
|
||||||
|
algorithm=ALGORITHM,
|
||||||
|
)
|
||||||
|
with pytest.raises(TokenError, match="access"):
|
||||||
|
decode_access_token(refresh_shaped)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefreshToken:
|
||||||
|
def test_tokens_are_unique(self) -> None:
|
||||||
|
assert generate_refresh_token()[0] != generate_refresh_token()[0]
|
||||||
|
|
||||||
|
def test_hash_matches_raw_value(self) -> None:
|
||||||
|
raw, stored = generate_refresh_token()
|
||||||
|
assert hash_refresh_token(raw) == stored
|
||||||
|
|
||||||
|
def test_raw_token_is_not_recoverable_from_hash(self) -> None:
|
||||||
|
raw, stored = generate_refresh_token()
|
||||||
|
assert raw not in stored
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrypto:
|
||||||
|
def test_roundtrip(self) -> None:
|
||||||
|
secret = "license-key-abc-123"
|
||||||
|
assert crypto.decrypt(crypto.encrypt(secret)) == secret
|
||||||
|
|
||||||
|
def test_ciphertext_hides_plaintext(self) -> None:
|
||||||
|
assert "license-key" not in crypto.encrypt("license-key-abc-123")
|
||||||
|
|
||||||
|
def test_tampered_ciphertext_is_rejected(self) -> None:
|
||||||
|
token = crypto.encrypt("license-key-abc-123")
|
||||||
|
tampered = token[:-4] + ("AAAA" if not token.endswith("AAAA") else "BBBB")
|
||||||
|
with pytest.raises(crypto.DecryptionError):
|
||||||
|
crypto.decrypt(tampered)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("value", "expected"),
|
||||||
|
[("", ""), ("abc", "***"), ("abcd", "****"), ("abcdefgh", "****efgh")],
|
||||||
|
)
|
||||||
|
def test_mask(self, value: str, expected: str) -> None:
|
||||||
|
assert crypto.mask(value) == expected
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
name: lux-fiscal
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
# Порт наружу не публикуется: доступ только из сети compose.
|
||||||
|
# Для локальной отладки раскомментируйте, привязав к loopback.
|
||||||
|
# ports:
|
||||||
|
# - "127.0.0.1:5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Миграции выполняются отдельным одноразовым контейнером, а не при старте api.
|
||||||
|
# Иначе при нескольких репликах api они пошли бы параллельно.
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
REDIS_HOST: redis
|
||||||
|
command: ["alembic", "upgrade", "head"]
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
REDIS_HOST: redis
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8000:8000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
volumes:
|
||||||
|
- receipt_storage:/app/storage
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
REDIS_HOST: redis
|
||||||
|
# Подключается на этапе 4 (Нова Пошта) — сейчас заглушка, чтобы структура
|
||||||
|
# compose не менялась задним числом.
|
||||||
|
command: ["python", "-c", "print('worker placeholder: см. этап 4 плана'); import time; time.sleep(3600)"]
|
||||||
|
# Заглушка ничего не слушает на 8000, а HEALTHCHECK бэкенда унаследован
|
||||||
|
# из общего образа — без отключения контейнер вечно висел бы "unhealthy".
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
volumes:
|
||||||
|
- receipt_storage:/app/storage
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5173:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
receipt_storage:
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Отдаёт собранный SPA и проксирует /api на бэкенд внутри docker-сети.
|
||||||
|
# Благодаря этому фронтенд обращается к API по тому же относительному пути
|
||||||
|
# /api/v1/..., что и в dev-режиме через vite proxy — CORS не задействуется
|
||||||
|
# нигде, кроме случаев прямого обращения к api:8000 в обход этого nginx.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA: неизвестные пути отдаём в index.html, роутинг решает React Router.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(?:css|js|svg|woff2?)$ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# Собирается с контекстом = корень репозитория (см. docker-compose.yml),
|
||||||
|
# потому что финальный слой берёт docker/nginx.frontend.conf из соседней
|
||||||
|
# директории — Docker не даёт COPY выйти за пределы build context иначе.
|
||||||
|
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Зависимости — отдельным слоем, чтобы правка исходников не переустанавливала node_modules.
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY docker/nginx.frontend.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>lux_fiscal</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1434
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.103.2",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-router-dom": "^7.18.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.7",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"oxlint": "^1.81.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
import { apiFetch } from '@/api/client'
|
||||||
|
import type { LoginResponse, TokenPair, User } from '@/api/types'
|
||||||
|
|
||||||
|
export function login(email: string, password: string): Promise<LoginResponse> {
|
||||||
|
return apiFetch<LoginResponse>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refresh(refreshToken: string): Promise<TokenPair> {
|
||||||
|
return apiFetch<TokenPair>('/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { refresh_token: refreshToken },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(refreshToken: string): Promise<void> {
|
||||||
|
return apiFetch<void>('/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { refresh_token: refreshToken },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMe(): Promise<User> {
|
||||||
|
return apiFetch<User>('/auth/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||||
|
return apiFetch<void>('/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { current_password: currentPassword, new_password: newPassword },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/api/tokenStore'
|
||||||
|
import type { ApiErrorBody, TokenPair } from '@/api/types'
|
||||||
|
|
||||||
|
const API_PREFIX = '/api/v1'
|
||||||
|
|
||||||
|
/** Эндпоинты, которые не должны получать Authorization и не должны запускать refresh. */
|
||||||
|
const PUBLIC_PATHS = new Set(['/auth/login', '/auth/refresh'])
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorMessage(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as ApiErrorBody
|
||||||
|
if (typeof body.detail === 'string') return body.detail
|
||||||
|
if (Array.isArray(body.detail)) {
|
||||||
|
return body.detail.map((item) => item.msg).join('; ')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Тело не JSON или пустое — используем сообщение по статусу ниже.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) return 'Требуется вход в систему'
|
||||||
|
if (response.status === 403) return 'Недостаточно прав для этого действия'
|
||||||
|
if (response.status >= 500) return 'Сервер временно недоступен, попробуйте позже'
|
||||||
|
return `Ошибка запроса (${response.status})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Параллельные 401 не должны порождать несколько запросов на refresh —
|
||||||
|
// все ждут один и тот же промис.
|
||||||
|
let refreshPromise: Promise<string> | null = null
|
||||||
|
|
||||||
|
async function refreshAccessToken(): Promise<string> {
|
||||||
|
if (refreshPromise) return refreshPromise
|
||||||
|
|
||||||
|
const refreshToken = getRefreshToken()
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new ApiError(401, 'Сессия истекла, войдите снова')
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_PREFIX}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
clearTokens()
|
||||||
|
throw new ApiError(response.status, await readErrorMessage(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
const pair = (await response.json()) as TokenPair
|
||||||
|
setTokens(pair.access_token, pair.refresh_token)
|
||||||
|
return pair.access_token
|
||||||
|
} finally {
|
||||||
|
refreshPromise = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiFetchOptions extends Omit<RequestInit, 'body'> {
|
||||||
|
body?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обёртка над fetch: подставляет Authorization, при первом 401 на защищённом
|
||||||
|
* эндпоинте один раз молча обновляет access-токен и повторяет запрос.
|
||||||
|
*/
|
||||||
|
export async function apiFetch<T = void>(path: string, options: ApiFetchOptions = {}): Promise<T> {
|
||||||
|
const isPublic = PUBLIC_PATHS.has(path)
|
||||||
|
const accessToken = getAccessToken()
|
||||||
|
|
||||||
|
const headers = new Headers(options.headers)
|
||||||
|
headers.set('Content-Type', 'application/json')
|
||||||
|
if (accessToken && !isPublic) {
|
||||||
|
headers.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_PREFIX}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 401 && !isPublic) {
|
||||||
|
try {
|
||||||
|
const newAccessToken = await refreshAccessToken()
|
||||||
|
const retryHeaders = new Headers(options.headers)
|
||||||
|
retryHeaders.set('Content-Type', 'application/json')
|
||||||
|
retryHeaders.set('Authorization', `Bearer ${newAccessToken}`)
|
||||||
|
|
||||||
|
const retryResponse = await fetch(`${API_PREFIX}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: retryHeaders,
|
||||||
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!retryResponse.ok) {
|
||||||
|
throw new ApiError(retryResponse.status, await readErrorMessage(retryResponse))
|
||||||
|
}
|
||||||
|
if (retryResponse.status === 204) return undefined as T
|
||||||
|
return (await retryResponse.json()) as T
|
||||||
|
} catch (err) {
|
||||||
|
clearTokens()
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(response.status, await readErrorMessage(response))
|
||||||
|
}
|
||||||
|
if (response.status === 204) return undefined as T
|
||||||
|
return (await response.json()) as T
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Хранилище токенов вне React-дерева.
|
||||||
|
*
|
||||||
|
* `client.ts` должен уметь читать access-токен и молча обновлять его по 401
|
||||||
|
* без импорта React-контекста (иначе получится цикл api -> auth-context ->
|
||||||
|
* api). Поэтому токены живут в модульном синглтоне, а `AuthProvider`
|
||||||
|
* подписывается на его изменения через `subscribe`.
|
||||||
|
*
|
||||||
|
* access-токен — только в памяти (переживает не дольше вкладки).
|
||||||
|
* refresh-токен — в localStorage, иначе при обновлении страницы
|
||||||
|
* пользователя выкидывало бы из системы каждый раз.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REFRESH_TOKEN_KEY = 'lux_fiscal.refresh_token'
|
||||||
|
|
||||||
|
interface TokenState {
|
||||||
|
accessToken: string | null
|
||||||
|
refreshToken: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: TokenState = {
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: readRefreshTokenFromStorage(),
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = (state: TokenState) => void
|
||||||
|
const listeners = new Set<Listener>()
|
||||||
|
|
||||||
|
function readRefreshTokenFromStorage(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||||
|
} catch {
|
||||||
|
// Приватный режим браузера может запрещать доступ к localStorage.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRefreshTokenToStorage(token: string | null): void {
|
||||||
|
try {
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, token)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Не критично: без localStorage просто не переживём перезагрузку страницы.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(): void {
|
||||||
|
for (const listener of listeners) listener(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessToken(): string | null {
|
||||||
|
return state.accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken(): string | null {
|
||||||
|
return state.refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTokens(accessToken: string, refreshToken: string): void {
|
||||||
|
state = { accessToken, refreshToken }
|
||||||
|
writeRefreshTokenToStorage(refreshToken)
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAccessToken(accessToken: string): void {
|
||||||
|
state = { ...state, accessToken }
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTokens(): void {
|
||||||
|
state = { accessToken: null, refreshToken: null }
|
||||||
|
writeRefreshTokenToStorage(null)
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вызывается при монтировании AuthProvider и при каждом изменении токенов. */
|
||||||
|
export function subscribeToTokens(listener: Listener): () => void {
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => listeners.delete(listener)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Типы, зеркалящие Pydantic-схемы бэкенда (backend/app/schemas/auth.py,
|
||||||
|
* backend/app/db/models/user.py). Меняются синхронно с ними вручную —
|
||||||
|
* генерация клиента из OpenAPI запланирована на более поздний этап.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// `erasableSyntaxOnly` в tsconfig запрещает TS-enum — используем union строк,
|
||||||
|
// он полностью совпадает по значениям с UserRole на бэкенде.
|
||||||
|
export type UserRole = 'admin' | 'cashier' | 'viewer'
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
full_name: string
|
||||||
|
role: UserRole
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenPair {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
token_type: string
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse extends TokenPair {
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorBody {
|
||||||
|
detail?: string | { msg: string }[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { AppRoutes } from '@/app/routes'
|
||||||
|
import { AuthProvider } from '@/features/auth/AuthContext'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
// Данные о заказах/чеках меняются постоянно и опрашиваются отдельно —
|
||||||
|
// глобальный автоповтор через фокус окна тут не нужен.
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { LoginPage } from '@/features/auth/LoginPage'
|
||||||
|
import { ProtectedRoute } from '@/features/auth/ProtectedRoute'
|
||||||
|
import { DashboardPage } from '@/pages/DashboardPage'
|
||||||
|
|
||||||
|
export function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import * as authApi from '@/api/auth'
|
||||||
|
import { ApiError } from '@/api/client'
|
||||||
|
import { clearTokens, getRefreshToken, setTokens } from '@/api/tokenStore'
|
||||||
|
import type { User } from '@/api/types'
|
||||||
|
|
||||||
|
type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
status: AuthStatus
|
||||||
|
user: User | null
|
||||||
|
/** null, пока запрос не завершится (успехом или ошибкой). */
|
||||||
|
error: string | null
|
||||||
|
login: (email: string, password: string) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// oxlint-disable-next-line react/only-export-components -- контекст и провайдер экспортируются вместе намеренно
|
||||||
|
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
// Если refresh-токена нет, статус известен сразу же, без похода в сеть —
|
||||||
|
// вычисляем его в инициализаторе, а не синхронным setState внутри эффекта.
|
||||||
|
const [status, setStatus] = useState<AuthStatus>(() =>
|
||||||
|
getRefreshToken() ? 'loading' : 'unauthenticated',
|
||||||
|
)
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// При открытии приложения пытаемся восстановить сессию по refresh-токену
|
||||||
|
// из localStorage — иначе каждая перезагрузка страницы требовала бы входа.
|
||||||
|
useEffect(() => {
|
||||||
|
const existingRefreshToken = getRefreshToken()
|
||||||
|
if (!existingRefreshToken) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const pair = await authApi.refresh(existingRefreshToken)
|
||||||
|
setTokens(pair.access_token, pair.refresh_token)
|
||||||
|
const me = await authApi.fetchMe()
|
||||||
|
if (cancelled) return
|
||||||
|
setUser(me)
|
||||||
|
setStatus('authenticated')
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return
|
||||||
|
clearTokens()
|
||||||
|
setStatus('unauthenticated')
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await authApi.login(email, password)
|
||||||
|
setTokens(result.access_token, result.refresh_token)
|
||||||
|
setUser(result.user)
|
||||||
|
setStatus('authenticated')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу'
|
||||||
|
setError(message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
const refreshToken = getRefreshToken()
|
||||||
|
if (refreshToken) {
|
||||||
|
// Best-effort: даже если сервер недоступен, локально выходим всё равно.
|
||||||
|
try {
|
||||||
|
await authApi.logout(refreshToken)
|
||||||
|
} catch {
|
||||||
|
// Токен и так будет забыт локально ниже.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearTokens()
|
||||||
|
setUser(null)
|
||||||
|
setStatus('unauthenticated')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({ status, user, error, login, logout }),
|
||||||
|
[status, user, error, login, logout],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
.login-page {
|
||||||
|
min-height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field input:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
border: 1px solid var(--color-danger-border);
|
||||||
|
color: var(--color-danger);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-submit:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-submit:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-submit .spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useId, useState } from 'react'
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { ApiError } from '@/api/client'
|
||||||
|
import '@/features/auth/LoginPage.css'
|
||||||
|
import { useAuth } from '@/features/auth/useAuth'
|
||||||
|
|
||||||
|
interface LocationState {
|
||||||
|
from?: { pathname: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { status, login } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const emailId = useId()
|
||||||
|
const passwordId = useId()
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Уже вошли — на странице входа делать нечего.
|
||||||
|
if (status === 'authenticated') {
|
||||||
|
const state = location.state as LocationState | null
|
||||||
|
const redirectTo = state?.from?.pathname ?? '/'
|
||||||
|
return <Navigate to={redirectTo} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setFormError(null)
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await login(email, password)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу'
|
||||||
|
setFormError(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-header">
|
||||||
|
<h1>lux_fiscal</h1>
|
||||||
|
<p>Вход в систему фискализации заказов</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
|
{formError && (
|
||||||
|
<div className="login-error" role="alert">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor={emailId}>Email</label>
|
||||||
|
<input
|
||||||
|
id={emailId}
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
disabled={submitting}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor={passwordId}>Пароль</label>
|
||||||
|
<input
|
||||||
|
id={passwordId}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
disabled={submitting}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="login-submit" disabled={submitting}>
|
||||||
|
{submitting && <span className="spinner" aria-hidden="true" />}
|
||||||
|
{submitting ? 'Входим…' : 'Войти'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { useAuth } from '@/features/auth/useAuth'
|
||||||
|
|
||||||
|
/** Пропускает дальше только аутентифицированных; остальных отправляет на /login. */
|
||||||
|
export function ProtectedRoute() {
|
||||||
|
const { status } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="page-center">
|
||||||
|
<span className="spinner" aria-label="Загрузка" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'unauthenticated') {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useContext } from 'react'
|
||||||
|
|
||||||
|
import { AuthContext } from '@/features/auth/AuthContext'
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useAuth должен вызываться внутри <AuthProvider>')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
* Глобальные стили и дизайн-токены.
|
||||||
|
* Внутренний рабочий инструмент, не маркетинговая страница — раскладка
|
||||||
|
* на всю ширину, без фиксированной колонки.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-bg: #f6f7f9;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-border: #e2e4e9;
|
||||||
|
--color-text: #1f2430;
|
||||||
|
--color-text-muted: #6b7280;
|
||||||
|
--color-primary: #2563eb;
|
||||||
|
--color-primary-hover: #1d4ed8;
|
||||||
|
--color-danger: #dc2626;
|
||||||
|
--color-danger-bg: #fef2f2;
|
||||||
|
--color-danger-border: #fecaca;
|
||||||
|
--shadow-card: 0 1px 2px rgba(16, 24, 40, 0.05), 0 1px 3px rgba(16, 24, 40, 0.1);
|
||||||
|
|
||||||
|
--font-sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-bg: #0f1115;
|
||||||
|
--color-surface: #171a21;
|
||||||
|
--color-border: #2a2e38;
|
||||||
|
--color-text: #e5e7eb;
|
||||||
|
--color-text-muted: #9ca3af;
|
||||||
|
--color-primary: #3b82f6;
|
||||||
|
--color-primary-hover: #60a5fa;
|
||||||
|
--color-danger: #f87171;
|
||||||
|
--color-danger-bg: rgba(248, 113, 113, 0.12);
|
||||||
|
--color-danger-border: rgba(248, 113, 113, 0.35);
|
||||||
|
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100svh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Общие утилиты, используются в нескольких экранах --- */
|
||||||
|
|
||||||
|
.page-center {
|
||||||
|
min-height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
|
import { App } from '@/app/App'
|
||||||
|
import '@/index.css'
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root')
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error('Элемент #root не найден в index.html')
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
.dashboard-shell {
|
||||||
|
min-height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-brand {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-role {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-logout {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-logout:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-placeholder {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 420px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-placeholder h2 {
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import '@/pages/DashboardPage.css'
|
||||||
|
import { useAuth } from '@/features/auth/useAuth'
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<string, string> = {
|
||||||
|
admin: 'Администратор',
|
||||||
|
cashier: 'Кассир',
|
||||||
|
viewer: 'Наблюдатель',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Временная заглушка. Очередь «получено, чек не пробит» — этап 6 плана,
|
||||||
|
* пока здесь только подтверждение, что вход и защищённый роут работают.
|
||||||
|
*/
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard-shell">
|
||||||
|
<header className="dashboard-topbar">
|
||||||
|
<span className="dashboard-brand">lux_fiscal</span>
|
||||||
|
<div className="dashboard-user">
|
||||||
|
<span>{user?.full_name}</span>
|
||||||
|
<span className="dashboard-role">{user ? (ROLE_LABEL[user.role] ?? user.role) : ''}</span>
|
||||||
|
<button type="button" className="dashboard-logout" onClick={() => void logout()}>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="dashboard-body">
|
||||||
|
<div className="dashboard-placeholder">
|
||||||
|
<h2>Очередь заказов появится здесь</h2>
|
||||||
|
<p>
|
||||||
|
Вход выполнен успешно. Экран «получено, чек не пробит» будет добавлен на следующих
|
||||||
|
этапах — после интеграций с Новой Поштой и Checkbox.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(import.meta.dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
// Бэкенд слушает на 8000 (см. docker-compose.yml). Запросы идут по
|
||||||
|
// относительному пути /api/v1/... — тот же приём работает и в проде,
|
||||||
|
// где /api проксирует nginx. Благодаря этому фронтенд нигде не хранит
|
||||||
|
// абсолютный адрес API и CORS не нужен вообще.
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user