commit 2664cb82130874e64183246042dc7b512430fade Author: Liutenko Oleksandr Date: Tue Sep 22 15:07:15 2026 +0300 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e078827 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e43a803 --- /dev/null +++ b/.env.example @@ -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=Администратор diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfec8a7 --- /dev/null +++ b/.gitignore @@ -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* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4c28541 --- /dev/null +++ b/CLAUDE.md @@ -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 ``; `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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9077f3d --- /dev/null +++ b/README.md @@ -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 ' +``` + +## Разработка без 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`. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..aca885b --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..df7c562 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..27c2f08 --- /dev/null +++ b/backend/alembic/env.py @@ -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()) diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1ec30a1 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/0001_users_and_audit.py b/backend/alembic/versions/0001_users_and_audit.py new file mode 100644 index 0000000..e886703 --- /dev/null +++ b/backend/alembic/versions/0001_users_and_audit.py @@ -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) diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..dd84e88 --- /dev/null +++ b/backend/app/api/deps.py @@ -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)] diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..f489acd --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -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) diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py new file mode 100644 index 0000000..7925952 --- /dev/null +++ b/backend/app/api/v1/health.py @@ -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, + ) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py new file mode 100644 index 0000000..a6ddb96 --- /dev/null +++ b/backend/app/api/v1/router.py @@ -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) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..6e3e4a1 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -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) diff --git a/backend/app/cli.py b/backend/app/cli.py new file mode 100644 index 0000000..6b2f222 --- /dev/null +++ b/backend/app/cli.py @@ -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()) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..890c573 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/crypto.py b/backend/app/core/crypto.py new file mode 100644 index 0000000..2eb418d --- /dev/null +++ b/backend/app/core/crypto.py @@ -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:] diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..e0c30d4 --- /dev/null +++ b/backend/app/core/logging.py @@ -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) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..075e6af --- /dev/null +++ b/backend/app/core/security.py @@ -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) diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..8f22a1c --- /dev/null +++ b/backend/app/db/base.py @@ -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, + ) diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py new file mode 100644 index 0000000..6b8b6fa --- /dev/null +++ b/backend/app/db/models/__init__.py @@ -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", +] diff --git a/backend/app/db/models/audit.py b/backend/app/db/models/audit.py new file mode 100644 index 0000000..7c03fd0 --- /dev/null +++ b/backend/app/db/models/audit.py @@ -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"), + ) diff --git a/backend/app/db/models/user.py b/backend/app/db/models/user.py new file mode 100644 index 0000000..f3f017c --- /dev/null +++ b/backend/app/db/models/user.py @@ -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"" + + +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) diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..3136eae --- /dev/null +++ b/backend/app/db/session.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..c0a13fb --- /dev/null +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..e57a056 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..79cfe98 --- /dev/null +++ b/backend/app/services/audit.py @@ -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 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..2e8cd10 --- /dev/null +++ b/backend/app/services/auth.py @@ -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)) + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..3fc2bab --- /dev/null +++ b/backend/pyproject.toml @@ -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 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..d35a56c --- /dev/null +++ b/backend/tests/conftest.py @@ -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", "") diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py new file mode 100644 index 0000000..e4dc3ca --- /dev/null +++ b/backend/tests/test_config.py @@ -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" diff --git a/backend/tests/test_migration_matches_models.py b/backend/tests/test_migration_matches_models.py new file mode 100644 index 0000000..2ca6f50 --- /dev/null +++ b/backend/tests/test_migration_matches_models.py @@ -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}" diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py new file mode 100644 index 0000000..84387f3 --- /dev/null +++ b/backend/tests/test_security.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b17a994 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/nginx.frontend.conf b/docker/nginx.frontend.conf new file mode 100644 index 0000000..1b2fc1d --- /dev/null +++ b/docker/nginx.frontend.conf @@ -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"; + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -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 }] + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..0b8a66a --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -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. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8bc8b22 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + lux_fiscal + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b5792f6 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1434 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "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" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.85.0.tgz", + "integrity": "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.85.0.tgz", + "integrity": "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.85.0.tgz", + "integrity": "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.85.0.tgz", + "integrity": "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.85.0.tgz", + "integrity": "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.85.0.tgz", + "integrity": "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.85.0.tgz", + "integrity": "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.85.0.tgz", + "integrity": "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.85.0.tgz", + "integrity": "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.85.0.tgz", + "integrity": "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.85.0.tgz", + "integrity": "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.85.0.tgz", + "integrity": "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.85.0.tgz", + "integrity": "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.85.0.tgz", + "integrity": "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.85.0.tgz", + "integrity": "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.85.0.tgz", + "integrity": "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.85.0.tgz", + "integrity": "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.85.0.tgz", + "integrity": "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.85.0.tgz", + "integrity": "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.103.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.2.tgz", + "integrity": "sha512-I8DkFXls5jXLqtm8+QpOhEmG07hIblhSZFzafg9wHsMSEvMzULy9hK17wU1T/ahfhMbtITJhbxutwwCoihkR7A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.103.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.2.tgz", + "integrity": "sha512-B+fWiYZBc+0uUD5zDZAeLw9dKj7XEsdnuu6zRZ+no6LNKpeE3P3bJ+N6HINjcIlWR6fW3vUoTneEaGa2V6ehqw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.103.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/node": { + "version": "24.13.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", + "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.85.0.tgz", + "integrity": "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.85.0", + "@oxlint/binding-android-arm64": "1.85.0", + "@oxlint/binding-darwin-arm64": "1.85.0", + "@oxlint/binding-darwin-x64": "1.85.0", + "@oxlint/binding-freebsd-x64": "1.85.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", + "@oxlint/binding-linux-arm-musleabihf": "1.85.0", + "@oxlint/binding-linux-arm64-gnu": "1.85.0", + "@oxlint/binding-linux-arm64-musl": "1.85.0", + "@oxlint/binding-linux-ppc64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-musl": "1.85.0", + "@oxlint/binding-linux-s390x-gnu": "1.85.0", + "@oxlint/binding-linux-x64-gnu": "1.85.0", + "@oxlint/binding-linux-x64-musl": "1.85.0", + "@oxlint/binding-openharmony-arm64": "1.85.0", + "@oxlint/binding-win32-arm64-msvc": "1.85.0", + "@oxlint/binding-win32-ia32-msvc": "1.85.0", + "@oxlint/binding-win32-x64-msvc": "1.85.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..ea1be77 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..359e896 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -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 { + return apiFetch('/auth/login', { + method: 'POST', + body: { email, password }, + }) +} + +export function refresh(refreshToken: string): Promise { + return apiFetch('/auth/refresh', { + method: 'POST', + body: { refresh_token: refreshToken }, + }) +} + +export function logout(refreshToken: string): Promise { + return apiFetch('/auth/logout', { + method: 'POST', + body: { refresh_token: refreshToken }, + }) +} + +export function fetchMe(): Promise { + return apiFetch('/auth/me') +} + +export function changePassword(currentPassword: string, newPassword: string): Promise { + return apiFetch('/auth/change-password', { + method: 'POST', + body: { current_password: currentPassword, new_password: newPassword }, + }) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..63d80ef --- /dev/null +++ b/frontend/src/api/client.ts @@ -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 { + 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 | null = null + +async function refreshAccessToken(): Promise { + 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 { + body?: unknown +} + +/** + * Обёртка над fetch: подставляет Authorization, при первом 401 на защищённом + * эндпоинте один раз молча обновляет access-токен и повторяет запрос. + */ +export async function apiFetch(path: string, options: ApiFetchOptions = {}): Promise { + 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 +} diff --git a/frontend/src/api/tokenStore.ts b/frontend/src/api/tokenStore.ts new file mode 100644 index 0000000..b6458e8 --- /dev/null +++ b/frontend/src/api/tokenStore.ts @@ -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() + +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) +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..6e866dc --- /dev/null +++ b/frontend/src/api/types.ts @@ -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 }[] +} diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx new file mode 100644 index 0000000..6b7f1a9 --- /dev/null +++ b/frontend/src/app/App.tsx @@ -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 ( + + + + + + + + ) +} diff --git a/frontend/src/app/routes.tsx b/frontend/src/app/routes.tsx new file mode 100644 index 0000000..a028037 --- /dev/null +++ b/frontend/src/app/routes.tsx @@ -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 ( + + } /> + + }> + } /> + + + } /> + + ) +} diff --git a/frontend/src/features/auth/AuthContext.tsx b/frontend/src/features/auth/AuthContext.tsx new file mode 100644 index 0000000..1145285 --- /dev/null +++ b/frontend/src/features/auth/AuthContext.tsx @@ -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 + logout: () => Promise +} + +// oxlint-disable-next-line react/only-export-components -- контекст и провайдер экспортируются вместе намеренно +export const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + // Если refresh-токена нет, статус известен сразу же, без похода в сеть — + // вычисляем его в инициализаторе, а не синхронным setState внутри эффекта. + const [status, setStatus] = useState(() => + getRefreshToken() ? 'loading' : 'unauthenticated', + ) + const [user, setUser] = useState(null) + const [error, setError] = useState(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( + () => ({ status, user, error, login, logout }), + [status, user, error, login, logout], + ) + + return {children} +} diff --git a/frontend/src/features/auth/LoginPage.css b/frontend/src/features/auth/LoginPage.css new file mode 100644 index 0000000..58cc62a --- /dev/null +++ b/frontend/src/features/auth/LoginPage.css @@ -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; +} diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx new file mode 100644 index 0000000..c058bec --- /dev/null +++ b/frontend/src/features/auth/LoginPage.tsx @@ -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(null) + + // Уже вошли — на странице входа делать нечего. + if (status === 'authenticated') { + const state = location.state as LocationState | null + const redirectTo = state?.from?.pathname ?? '/' + return + } + + async function handleSubmit(event: FormEvent) { + 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 ( +
+
+
+

