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