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:
2026-09-22 15:07:15 +03:00
co-authored by Claude Sonnet 5
commit 2664cb8213
71 changed files with 4999 additions and 0 deletions
+12
View File
@@ -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
+50
View File
@@ -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
View File
@@ -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*
+121
View File
@@ -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.
+101
View File
@@ -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`.
+34
View File
@@ -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"]
+42
View File
@@ -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
+70
View File
@@ -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())
+26
View File
@@ -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)
View File
View File
+87
View File
@@ -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)]
View File
+92
View File
@@ -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)
+69
View File
@@ -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,
)
+10
View File
@@ -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)
+133
View File
@@ -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)
+72
View File
@@ -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())
View File
+96
View File
@@ -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()
+59
View File
@@ -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:]
+53
View File
@@ -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)
+97
View File
@@ -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)
View File
+45
View File
@@ -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,
)
+18
View File
@@ -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",
]
+58
View File
@@ -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"),
)
+74
View File
@@ -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)
+47
View File
@@ -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
+104
View File
@@ -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()
View File
+44
View File
@@ -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)
View File
+72
View File
@@ -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
+166
View File
@@ -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))
)
+56
View File
@@ -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
View File
+18
View File
@@ -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", "")
+57
View File
@@ -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}"
+124
View File
@@ -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
+108
View File
@@ -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:
+30
View File
@@ -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";
}
}
+24
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+24
View File
@@ -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
+32
View File
@@ -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.
+13
View File
@@ -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>
+1434
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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

+34
View File
@@ -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 },
})
}
+125
View File
@@ -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
}
+83
View File
@@ -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)
}
+32
View File
@@ -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 }[]
}
+28
View File
@@ -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>
)
}
+19
View File
@@ -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>
}
+111
View File
@@ -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;
}
+97
View File
@@ -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 />
}
+11
View File
@@ -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
}
+98
View File
@@ -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);
}
}
+16
View File
@@ -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>,
)
+71
View File
@@ -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;
}
+41
View File
@@ -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>
)
}
+31
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+27
View File
@@ -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,
},
},
},
})