lux_fiscal

+

Вход в систему фискализации заказов

+
+ +
+ {formError && ( +
+ {formError} +
+ )} + +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + /> +
+ + +
+
+
+ ) +} diff --git a/frontend/src/features/auth/ProtectedRoute.tsx b/frontend/src/features/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..864d94d --- /dev/null +++ b/frontend/src/features/auth/ProtectedRoute.tsx @@ -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 ( +
+ +
+ ) + } + + if (status === 'unauthenticated') { + return + } + + return +} diff --git a/frontend/src/features/auth/useAuth.ts b/frontend/src/features/auth/useAuth.ts new file mode 100644 index 0000000..f868fd9 --- /dev/null +++ b/frontend/src/features/auth/useAuth.ts @@ -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 должен вызываться внутри ') + } + return ctx +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..5a65ec6 --- /dev/null +++ b/frontend/src/index.css @@ -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); + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..dc06150 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + , +) diff --git a/frontend/src/pages/DashboardPage.css b/frontend/src/pages/DashboardPage.css new file mode 100644 index 0000000..055dfd4 --- /dev/null +++ b/frontend/src/pages/DashboardPage.css @@ -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; +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..4f77a71 --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,41 @@ +import '@/pages/DashboardPage.css' +import { useAuth } from '@/features/auth/useAuth' + +const ROLE_LABEL: Record = { + admin: 'Администратор', + cashier: 'Кассир', + viewer: 'Наблюдатель', +} + +/** + * Временная заглушка. Очередь «получено, чек не пробит» — этап 6 плана, + * пока здесь только подтверждение, что вход и защищённый роут работают. + */ +export function DashboardPage() { + const { user, logout } = useAuth() + + return ( +
+
+ lux_fiscal +
+ {user?.full_name} + {user ? (ROLE_LABEL[user.role] ?? user.role) : ''} + +
+
+ +
+
+

Очередь заказов появится здесь

+

+ Вход выполнен успешно. Экран «получено, чек не пробит» будет добавлен на следующих + этапах — после интеграций с Новой Поштой и Checkbox. +

+
+
+
+ ) +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..99f2c3e --- /dev/null +++ b/frontend/tsconfig.app.json @@ -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"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..6a36671 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,27 @@ +import path from 'node:path' + +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(import.meta.dirname, 'src'), + }, + }, + server: { + port: 5173, + proxy: { + // Бэкенд слушает на 8000 (см. docker-compose.yml). Запросы идут по + // относительному пути /api/v1/... — тот же приём работает и в проде, + // где /api проксирует nginx. Благодаря этому фронтенд нигде не хранит + // абсолютный адрес API и CORS не нужен вообще. + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +})