commit 86cc3fa541b132ff4f2d60c39103ccd2902056e9 Author: vlad Date: Tue Jul 14 17:12:28 2026 +0300 Update admin theme/layout and refresh README details. Align the project baseline with the latest admin interface styling and layout structure while documenting setup and usage updates in README. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f5f1bbc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Git / editor noise +.git +.gitignore +.cursor +.vscode +*.md + +# Dependencies & build artifacts (installed inside the image) +**/node_modules +**/dist +**/coverage +**/.turbo + +# Python API — not needed for the web image +apps/api + +# Infra / CI helpers +infra + +# Env & secrets (injected via compose or mounted at runtime) +**/.env +**/.env.* +!**/.env.example + +# Test / e2e artifacts +**/playwright-report +**/test-results diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..957ffba --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install frontend deps + run: pnpm install --frozen-lockfile=false + - name: Install backend deps + run: pip install -r apps/api/requirements-dev.txt + - name: Lint + run: pnpm lint + - name: Types + run: pnpm typecheck + - name: Mypy + working-directory: apps/api + run: python -m mypy app + - name: Frontend tests with coverage + run: pnpm --filter web test:ci + - name: Backend tests with coverage + working-directory: apps/api + run: python -m pytest --cov=app --cov-fail-under=90 + - name: Security scan (python deps) + run: pip-audit + - name: Bandit scan + run: bandit -q -r apps/api/app + - name: Security scan (node deps) + run: pnpm audit --audit-level high + - name: Gitleaks scan + run: docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest detect --source=/repo --no-git -v + - name: Install Playwright browsers + run: pnpm --filter web exec playwright install chromium + - name: E2E smoke + run: pnpm --filter web e2e diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2cad07d --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +node_modules/ +.venv/ +__pycache__/ +.pytest_cache/ +.mypy_cache/ +coverage/ +.coverage +dist/ +playwright-report/ +test-results/ +.DS_Store +.env +*.pyc +apps/web/.env +apps/api/.env +apps/api/.e2e.sqlite +apps/api/.e2e-test.sqlite +apps/api/data/secrets/install.env +apps/api/data/secrets/install.meta.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8930e4 --- /dev/null +++ b/README.md @@ -0,0 +1,426 @@ +# Compton Platform + +Monorepo-lite project that follows the `docs/TZ.md` specification for the Compton platform. + +## Stack + +- **Frontend:** React 19, TypeScript, Vite, React Router, TanStack Query, Zustand, RHF + Zod, Ant Design (admin panel) +- **Backend:** FastAPI, SQLAlchemy 2, Alembic, Pydantic v2 +- **Data/Infra:** PostgreSQL, Redis, MinIO (S3-compatible), Docker Compose +- **Quality:** Vitest, Testing Library, Playwright, pytest, coverage gates in CI + +## Repository Layout + +- `apps/web` — frontend SPA +- `apps/api` — backend API (`app/core/crypto.py` — unified crypto; `app/core/install_secrets.py` — bootstrap/lock) +- `apps/api/data/secrets/` — per-install secrets (`install.env`, gitignored) +- `apps/api/data/compton_settings.json` — runtime panel settings (superuser-editable via API) +- `packages/shared-types` — generated API types contract target +- `packages/eslint-config` — shared eslint config package +- `infra` — docker/nginx/ci helper files +- `docker-compose.dev-ports.yml` — optional override to expose DB/Redis/MinIO on host + +## Quick Start + +**Full stack in Docker** (API + DB + frontend — no local `pnpm install` required): + +```bash +# 1. Bootstrap install secrets — REQUIRED before the first docker compose up +python apps/api/scripts/bootstrap_install.py + +# 2. Build and start everything +docker compose --profile docker-web up -d --build + +# 3. Verify (Windows PowerShell: use curl.exe, not curl — it is an alias for Invoke-WebRequest) +curl.exe http://localhost:8000/api/v1/health +``` + +Open [http://localhost:5173](http://localhost:5173) — static landing (`index.html`). SPA routes (`/login`, `/admin`, `/profile`, …) are served via `app.html` fallback in Vite dev/preview. + +The `web` container runs Vite with hot-reload; dependencies are installed inside the container automatically. + +Log in as `admin@compton.example` (password `Admin1234` by default) and open `/admin`. See [Seed data](#seed-data) for all demo accounts. + +**Optional** — copy env files if you also run API or frontend locally (hybrid mode): + +```bash +cp apps/api/.env.example apps/api/.env +cp apps/web/.env.example apps/web/.env +``` + +> **Important:** Run bootstrap **before** the first `docker compose up`. If Postgres was started without `install.env`, the API will fail with `password authentication failed for user "compton_app"`. Fix: `docker compose --profile docker-web down -v`, then bootstrap + up again. See [Troubleshooting](#troubleshooting). + +**Hybrid setup** (Docker API + local frontend): + +```bash +cp apps/api/.env.example apps/api/.env +cp apps/web/.env.example apps/web/.env +pnpm install +python apps/api/scripts/bootstrap_install.py +docker compose up -d +pnpm --filter web dev +``` + +## Local Setup + +### Prerequisites + +- **Docker + Docker Compose** — enough for the full-stack Docker workflow +- **Node.js 22+ and pnpm 9+** — only if you run the frontend locally (`pnpm --filter web dev`) +- **Python 3.12** — bootstrap script, local API, or tests outside Docker + +### Start services + +| Mode | Command | What runs | +| ---- | ------- | --------- | +| **Full Docker (recommended)** | `docker compose --profile docker-web up -d --build` | API, Postgres, Redis, MinIO, Vite on `:5173` | +| **API + infra only** | `docker compose up -d` | API, Postgres, Redis, MinIO — frontend locally | +| **Local API + local frontend** | see [Run applications](#run-applications) | everything on host | + +| Service | URL / Port | Notes | +| --------- | ---------------------------------------------- | ----- | +| API | [http://localhost:8000](http://localhost:8000) | migrations + seed on startup | +| Web (dev) | [http://localhost:5173](http://localhost:5173) | Docker `--profile docker-web` or `pnpm --filter web dev` | +| Postgres | internal docker network | host access via `docker-compose.dev-ports.yml` | +| Redis | internal docker network | host access via `docker-compose.dev-ports.yml` | +| MinIO | internal docker network | host access via `docker-compose.dev-ports.yml` | + +> Do not run Docker `web` and `pnpm --filter web dev` at the same time — both bind port **5173**. + +The Docker `web` container bind-mounts source for live-reload on Windows/macOS (`CHOKIDAR_USEPOLLING=true`); `node_modules` stay isolated inside the container. + +### Install secrets + +Each project copy gets unique runtime secrets generated once and locked forever (prevents accidental rotation and DB credential mismatch). + +| File | Purpose | +| ---- | ------- | +| `apps/api/data/secrets/install.env` | PostgreSQL, JWT, S3/MinIO credentials (gitignored) | +| `apps/api/data/secrets/install.meta.json` | Install ID and lock timestamp (gitignored) | + +**Bootstrap (required before first `docker compose up`):** + +```bash +python apps/api/scripts/bootstrap_install.py +``` + +Generated keys: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `DATABASE_URL`, `JWT_ACCESS_SECRET`, `JWT_REFRESH_PEPPER`, `S3_SECRET_KEY`, `MINIO_ROOT_PASSWORD`. + +Docker Compose passes `install.env` directly into the `api`, `postgres`, and `minio` services — no root `.env` or `--env-file` flag needed. + +- Re-run is safe: existing locked secrets are never overwritten. +- API entrypoint also calls `ensure_install_secrets()` on startup (adopts env vars when migrating from an older setup). +- **Do not rotate** `POSTGRES_PASSWORD` / JWT secrets after first bootstrap without a coordinated DB migration — see [docs/secrets-recovery.md](docs/secrets-recovery.md). + +**Host access to DB/Redis/MinIO** (DBeaver, pgAdmin, MinIO console): + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev-ports.yml up -d +``` + +| Exposed port | Service | +| ------------ | ------- | +| 5432 | PostgreSQL | +| 6379 | Redis | +| 9000 / 9001 | MinIO API / console | + +**Reveal credentials:** Admin → Security → Install Secrets (superuser only, audited). Supports `database_password`, `jwt_access_secret`, `jwt_refresh_pepper`, `s3_secret_key`. + +### Configure environment + +For hybrid or fully local dev, copy example env files: + +```bash +cp apps/api/.env.example apps/api/.env +cp apps/web/.env.example apps/web/.env +``` + +Key variables: + +| Variable | App | Purpose | +| ----------------------------------------- | --- | ---------------------------------------------------- | +| `DATABASE_URL` | api | PostgreSQL connection string | +| `APP_ENV` | api | `development` / `production` startup guards | +| `JWT_ACCESS_SECRET`, `JWT_REFRESH_PEPPER` | api | Token signing (32+ bytes) | +| `VITE_API_URL` | web | Local dev: `http://localhost:8000`. Docker `web`: `http://api:8000` | +| `VITE_USE_API_PROXY` | web | `true` — proxy `/api` through Vite (default in dev) | +| `EMAIL_DELIVERY_MODE` | api | `memory` (Docker dev) or `smtp` (real mail) | +| `STORAGE_MODE` | api | `s3` (MinIO) or `memory` (tests) | +| `S3_*` | api | MinIO/S3 credentials and bucket | +| `CORS_ORIGINS` | api | Must include `http://localhost:5173` | +| `ENABLE_TEST_ROUTES` | api | `true` only for E2E (email token helper) | +| `TRUSTED_PROXY_IPS` | api | Which proxy IPs may set `X-Forwarded-For` | +| `ADMIN_INITIAL_PASSWORD` | api | Seed password for superuser admin | +| `DEMO_USER_PASSWORD` | api | Seed password for regular demo user | +| `DEMO_OPS_PASSWORD` | api | Seed password for ops admin (no superuser) | +| `ENABLE_RATE_LIMIT` | api | Rate limiting (required `true` in production) | +| `COOKIE_SECURE` | api | HttpOnly refresh cookie `Secure` flag | +| `COMPTON_SETTINGS_PATH` | api | Path to runtime settings JSON (default `data/compton_settings.json`) | + +In dev the frontend proxies API requests through Vite (`/api` → backend). Locally the target is `localhost:8000`; in Docker Compose it is the `api` service. + +### Runtime settings (`compton_settings.json`) + +Non-secret runtime toggles live in `apps/api/data/compton_settings.json` and are applied on API startup. Superusers can read/update them via `GET/PATCH /admin/settings`. + +Env vars with the same keys (e.g. `ENABLE_RATE_LIMIT`, `CORS_ORIGINS`) act as **locks** — when set, the corresponding JSON field cannot be changed from the admin panel. + +Typical fields: rate limit, API docs, secure cookie, JWT TTL, CORS origins, SMTP host/port, avatar limits, audit retention. + +### Install dependencies (hybrid / local dev only) + +Skip if you use `docker compose --profile docker-web` — dependencies are installed inside the `web` container. + +```bash +pnpm install +pip install -r apps/api/requirements-dev.txt +``` + +### Run applications + +**Option A — Full Docker stack:** + +```bash +python apps/api/scripts/bootstrap_install.py # first run only +docker compose --profile docker-web up -d --build +``` + +**Option B — Docker API + local frontend:** + +```bash +python apps/api/scripts/bootstrap_install.py # first run only +docker compose up -d +pnpm --filter web dev +``` + +**Option C — Local API + local frontend:** + +```bash +cd apps/api +alembic upgrade head +uvicorn app.main:app --reload --app-dir . +``` + +In another terminal: + +```bash +pnpm --filter web dev +``` + +Open [http://localhost:5173](http://localhost:5173). + +### Seed data + +After migrations the database is seeded on every API startup with demo users and CMS pages. Passwords come from env vars (defaults in `.env.example`): + +| Email | Env variable | Default (dev) | Role | Superuser | Access | +| ----- | ------------ | --------------- | ---- | --------- | ------ | +| `admin@compton.example` | `ADMIN_INITIAL_PASSWORD` | `Admin1234` | `admin` | yes | Full admin + Security/Diagnostics/Install Secrets | +| `ops@compton.example` | `DEMO_OPS_PASSWORD` | `OpsAdmin1234` | `admin` | no | Users, Content, Activity (no Security/Diagnostics) | +| `user@compton.example` | `DEMO_USER_PASSWORD` | `User1234` | `user` | no | Profile only | + +**Content pages:** `about`, `privacy`, `terms` (published). + +> Change demo passwords via env before first seed in production. Admin-created users must pass the shared password policy (length, complexity, denylist). + +## Frontend Routes + +The project uses a **dual-entry** frontend: + +| Entry | Served at | Purpose | +| ----- | --------- | ------- | +| `index.html` | `/` | Public marketing landing (static HTML/CSS/JS in `main/`) | +| `app.html` | `/login`, `/register`, `/profile`, `/admin`, `/pages/:slug`, … | React SPA (auth, profile, admin, CMS pages) | + +Vite middleware rewrites SPA paths to `app.html` on dev/preview (`vite.main-static.ts`). + +| Path | Page | Access | +| ------------------ | --------------------- | ----------------------- | +| `/` | Landing (`index.html`)| public | +| `/login` | Login | guest | +| `/register` | Registration | guest | +| `/verify` | Email verification | public | +| `/forgot-password` | Password reset request| guest | +| `/reset-password` | Set new password | public (with token) | +| `/pages/:slug` | CMS page | public (published only) | +| `/profile` | Profile CRUD + avatar | authenticated | +| `/admin` | Admin panel | role `admin` | + +**Auth model:** one login flow for everyone. The `is_superuser` flag on admin accounts controls access to critical panel sections (Security, Diagnostics, Install Secrets reveal, runtime settings). Regular admins see Users, Content, and Activity only. + +- **Access JWT** — in memory only (Zustand), not in `localStorage` / `sessionStorage`. +- **Refresh token** — HttpOnly cookie (`Path=/api/v1/auth`, `SameSite=Lax`), rotated on each refresh. +- **Session restore on reload** — `AuthBootstrap` calls a deduplicated `bootstrapSessionRefresh()` when a session hint exists in `sessionStorage` **or** the current path is protected (`/admin`, `/profile`). Guards wait for `bootstrapped` before redirecting. +- **After login** — admins go to `/admin`, regular users to `/profile`. + +Admin users can return to the public landing via the **«На сайт»** topbar link (full navigation to `/`, not client-side React routing). + +## API Overview + +Base URL: `http://localhost:8000/api/v1` + +| Area | Endpoints | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | +| **Health** | `GET /health` | +| **Auth** | `POST /auth/register`, `/login`, `/logout`, `/refresh`, `/verify-email`, `/forgot-password`, `/reset-password` | +| **Profile** | `GET/PATCH /users/me`, `POST /users/me/password`, `POST /users/me/avatar` | +| **Content** | `GET /content/pages`, `GET /content/pages/{slug}`, admin: `POST/PATCH/DELETE /content/pages`, `GET /content/pages/manage/all` | +| **Admin** | `GET/PATCH /admin/users`, `POST /admin/users`, `PATCH /admin/users/{id}/password`, `GET /admin/stats`, `GET/PATCH /admin/settings`, `GET /admin/diagnostics/report`, `GET /admin/activity-feed`, `POST /admin/ui-activity`, `GET /admin/server-log`, `GET /admin/secrets`, `POST /admin/secrets/reveal` | +| **Media** | `GET /media/files/{path}` | + +OpenAPI docs (when `ENABLE_DOCS=true`): [http://localhost:8000/api/v1/docs](http://localhost:8000/api/v1/docs) + +## Database Migrations + +```bash +cd apps/api +alembic upgrade head # apply schema +alembic downgrade -1 # rollback one revision +``` + +Tables: `users`, `user_profiles`, `refresh_tokens`, `password_reset_tokens`, `email_verification_tokens`, `content_pages`. + +**Security constraints** (PostgreSQL, migration `20260714_0004`): + +- `CHECK` on `users.role`, `users.status`, `content_pages.status` +- Superuser rule: `is_superuser=true` only when `role=admin` +- Indexes on `expires_at` for token tables + +Expired tokens are cleaned up on API startup (`cleanup_expired_tokens`, 30-day retention). + +## Auth Email (SMTP) + +- Verify/reset use one-time opaque tokens (SHA-256 hash + pepper in DB, TTL 1 hour); raw token omitted from production email bodies +- Env: `SMTP_HOST`, `SMTP_PORT`, `SMTP_FROM`, `FRONTEND_URL`, `EMAIL_DELIVERY_MODE` +- Docker dev: `EMAIL_DELIVERY_MODE=memory` (no real SMTP required) +- Local SMTP: Mailpit/Mailhog on `localhost:1025` (`SMTP_HOST=localhost`, `SMTP_PORT=1025`) +- Tests: `EMAIL_DELIVERY_MODE=memory` (in-memory outbox) + +## Profile & Avatar (MinIO) + +- `GET/PATCH /api/v1/users/me`, `POST /api/v1/users/me/password`, `POST /api/v1/users/me/avatar` +- Avatar: jpeg/png/webp, max 2 MB, re-encoded via Pillow (SVG rejected) +- Storage env: `STORAGE_MODE` (`s3` or `memory`), `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET` +- Local dev: MinIO on `localhost:9000` (console `9001`), bucket `compton` (created on API startup) +- Tests: `STORAGE_MODE=memory` (in-memory file store, no MinIO) + +## Admin Panel + +- Route: `/admin` (`AdminGuard`, role `admin`) +- UI: WESP-style **Ant Design** shell with fixed left sidebar, content topbar, and a dedicated admin theme system +- Topbar actions: + - **Light/Dark** — toggles isolated admin theme (preference persisted in `localStorage`) + - **«На сайт»** — opens the static landing at `/` (`index.html`) via full navigation + - **Logout** — revokes refresh cookie and redirects to `/login` +- Reload UX: no blocking «Loading…» screens; admin routes preload the current admin background (`#f6f6f4` in Light, `#1f2229` in Dark) to avoid flash +- Sections: + - **Users** — list/create/patch role, status, password + - **Content** — CMS CRUD + - **Security** *(superuser only)* — runtime toggles + Install Secrets reveal + - **Diagnostics** *(superuser only)* — security health checks (JWT, CORS, DB credentials, SSL mode, token table size, install secrets lock) + - **Activity** — admin audit feed + server log tail +- Business rules: last admin protected, no self-demotion, no self-block; critical endpoints require `is_superuser` + +### Auth UI (zootech) + +Auth pages (`/login`, `/register`, `/forgot-password`, `/reset-password`, `/verify`) use a WESP-inspired zootech card layout with organic theme tokens (`#48816d`, centered card, icon inputs). `AppHeader` is hidden on auth routes and in `/admin`. + +## Tests + +```bash +# Frontend unit/component (coverage ≥ 85%) +pnpm --filter web test:ci + +# Same, inside Docker web container (when pnpm is not installed on host) +docker compose --profile docker-web exec web sh -c "cd apps/web && pnpm test:ci" + +# Backend unit/integration (coverage ≥ 90%) +cd apps/api && python -m pytest --cov=app --cov-fail-under=90 + +# E2E regression (Playwright §15.7, 16 critical scenarios) +pnpm --filter web e2e +``` + +### E2E (Playwright) + +- `pnpm --filter web e2e` starts a dedicated API (`:8001`) and Vite (`:5175`) — does not conflict with dev on `:5173` or Docker API on `:8000` +- Uses SQLite + `EMAIL_DELIVERY_MODE=memory` + `ENABLE_TEST_ROUTES=true` +- Test email tokens: `GET /api/v1/test/emails/latest-token` (only when test routes enabled) +- Reuse running API: `E2E_START_API=false E2E_API_URL=http://localhost:8000 pnpm --filter web e2e` +- Install browsers once: `pnpm --filter web exec playwright install chromium` + +## Troubleshooting + +| Problem | Solution | +| ------- | -------- | +| **`docker compose up` fails: install.env missing** | Run `python apps/api/scripts/bootstrap_install.py` before first start | +| **API exits: `password authentication failed for user "compton_app"`** | Postgres volume was initialized before bootstrap. Reset local dev data: `docker compose --profile docker-web down -v`, bootstrap again, then `docker compose --profile docker-web up -d --build`. See [docs/secrets-recovery.md](docs/secrets-recovery.md) | +| **Login failed** with correct credentials | Check API: `curl http://localhost:8000/api/v1/health`. Ensure `apps/web/.env` exists for local frontend. Restart: `docker compose restart web` or `pnpm --filter web dev` | +| **401 on `/auth/refresh` in browser console (guest)** | Expected for logged-out users on public pages — refresh is skipped unless a session hint or protected path (`/admin`, `/profile`) triggers bootstrap | +| **Logged out after F5 on `/admin`** | Usually a failed refresh (expired cookie) or stale Docker web build. Rebuild: `docker compose --profile docker-web up -d --build web`. Log in again if the refresh cookie expired | +| **«На сайт» in admin does nothing / goes to login** | Must use full navigation to `/` (static landing), not React Router. Ensure topbar action is `href="/"`, then rebuild web container if behavior persists | +| **White flash on admin reload** | `app.html` preloads admin background before React mount. Verify `localStorage.wespAdminTheme` and rebuild web if stale assets are served | +| **Sidebar should stay visible while scrolling** | Admin sidebar is fixed on desktop (`position: fixed`, `height: 100vh`) and switches back to normal flow on mobile (`<=768px`) | +| **Port 5173 already in use** | Stop Docker web: `docker compose --profile docker-web stop web`. Or stop local Vite | +| **Frontend in Docker: missing modules / esbuild errors** | Rebuild: `docker compose --profile docker-web up -d --build web`. Do not run `pnpm install` on the host for the Docker workflow | +| **Hot-reload not working in Docker (Windows/macOS)** | Enabled via `CHOKIDAR_USEPOLLING=true`. Restart: `docker compose --profile docker-web restart web` | +| **Admin panel missing Security/Diagnostics tabs** | Log in as `admin@compton.example` (superuser), not `ops@compton.example` | +| **Need DB password for DBeaver** | Admin → Security → Install Secrets → Reveal DB password (superuser), then `docker-compose.dev-ports.yml` | +| **Lost `install.env` / DB auth failed** | See [docs/secrets-recovery.md](docs/secrets-recovery.md). Do not regenerate secrets if Postgres volume already exists | +| **CORS errors in browser console** | Keep `VITE_USE_API_PROXY=true` (default in dev). Do not call `localhost:8000` directly from the browser | + +## Security Highlights + +All cryptographic primitives live in a single module (`apps/api/app/core/crypto.py`); `security.py` and `media_signing.py` re-export from it. + +| Layer | Mechanism | +| ----- | --------- | +| **Passwords** | bcrypt cost 12 with per-user salt (embedded in hash); shared denylist | +| **Access JWT** | HS256, short TTL, in-memory on frontend only | +| **Refresh tokens** | HttpOnly cookie (`Path=/api/v1/auth`), rotation + family reuse detection; SHA-256 hash with server-side **pepper** | +| **Email/reset tokens** | Opaque tokens, SHA-256 + pepper in DB; raw token never stored | +| **Media URLs** | HMAC-SHA256 signed paths with TTL | +| **Install secrets** | `secrets.token_*` generation; generate-once + lock; superuser reveal (audited) | + +**Auth & API** + +- Refresh token rotation and reuse-detection (family revoke) +- Rate limiting and brute-force lockout (`ENABLE_RATE_LIMIT=true` in Docker compose) +- Pending/blocked users rejected on refresh; frontend clears session on `403` (`ACCOUNT_BLOCKED`, `EMAIL_NOT_VERIFIED`) +- Origin/Referer validation on cookie-based auth endpoints +- `TRUSTED_PROXY_IPS` — only listed proxies may influence client IP via `X-Forwarded-For` +- Email bodies omit raw tokens in production (`EMAIL_DELIVERY_MODE=memory` or `ENABLE_TEST_ROUTES=true` only) + +**Data & infra** + +- PostgreSQL/Redis/MinIO on internal Docker network by default (no host ports) +- DB CHECK constraints, connection pool tuning, startup token cleanup +- CMS HTML sanitization (bleach) with allowed URL protocols: `http`, `https`, `mailto` +- Signed avatar URLs; RBAC + superuser guards on admin routes +- Admin password policy on user create/reset + +**Production guards** (`APP_ENV=production` — API refuses to start if): + +- `ENABLE_TEST_ROUTES=true` +- `ENABLE_DOCS=true` +- `ENABLE_RATE_LIMIT=false` +- `COOKIE_SECURE=false` +- Default DB credentials (`user:pass`) or placeholder JWT secrets +- `DATABASE_URL` without `sslmode=require` + +## Related Documentation + +- [docs/secrets-recovery.md](docs/secrets-recovery.md) — recover from lost install secrets +- [docs/security-checklist.md](docs/security-checklist.md) — pre-production security checklist +- [docs/TZ.md](docs/TZ.md) — full technical specification + +## MVP Status (Phase 1) + +| # | Feature | Status | +| --- | ----------------------------- | ------ | +| 1.1 | PostgreSQL + Alembic + seed | done | +| 1.2 | Auth + SMTP (verify/reset) | done | +| 1.3 | Profile CRUD + avatar (MinIO) | done | +| 1.4 | Content pages | done | +| 1.5 | Admin panel (Ant Design + zootech auth UI) | done | +| 1.6 | E2E regression §15.7 | done | diff --git a/TZ (1).md b/TZ (1).md new file mode 100644 index 0000000..5260ce1 --- /dev/null +++ b/TZ (1).md @@ -0,0 +1,1510 @@ +# Техническое задание +## Платформа «Комптон» — React-приложение с модульной архитектурой +### Испольнителю следовать плану реализации с особым пристрастрием + +**Версия документа:** 1.0 +**Дата:** 09.07.2026 +**Разработчик:** Huli + +--- + +## Содержание + +1. [Общие сведения](#1-общие-сведения) +2. [Цели и границы проекта](#2-цели-и-границы-проекта) +3. [Профиль нагрузки и масштабирование](#3-профиль-нагрузки-и-масштабирование) +4. [Технологический стек](#4-технологический-стек) +5. [Архитектура системы](#5-архитектура-системы) +6. [Модульная структура Frontend (React)](#6-модульная-структура-frontend-react) +7. [Модульная структура Backend (API)](#7-модульная-структура-backend-api) +8. [Модель данных](#8-модель-данных) +9. [API-контракты](#9-api-контракты) +10. [Аутентификация и авторизация](#10-аутентификация-и-авторизация) +11. [UI/UX и дизайн-система](#11-uiux-и-дизайн-система) +12. [Нефункциональные требования](#12-нефункциональные-требования) +13. [Инфраструктура и DevOps](#13-инфраструктура-и-devops) +14. [Безопасность](#14-безопасность) +15. [Тестирование и качество](#15-тестирование-и-качество) +16. [Этапы разработки](#16-этапы-разработки) +17. [Критерии приёмки](#17-критерии-приёмки) +18. [Риски и допущения](#18-риски-и-допущения) + +--- + +## 1. Общие сведения + +### 1.1. Наименование проекта + +**Комптон®** — веб-платформа бренда «Organic Tech» с публичной витриной, личным кабинетом пользователя и административной панелью. + +### 1.2. Текущее состояние + +- Чисто поржать на ошибках и нейронке +- Заглушка на Flask (`main.py`) с одностраничным лендингом. +- Визуальная идентичность задана не полностью: цвета, шрифты (Inter, JetBrains Mono), hero-блок, бегущая строка. +- Рефакторинг текщего состояния не возможен, задача переписать проект. +- Функциональность отсутствует: нет регистрации, контента, API, БД. + + +### 1.3. Заказчик / продукт + +Еблан который зовет себя Huli. Документ описывает целевую архитектуру для замены заглушки полноценным продуктом. + +### 1.4. Термины + +| Термин | Определение | +|--------|-------------| +| **Модуль (feature)** | Изолированный функциональный блок с собственными компонентами, API-слоем, типами и тестами | +| **Shared** | Переиспользуемый код без бизнес-логики конкретного модуля | +| **Core** | Ядро приложения: роутинг, провайдеры, конфигурация, HTTP-клиент | +| **MAU** | Monthly Active Users — уникальные пользователи за месяц | +| **CCU** | Concurrent Users — одновременно онлайн | + +--- + +## 2. Цели и границы проекта + +### 2.1. Бизнес-цели + +1. Заменить заглушку работающим сайтом с сохранением бренда. +2. Обеспечить регистрацию и работу **до 1 000 зарегистрированных пользователей** (~100–150 DAU) без деградации UX. +3. Заложить архитектуру, позволяющую масштабироваться до **10 000+ пользователей** без переписывания модулей. +4. Обеспечить независимую разработку модулей разными разработчиками. +5. **Каждый шаг реализации** (задача / PR / модуль) поставляется **только с полным набором тестов**: unit/integration логики + E2E пользовательских сценариев (§15). + +### 2.2. Функциональный scope (MVP → v1.0) + +#### MVP (фаза 1) + +| Модуль | Функции | +|--------|---------| +| **Landing** | Hero, бегущая строка, «О бренде», контакты, SEO-мета | +| **Auth** | Регистрация, вход, выход, восстановление пароля, подтверждение email *(требует SMTP в MVP — см. §16)* | +| **Profile** | Просмотр/редактирование профиля, аватар, смена пароля | +| **Content** | Статические страницы (О нас, Политика, Условия) из CMS или markdown | +| **Admin** | Управление пользователями, контентом, просмотр метрик | + +#### v1.0 (фаза 2, опционально) + +| Модуль | Функции | +|--------|---------| +| **Catalog** | Каталог продуктов/услуг, карточки, фильтры | +| **Orders** | Корзина, оформление заявки (без оплаты — см. §2.3), история | +| **Notifications** | In-app уведомления; email-рассылки через очередь (Celery) | +| **Analytics** | Дашборд событий, экспорт | + +### 2.3. Out of scope (не входит в v1.0) + +- Мобильное нативное приложение (только responsive web). +- Мультиязычность (заложить i18n-структуру, реализовать позже). +- Платёжные шлюзы (интеграция — отдельный этап). Модуль Orders в v1.0 работает как **заявка/бронирование без оплаты**. +- Real-time чат / WebRTC. + +--- + +## 3. Профиль нагрузки и масштабирование + +### 3.1. Целевые метрики (1000 пользователей) + +| Метрика | Значение | Комментарий | +|---------|----------|-------------| +| Зарегистрированных пользователей | 1 000 | Целевой объём на старте | +| DAU | 100–150 (10–15%) | Типичный коэффициент для B2C | +| CCU (пик) | 30–50 | Одновременные сессии | +| RPS API (пик) | 20–50 req/s | С запасом ×3 | +| Размер БД | < 5 GB | Профили, контент, логи | +| Медиа | < 50 GB | CDN для статики | + +### 3.2. Путь масштабирования + +```mermaid +flowchart LR + subgraph phase1 [Фаза 1: до 1K] + A1[Monolith API] + A2[PostgreSQL] + A3[Redis cache] + A4[CDN static] + end + + subgraph phase2 [Фаза 2: 1K–10K] + B1[2× API instances] + B2[Read replica PG] + B3[Redis cluster] + B4[Object storage S3] + end + + subgraph phase3 [Фаза 3: 10K+] + C1[Horizontal API scale] + C2[Extract heavy modules] + C3[Message queue] + C4[Separate admin] + end + + phase1 --> phase2 --> phase3 +``` + +**Принцип:** на 1000 пользователей достаточно **модульного монолита** (один backend-процесс, чёткие границы модулей). Микросервисы не нужны до 10K+ и только для узких «тяжёлых» модулей (уведомления, аналитика). + +### 3.3. SLA (целевые) + +| Параметр | MVP | v1.0 | +|----------|-----|------| +| Uptime | 99.5% | 99.9% | +| TTFB публичных страниц | < 500 ms | < 300 ms | +| API p95 latency | < 300 ms | < 200 ms | +| LCP (Lighthouse mobile) | < 2.5 s | < 2.0 s | + +--- + +## 4. Технологический стек + +### 4.1. Frontend + +| Слой | Технология | Обоснование | +|------|------------|-------------| +| Framework | **React 19** + **TypeScript 5** | Экосистема, типизация | +| Bundler | **Vite 6** | Быстрая сборка, HMR | +| Routing | **React Router 7** | Стандарт de facto | +| Server state | **TanStack Query 5** | Кеш, retry, invalidation | +| Client state | **Zustand** | Лёгкий, без boilerplate | +| Forms | **React Hook Form** + **Zod** | Валидация, DX | +| UI | **Tailwind CSS 4** + headless (Radix UI) | Соответствие дизайн-системе | +| HTTP | **Axios** или fetch-обёртка | Interceptors, типизация | +| i18n (заготовка) | **react-i18next** | Структура без реализации | +| Tests | **Vitest** + **Testing Library** + **Playwright** + **pytest** | Unit + integration + E2E на каждом PR (§15) | + +### 4.2. Backend + +| Слой | Технология | Обоснование | +|------|------------|-------------| +| Runtime | **Python 3.12** | Преемственность Flask-проекта | +| Framework | **FastAPI** | Async, OpenAPI, производительность | +| ORM | **SQLAlchemy 2** + **Alembic** | Миграции, типизация | +| Validation | **Pydantic v2** | Согласованность с OpenAPI | +| Auth | **JWT** (access + refresh rotation) + **passlib/bcrypt** | Stateless access, revocable refresh | +| Task queue (v1.0) | **Celery** + Redis | Email, фоновые задачи | +| Email | **SMTP** / SendGrid / Resend | MVP: синхронная отправка verify/reset; v1.0: Celery queue | + +### 4.3. Data & Infra + +| Компонент | Технология | +|-----------|------------| +| Primary DB | **PostgreSQL 16** | +| Cache / sessions | **Redis 7** | +| File storage | **S3-compatible** (MinIO dev / Yandex S3 / AWS prod) | +| Reverse proxy | **Nginx** | +| Containerization | **Docker** + **Docker Compose** (dev/staging) | +| CI/CD | **GitHub Actions** | +| Monitoring | **Prometheus** + **Grafana** (или managed: Datadog) | +| Logging | Structured JSON → **Loki** или cloud logs | +| Error tracking | **Sentry** | + +### 4.4. Архитектурный стиль Frontend + +**Feature-Sliced Design (FSD)** — адаптированная версия с явными модулями: + +``` +app → pages → modules → shared +``` + +- **app** — инициализация, провайдеры, глобальный роутер. +- **pages** — композиция модулей в маршруты (тонкий слой). +- **modules** — бизнес-фичи (auth, profile, catalog…). +- **shared** — UI-kit, utils, API client, types без доменной логики. + +--- + +## 5. Архитектура системы + +### 5.1. Общая схема + +```mermaid +flowchart TB + subgraph client [Клиент] + Browser[Browser / Mobile Web] + SPA[React SPA] + end + + subgraph edge [Edge] + CDN[CDN — static assets] + Nginx[Nginx — TLS, gzip, rate limit] + end + + subgraph backend [Backend] + API[FastAPI Monolith] + subgraph modules_api [Modules] + M1[auth] + M2[users] + M3[content] + M4[admin] + end + end + + subgraph data [Data Layer] + PG[(PostgreSQL)] + Redis[(Redis)] + S3[(Object Storage)] + end + + Browser --> CDN + Browser --> Nginx + Nginx -->|static| SPA + SPA -->|REST JSON /api| Nginx + Nginx --> API + API --> modules_api + modules_api --> PG + modules_api --> Redis + modules_api --> S3 +``` + +### 5.2. Принципы модульности + +1. **Вертикальные срезы** — каждый модуль владеет UI + API + схемой БД (таблицы через общий ORM, но namespace по модулю). +2. **Запрет cross-import между modules** — общение только через: + - публичный API модуля (`modules/auth/api/index.ts`); + - shared-слой; + - backend REST/events. +3. **Публичный контракт модуля** — `index.ts` экспортирует только то, что нужно снаружи. +4. **Приватная реализация** — `internal/` не импортируется из других модулей. + +### 5.3. Репозиторий (monorepo-lite) + +``` +compton/ +├── apps/ +│ ├── web/ # React SPA +│ └── api/ # FastAPI +├── packages/ +│ ├── shared-types/ # OpenAPI-generated TS types +│ └── eslint-config/ # Общие lint rules +├── infra/ +│ ├── docker/ +│ ├── nginx/ +│ └── terraform/ # опционально +├── docs/ +│ └── TZ.md +└── docker-compose.yml +``` + +На старте допустим **single repo** без Turborepo; `packages/shared-types` генерируется из OpenAPI при CI. + +--- + +## 6. Модульная структура Frontend (React) + +### 6.1. Дерево каталогов `apps/web/src` + +``` +src/ +├── app/ +│ ├── App.tsx +│ ├── providers/ +│ │ ├── QueryProvider.tsx +│ │ ├── AuthProvider.tsx +│ │ └── ThemeProvider.tsx +│ ├── router/ +│ │ ├── routes.tsx +│ │ └── guards/ +│ │ ├── AuthGuard.tsx +│ │ └── AdminGuard.tsx +│ └── styles/ +│ └── globals.css +│ +├── pages/ +│ ├── HomePage/ +│ ├── LoginPage/ +│ ├── RegisterPage/ +│ ├── ProfilePage/ +│ ├── ContentPage/ +│ └── AdminPage/ +│ +├── modules/ +│ ├── landing/ +│ │ ├── index.ts # public API +│ │ ├── components/ +│ │ │ ├── HeroSection.tsx +│ │ │ └── MarqueeSection.tsx +│ │ ├── hooks/ +│ │ └── types/ +│ │ +│ ├── auth/ +│ │ ├── index.ts +│ │ ├── api/ +│ │ │ └── authApi.ts +│ │ ├── components/ +│ │ │ ├── LoginForm.tsx +│ │ │ └── RegisterForm.tsx +│ │ ├── hooks/ +│ │ │ ├── useLogin.ts +│ │ │ └── useAuth.ts +│ │ ├── store/ +│ │ │ └── authStore.ts +│ │ └── types/ +│ │ +│ ├── profile/ +│ │ ├── index.ts +│ │ ├── api/ +│ │ ├── components/ +│ │ ├── hooks/ +│ │ └── types/ +│ │ +│ ├── content/ +│ │ ├── index.ts +│ │ ├── api/ +│ │ ├── components/ +│ │ └── hooks/ +│ │ +│ ├── admin/ +│ │ ├── index.ts +│ │ ├── api/ +│ │ ├── components/ +│ │ └── hooks/ +│ │ +│ └── catalog/ # v1.0 +│ └── ... +│ +├── __tests__/ # cross-module integration (frontend) +│ └── setup.ts +│ +└── shared/ + ├── api/ + │ ├── client.ts + │ ├── client.test.ts + │ ├── errors.ts + │ └── types.ts + ├── ui/ + │ ├── Button/ + │ │ ├── Button.tsx + │ │ └── Button.test.tsx + │ └── ... + └── ... + +# Каждый module обязан содержать: +modules// +├── __tests__/ # unit: hooks, utils, store +├── components/ +│ └── *.test.tsx # component tests (Testing Library) +└── api/ + └── *.test.ts # API client + mock handlers +``` + +### 6.2. Правила зависимостей (import rules) + +```mermaid +flowchart TD + app --> pages + pages --> modules + modules --> shared + modules -.->|FORBIDDEN| modules + shared -.->|FORBIDDEN| modules + shared -.->|FORBIDDEN| pages +``` + +Enforcement через **ESLint** (`eslint-plugin-boundaries` или custom rules): + +| From → To | app | pages | modules | shared | +|-----------|-----|-------|---------|--------| +| app | ✓ | ✓ | ✓ | ✓ | +| pages | — | ✓ | ✓ | ✓ | +| modules | — | — | ✓ (own) | ✓ | +| shared | — | — | ✗ | ✓ | + +### 6.3. Описание модулей Frontend + +#### 6.3.1. `landing` + +**Ответственность:** + +- Буду ебать за каждый нейрослоп + +**Зависимости:** только `shared/ui`, `shared/hooks`. + +#### 6.3.2. `auth` + +**Ответственность:** аутентификация, сессия, guards. + +| Export (public API) | Описание | +|---------------------|----------| +| `LoginForm`, `RegisterForm` | Формы | +| `useAuth()` | `{ user, isAuthenticated, login, logout, refreshSession }` | + +**Guards** (`AuthGuard`, `GuestGuard`, `AdminGuard`) живут в `app/router/guards/` — они **импортируют** `useAuth` из модуля `auth`, но не экспортируются из него (слой `app` композирует модули). + +**Store:** `accessToken` только in-memory (Zustand). `refreshToken` **только** httpOnly Secure cookie (`SameSite=Lax`). Хранение refresh в `localStorage` / `sessionStorage` **запрещено**. + +#### 6.3.3. `profile` + +**Ответственность:** CRUD профиля пользователя. + +| Функции | API endpoints | +|---------|---------------| +| Просмотр профиля | `GET /api/v1/users/me` | +| Редактирование | `PATCH /api/v1/users/me` | +| Смена пароля (авторизован) | `POST /api/v1/users/me/password` | +| Загрузка аватара | `POST /api/v1/users/me/avatar` | + +#### 6.3.4. `content` + +**Ответственность:** рендер CMS-страниц по slug. Запись — только для `admin` (через те же роуты с RBAC-проверкой; отдельный `admin`-модуль не дублирует CRUD контента). + +| Функции | Описание | +|---------|----------| +| `ContentPage` | `/about`, `/privacy`, `/terms` | +| SEO | meta title, description, og:image | + +#### 6.3.5. `admin` + +**Ответственность:** панель администратора (role: `admin`). + +| Раздел | Функции | +|--------|---------| +| Users | список, блокировка, смена роли *(с ограничениями — §14.10)* | +| Content | — *(CRUD контента — модуль `content`, §9.4)* | +| Dashboard | базовые метрики (users count, registrations/day) | + +### 6.4. Роутинг + +| Маршрут | Page | Guard | Модуль(и) | +|---------|------|-------|-----------| +| `/` | HomePage | — | landing | +| `/login` | LoginPage | guest only | auth | +| `/register` | RegisterPage | guest only | auth | +| `/profile` | ProfilePage | AuthGuard | profile | +| `/pages/:slug` | ContentPage | — | content | +| `/admin/*` | AdminPage | AdminGuard | admin | + +### 6.5. Code splitting + +- Lazy load: `admin`, `profile`, `catalog`. +- Prefetch on hover для `/login`, `/register`. +- Landing — в initial bundle (LCP-critical). + +--- + +## 7. Модульная структура Backend (API) + +### 7.1. Дерево каталогов `apps/api` + +``` +app/ +├── main.py # FastAPI app factory +├── core/ +│ ├── config.py +│ ├── database.py +│ ├── redis.py +│ ├── security.py # JWT, password hashing +│ ├── dependencies.py # get_db, get_current_user +│ └── exceptions.py +│ +├── modules/ +│ ├── auth/ +│ │ ├── router.py +│ │ ├── service.py +│ │ ├── schemas.py +│ │ └── repository.py +│ │ +│ ├── users/ +│ │ ├── router.py +│ │ ├── service.py +│ │ ├── models.py +│ │ ├── schemas.py +│ │ └── repository.py +│ │ +│ ├── content/ +│ │ ├── router.py +│ │ ├── service.py +│ │ ├── models.py +│ │ └── schemas.py +│ │ +│ ├── media/ +│ │ ├── router.py +│ │ ├── service.py # S3 upload +│ │ └── schemas.py +│ │ +│ └── admin/ +│ ├── router.py +│ ├── service.py +│ └── schemas.py +│ +├── migrations/ # Alembic +└── tests/ + ├── conftest.py # fixtures: db, client, test users + ├── factories.py # user/content factories + ├── modules/ + │ ├── auth/ + │ │ ├── test_service.py # unit: бизнес-логика + │ │ ├── test_router.py # integration: HTTP + DB + │ │ └── test_security.py # rotation, lockout, enumeration + │ ├── users/ + │ ├── content/ + │ └── admin/ + └── e2e/ # pytest API smoke (optional layer) + └── test_health.py +``` + +### 7.2. Слои внутри модуля (Clean Architecture lite) + +``` +Router → Service → Repository → Model + ↓ + Schemas (Pydantic) +``` + +| Слой | Ответственность | +|------|-----------------| +| **Router** | HTTP, status codes, dependency injection | +| **Service** | Бизнес-логика, orchestration | +| **Repository** | SQL-запросы, абстракция БД | +| **Schemas** | Request/Response DTO | +| **Models** | SQLAlchemy ORM | + +**Правило:** модуль не импортирует `repository` другого модуля. Межмодульные вызовы — только через **публичный `service`-фасад** (например, `users.public.get_by_id()`) или domain events. + +### 7.3. Версионирование API + +- Префикс: `/api/v1/` +- Breaking changes → `/api/v2/` +- OpenAPI: `/api/v1/openapi.json` +- Swagger UI: `/api/v1/docs` (только dev/staging) + +--- + +## 8. Модель данных + +### 8.1. ER-диаграмма (MVP) + +```mermaid +erDiagram + users ||--o| user_profiles : has + users ||--o{ refresh_tokens : has + users ||--o{ password_reset_tokens : has + users ||--o{ content_pages : creates + + users { + uuid id PK + string email UK + string password_hash + enum role "user|admin" + enum status "active|blocked|pending" + timestamp email_verified_at + int failed_login_attempts + timestamp locked_until + timestamp created_at + timestamp updated_at + } + + user_profiles { + uuid user_id PK,FK + string display_name + string avatar_url + json metadata + } + + refresh_tokens { + uuid id PK + uuid user_id FK + string token_hash UK + uuid family_id + timestamp expires_at + timestamp revoked_at + timestamp created_at + } + + password_reset_tokens { + uuid id PK + uuid user_id FK + string token_hash UK + timestamp expires_at + timestamp used_at + } + + content_pages { + uuid id PK + string slug UK + string title + text body + enum status "draft|published" + uuid author_id FK + timestamp published_at + timestamp updated_at + } +``` + +### 8.2. Индексы + +```sql +CREATE UNIQUE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_status ON users(status); +CREATE INDEX idx_content_slug ON content_pages(slug); +CREATE INDEX idx_content_status ON content_pages(status); +CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_family ON refresh_tokens(family_id); +CREATE INDEX idx_password_reset_user ON password_reset_tokens(user_id); +``` + +### 8.3. Миграции + +- Alembic, одна миграция = одна логическая задача. +- Rollback обязателен для каждой миграции. +- Seed: admin user, demo content pages. + +--- + +## 9. API-контракты + +### 9.1. Общие соглашения + +| Аспект | Стандарт | +|--------|----------| +| Format | JSON, UTF-8 | +| Dates | ISO 8601 UTC (`2026-07-09T12:00:00Z`) | +| IDs | UUID v4 | +| Pagination | `?page=1&limit=20`, response: `{ data, meta: { total, page, limit } }` | +| Errors | `{ "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [] } }` | + +### 9.2. Auth endpoints + +| Method | Path | Auth | Описание | +|--------|------|------|----------| +| POST | `/api/v1/auth/register` | — | Регистрация → `status: pending`, письмо с подтверждением | +| POST | `/api/v1/auth/login` | — | Вход → access в body, refresh в `Set-Cookie` | +| POST | `/api/v1/auth/refresh` | refresh cookie | Новая пара access + refresh (rotation) | +| POST | `/api/v1/auth/logout` | refresh cookie | Revoke token family, очистка cookie | +| POST | `/api/v1/auth/verify-email` | — | Body: `{ "token": "..." }` → `status: active` | +| POST | `/api/v1/auth/resend-verification` | — | Повторная отправка (rate limited) | +| POST | `/api/v1/auth/forgot-password` | — | Отправка reset-link (без раскрытия наличия email) | +| POST | `/api/v1/auth/reset-password` | — | Смена пароля по одноразовому token | + +**Правила ответов auth (anti-enumeration):** +- `register` с существующим email → **200** с нейтральным телом *или* **409** без указания причины в prod (на выбор; зафиксировать в реализации). +- `forgot-password` → всегда **200** «Если email зарегистрирован, письмо отправлено». +- `login` при неверных данных → **401** с общим сообщением «Неверный email или пароль». +- `login` при `status: pending` → **403** `EMAIL_NOT_VERIFIED`. +- `login` при `status: blocked` → **403** `ACCOUNT_BLOCKED`. +- `login` при блокировке brute-force → **429** `ACCOUNT_TEMPORARILY_LOCKED`. + +**Response login (200):** +```json +{ + "access_token": "eyJ...", + "token_type": "bearer", + "expires_in": 900, + "user": { + "id": "uuid", + "email": "user@example.com", + "role": "user", + "status": "active" + } +} +``` + +**Set-Cookie (login / refresh — обязательно):** +``` +Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Lax; Path=/api/v1/auth; Max-Age=2592000 +``` + +> Refresh token **никогда** не возвращается в JSON-body. + +### 9.3. Users endpoints + +| Method | Path | Auth | Описание | +|--------|------|------|----------| +| GET | `/api/v1/users/me` | user | Текущий пользователь + profile | +| PATCH | `/api/v1/users/me` | user | Обновление профиля (whitelist полей) | +| POST | `/api/v1/users/me/password` | user | `{ current_password, new_password }` | +| POST | `/api/v1/users/me/avatar` | user | multipart upload | +| DELETE | `/api/v1/users/me` | user | Удаление аккаунта (v1.0, soft-delete + anonymize) | + +### 9.4. Content endpoints + +| Method | Path | Auth | Описание | +|--------|------|------|----------| +| GET | `/api/v1/content/pages` | — | Список published pages | +| GET | `/api/v1/content/pages/{slug}` | — | Страница по slug | +| POST | `/api/v1/content/pages` | admin | Создание | +| PATCH | `/api/v1/content/pages/{id}` | admin | Обновление | +| DELETE | `/api/v1/content/pages/{id}` | admin | Удаление | + +### 9.5. Admin endpoints + +| Method | Path | Auth | Описание | +|--------|------|------|----------| +| GET | `/api/v1/admin/users` | admin | Список пользователей (pagination, без password_hash) | +| PATCH | `/api/v1/admin/users/{id}` | admin | Блокировка, смена роли *(§14.10)* | +| GET | `/api/v1/admin/stats` | admin | Базовые метрики | +| GET | `/api/v1/admin/audit-log` | admin | Журнал действий админов (v1.0) | + +### 9.6. Rate limiting + +| Endpoint group | Limit | Key | +|----------------|-------|-----| +| Auth: login | 5 req/min | IP + email (composite) | +| Auth: register | 3 req/hour | IP | +| Auth: forgot-password, resend-verification | 3 req/hour | IP + email | +| Auth: refresh | 30 req/min | user_id (из token) | +| Public API | 100 req/min | IP | +| Authenticated API | 300 req/min | user_id (fallback: IP) | +| Upload (avatar) | 10 req/hour | user_id | + +При превышении → **429** + заголовок `Retry-After`. + +**Brute-force (login):** после 5 неудачных попыток за 15 мин — `locked_until = now + 15 min` (поле в `users`). + +--- + +## 10. Аутентификация и авторизация + +### 10.1. JWT Strategy + +| Token | Формат | TTL | Storage | Передача | +|-------|--------|-----|---------|----------| +| Access | JWT (HS256 или RS256) | 15 min | In-memory (JS) | `Authorization: Bearer` | +| Refresh | Opaque random (256 bit) | 30 days | httpOnly Secure cookie | Cookie `refresh_token` | + +**Claims access JWT:** `sub` (user_id), `role`, `iat`, `exp`, `jti` (уникальный ID для optional denylist). + +**Refresh token:** +- В БД хранится **только SHA-256 hash**, не plaintext. +- **Rotation:** каждый `/auth/refresh` выдаёт новый refresh, старый revoke. +- **Reuse detection:** повторное использование revoked token → revoke всей `family_id`, принудительный logout на всех устройствах. +- Cookie `Path=/api/v1/auth` — не отправляется на остальные API-роуты (минимизация поверхности CSRF). + +### 10.2. RBAC + +| Role | Permissions | +|------|-------------| +| `guest` | Чтение опубликованного контента | +| `user` (`status: active`) | Управление своим профилем | +| `admin` | Управление пользователями, контентом, stats; **не может** изменить собственную роль (§14.10) | + +**Проверка на каждом protected endpoint:** +1. Валидный access JWT (подпись, exp, jti). +2. Пользователь существует и `status != blocked`. +3. Для admin-роутов — `role == admin`. +4. Для user-роутов — `sub == resource owner` (защита от IDOR). + +`pending`-пользователь может вызвать только: verify-email, resend-verification, logout. + +### 10.3. Password policy + +- Минимум 8 символов, 1 uppercase, 1 lowercase, 1 digit. +- Denylist топ-10k паролей (Have I Been Pwned или локальный список). +- bcrypt, cost factor 12. +- Reset/verify token: 256 bit random, **hash в БД**, TTL 1 hour, одноразовый. +- При смене/сбросе пароля — revoke все `refresh_tokens` пользователя. + +### 10.4. CORS и credentials + +```python +# FastAPI CORSMiddleware +allow_origins = CORS_ORIGINS # whitelist, без wildcard в prod +allow_credentials = True # обязательно для refresh cookie +allow_methods = ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] +allow_headers = ["Authorization", "Content-Type", "X-Request-ID"] +``` + +Frontend HTTP-клиент: `withCredentials: true` только для auth-запросов (login, refresh, logout). + +### 10.5. CSRF (cookie-based refresh) + +| Механизм | Применение | +|----------|------------| +| `SameSite=Lax` на refresh cookie | Блокирует cross-site POST в большинстве браузеров | +| `Path=/api/v1/auth` | Cookie не уходит на PATCH/POST других ресурсов | +| Проверка `Origin` / `Referer` на auth-роутах | Backend отклоняет запросы с чужого origin | +| Access JWT в header (не cookie) | State-changing API через Bearer не уязвим к CSRF | + +Дополнительный CSRF-token **не требуется** при соблюдении схемы выше. Если refresh cookie когда-либо расширят на весь `/api` — добавить double-submit CSRF token. + +--- + +## 11. UI/UX и дизайн-система + +### 11.1. Design tokens (из текущей заглушки) + +```css +:root { + --bg: #F6F6F4; + --foreground: #1A1E1C; + --primary: #48816D; + --muted: #4A5A52; + --marquee-bg: #EBEBE5; +} +``` + +### 11.2. Типографика + +| Назначение | Шрифт | +|------------|-------| +| UI / body | Inter (400, 500, 600) | +| Mono / акценты | JetBrains Mono (400, 500) | +| Декоративный (опционально) | Oktyabrina Script | + +### 11.3. Breakpoints + +| Token | Width | +|-------|-------| +| `sm` | 380px | +| `md` | 480px | +| `lg` | 768px | +| `xl` | 1024px | +| `2xl` | 1280px | + +### 11.4. Компоненты shared/ui (MVP) + +- Button (primary, secondary, ghost) +- Input, Textarea +- Label, ErrorMessage +- Card, Modal, Spinner +- Header, Footer, Container +- Avatar, Badge + +### 11.5. Accessibility + +- WCAG 2.1 AA для MVP. +- Keyboard navigation, focus visible. +- `alt` для всех изображений. +- `prefers-reduced-motion` — отключение marquee-анимации. + +--- + +## 12. Нефункциональные требования + +### 12.1. Performance + +| Метрика | Target | +|---------|--------| +| Initial JS bundle (landing) | < 150 KB gzip | +| Total JS (app loaded) | < 350 KB gzip | +| API cache (public content) | Redis TTL 5 min | +| Static assets | CDN, cache-control 1 year (hash in filename) | + +### 12.2. SEO + +- SSR/SSG **не обязателен** для MVP (SPA + prerender landing через Vite SSG plugin). +- Meta tags, sitemap.xml, robots.txt. +- Semantic HTML (`header`, `main`, `section`). + +### 12.3. Observability + +| Signal | Tool | +|--------|------| +| Logs | JSON structured, correlation-id | +| Metrics | request duration, error rate, DB pool | +| Traces | OpenTelemetry (optional v1.0) | +| Frontend errors | Sentry browser SDK | + +### 12.4. Backup & Recovery + +- PostgreSQL: daily backup, retention 30 days. +- RPO: 24 hours, RTO: 4 hours (на 1000 users достаточно). + +--- + +## 13. Инфраструктура и DevOps + +### 13.1. Окружения + +| Env | Назначение | URL | +|-----|------------|-----| +| local | разработка | localhost:5173 (web), :8000 (api) | +| staging | QA, demo | staging.compton.example | +| production | prod | compton.example | + +### 13.2. Docker Compose (local) + +```yaml +services: + web: + build: ./apps/web + ports: ["5173:5173"] + api: + build: ./apps/api + ports: ["8000:8000"] + depends_on: [postgres, redis] + postgres: + image: postgres:16 + redis: + image: redis:7-alpine + minio: + image: minio/minio # S3-compatible dev + +# apps/web/e2e/ — Playwright specs (§15.3) +# docker-compose.test.yml — postgres + redis + api + web для CI/E2E +``` + +### 13.3. CI/CD Pipeline + +```mermaid +flowchart LR + Push[Git Push / PR] --> Lint[Lint + Typecheck] + Lint --> Unit[Unit + Integration] + Unit --> Cov{Coverage ≥ threshold?} + Cov -->|No| Block1[❌ Block merge] + Cov -->|Yes| Build[Build Docker] + Build --> DeployStaging[Deploy Staging] + DeployStaging --> E2E[Playwright E2E] + E2E --> E2EPass{All E2E pass?} + E2EPass -->|No| Block2[❌ Block merge] + E2EPass -->|Yes| Review[Code Review] + Review --> ManualApprove[Manual Approve] + ManualApprove --> DeployProd[Deploy Production] +``` + +**Checks на каждый PR (обязательны, merge заблокирован при падении):** + +| Gate | Команда | Условие pass | +|------|---------|--------------| +| Lint | `eslint`, `ruff`, `prettier --check` | 0 errors | +| Types | `tsc --noEmit`, `mypy` | 0 errors | +| Unit (FE) | `vitest run --coverage` | ≥ порогов §15.2 | +| Unit + Integration (BE) | `pytest --cov=app --cov-fail-under=...` | ≥ порогов §15.2 | +| OpenAPI | schema diff | no breaking без bump version | +| E2E | `playwright test` | 100% pass, 0 flaky retries exhausted | +| Security | `pip-audit`, `npm audit`, gitleaks | no unwaived critical | + +> **Правило:** PR без тестов на изменённую логику **не ревьюится** и **не мержится**. + +### 13.4. Production topology (1000 users) + +``` +1× VPS (4 vCPU, 8 GB RAM) или managed PaaS +├── Nginx +├── API container (uvicorn, 2 workers) +├── Web static (built SPA) +├── PostgreSQL (managed или co-located) +└── Redis (managed или co-located) +``` + +**Запас на рост:** второй API instance за load balancer при CPU > 70% sustained. + +--- + +## 14. Безопасность + +### 14.1. Threat model (STRIDE, MVP) + +| Угроза | Вектор | Митигация | +|--------|--------|-----------| +| **Spoofing** | Подделка JWT / session | Подпись JWT, короткий TTL, rotation refresh | +| **Tampering** | Изменение чужих данных | RBAC + owner check (`sub == user_id`) | +| **Repudiation** | Отрицание admin-действий | Audit log (v1.0), correlation-id в логах | +| **Information disclosure** | Enumeration email, утечка PII | Anti-enumeration (§9.2), маскировка логов | +| **Denial of service** | Flood login/register | Rate limit + account lockout (§9.6) | +| **Elevation of privilege** | user → admin | RBAC middleware, admin self-edit запрещён | + +**Trust boundaries:** +1. Browser (недоверенный) ↔ Nginx (TLS termination) +2. Nginx ↔ API (internal network / docker network) +3. API ↔ PostgreSQL / Redis / S3 (credentials via env) + +### 14.2. Transport и security headers + +**Обязательно в production (Nginx / middleware):** + +| Header | Значение | +|--------|----------| +| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | +| `X-Content-Type-Options` | `nosniff` | +| `X-Frame-Options` | `DENY` | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | +| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | +| `Content-Security-Policy` | см. §14.3 | + +- TLS 1.2+ only, сильные cipher suites. +- Swagger UI (`/docs`) и OpenAPI JSON — **отключены в production** (env flag `ENABLE_DOCS=false`). + +### 14.3. Content Security Policy + +``` +default-src 'self'; +script-src 'self'; +style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; +font-src 'self' https://fonts.gstatic.com; +img-src 'self' data: https://; +connect-src 'self' https://; +frame-ancestors 'none'; +base-uri 'self'; +form-action 'self'; +``` + +- Inline scripts в SPA — через nonce или только bundled (Vite). +- Google Fonts — допустимое исключение; при ужесточении CSP — self-host шрифтов. + +### 14.4. XSS и инъекции + +| Поверхность | Защита | +|-------------|--------| +| React UI | Auto-escaping JSX | +| CMS `content_pages.body` (HTML) | Server-side sanitize (**bleach** / **nh3**) + client DOMPurify allowlist перед `dangerouslySetInnerHTML` | +| User input (profile) | Pydantic validation, max length, strip control chars | +| SQL | SQLAlchemy ORM, parameterized queries; raw SQL запрещён без review | +| Log injection | Structured JSON logging, sanitize `\n\r` в user input | + +**Allowlist HTML-тегов CMS:** `p, h1-h4, ul, ol, li, a, strong, em, br, img` — без `script`, `iframe`, `on*` attributes. + +### 14.5. IDOR и авторизация + +| Endpoint | Правило | +|----------|---------| +| `GET/PATCH /users/me` | `current_user.id` из JWT, не из body | +| `POST /users/me/avatar` | только свой user_id | +| `PATCH /admin/users/{id}` | admin + business rules (§14.10) | +| `GET /content/pages/{slug}` | только `status=published` для non-admin | +| `PATCH /content/pages/{id}` | admin only; проверка существования id | + +**Запрещено:** принимать `user_id` / `role` из request body для повышения привилегий. + +### 14.6. Загрузка файлов (avatar) + +| Проверка | Значение | +|----------|----------| +| Max size | 2 MB | +| MIME (whitelist) | `image/jpeg`, `image/png`, `image/webp` | +| Magic bytes | python-magic / file signature check | +| Filename | UUID + ext, без user-supplied path | +| Storage | S3 private bucket; URL — signed / через CDN proxy | +| Processing | Re-encode (Pillow) для удаления EXIF и embedded payload | + +**Запрещено:** SVG upload (XSS vector), исполнение файлов на сервере. + +### 14.7. Secrets и криптография + +| Secret | Хранение | Rotation | +|--------|----------|----------| +| `JWT_ACCESS_SECRET` | env / vault | при компрометации, quarterly | +| `JWT_REFRESH_PEPPER` | env / vault | отдельный от access secret | +| DB credentials | env / managed DB | managed rotation | +| S3 keys | IAM role (preferred) или env | quarterly | + +- `.env` в `.gitignore`; `.env.example` без реальных значений. +- Pre-commit hook / CI: **gitleaks** или **trufflehog**. +- JWT secret ≥ 256 bit entropy. + +### 14.8. Сессии и logout + +- **Logout:** revoke refresh token family + clear cookie (`Max-Age=0`). +- **Password change / reset:** revoke **все** refresh tokens пользователя. +- **Block user (admin):** revoke tokens немедленно. +- Optional: Redis denylist для access JWT by `jti` до exp (для instant revoke admin-сессий). + +### 14.9. Логирование и PII + +**Логировать:** +- `request_id`, method, path, status, duration, user_id (if auth), IP (hashed в prod опционально). + +**Не логировать:** +- Passwords, tokens, reset links, full email в debug (mask: `u***@example.com`). + +**Retention:** application logs 30 days; audit log admin-действий 1 year. + +### 14.10. Admin security rules + +| Правило | Описание | +|---------|----------| +| Last admin | Нельзя удалить/понизить последнего admin | +| Self-demotion | Admin не может снять с себя роль admin | +| Self-block | Admin не может заблокировать себя | +| Role escalation | Только admin может назначать admin; 2FA для admin — v1.0 (recommended) | +| Seed admin | Создаётся миграцией; пароль из env при первом deploy, затем смена | + +### 14.11. Supply chain + +- Dependabot / Renovate — auto PR на CVE. +- CI: `pip-audit` (backend), `npm audit` (frontend), **Trivy** scan Docker images. +- Pin dependencies (`poetry.lock`, `package-lock.json`). +- Block merge при critical/high без explicit waiver. + +### 14.12. GDPR / 152-ФЗ + +| Требование | Реализация | +|------------|------------| +| Согласие | Checkbox при регистрации + ссылка на политику | +| Политика конфиденциальности | CMS-страница `/privacy` | +| Право на удаление | `DELETE /users/me` (v1.0): soft-delete, anonymize PII, retain audit/legal minimum | +| Data minimization | `metadata` json — только необходимые поля | +| Хранение | RU/EU region hosting (допущение §18.1) | +| Breach notification | Runbook в docs (v1.0) | + +### 14.13. Security checklist (pre-production) + +- [ ] HTTPS + HSTS +- [ ] CSP, security headers (§14.2) +- [ ] CORS whitelist, `allow_credentials` только для trusted origins +- [ ] Refresh: httpOnly, Secure, SameSite, Path, rotation + reuse detection +- [ ] Access JWT in memory only +- [ ] Anti-enumeration на auth endpoints +- [ ] Rate limiting + brute-force lockout +- [ ] IDOR tests на все `/me` и admin routes +- [ ] File upload validation + private S3 +- [ ] CMS HTML sanitization +- [ ] Secrets not in git; gitleaks in CI +- [ ] Swagger disabled in prod +- [ ] Dependency scan clean (no unwaived critical) +- [ ] OWASP ASVS Level 1 review +- [ ] Sentry: scrub PII from breadcrumbs + +### 14.14. Security testing (дополнение к §15) + +| Тест | Tool / метод | +|------|--------------| +| SAST | Bandit (Python), ESLint security plugins | +| DAST | OWASP ZAP baseline scan на staging | +| Auth flows | pytest: token rotation, reuse detection, lockout | +| IDOR | pytest: user A cannot access user B | +| Fuzzing upload | Invalid MIME, oversized, polyglot files | + +--- + +## 15. Тестирование и качество + +> **Базовое правило проекта:** функция считается реализованной только когда поставлены **тесты логики** (unit + integration) **и E2E-сценарии** для затронутых пользовательских потоков. Исключений нет. + +### 15.1. Пирамида и обязательные уровни + +| Уровень | Что покрывает | Tools | Обязательность | +|---------|---------------|-------|----------------| +| **Unit** | service, repository, hooks, store, utils, pure components | Vitest, pytest | **Обязательно** на каждый PR с логикой | +| **Integration** | API routers + DB + Redis; form → API client | pytest + TestClient, MSW | **Обязательно** для backend и API-слоя frontend | +| **Component** | UI: render, a11y, user events | Testing Library | **Обязательно** для новых/изменённых компонентов | +| **E2E** | Сквозные user journeys в браузере | Playwright | **Обязательно** на каждый модуль / фазу (§15.5) | +| **Visual** | Landing regression | Percy/Chromatic | Опционально | +| **Load** | API под CCU | k6 | Перед релизом MVP/v1.0 | +| **Security** | auth, IDOR, upload | pytest + ZAP | Перед релизом (§14.14) | + +### 15.2. Пороги покрытия (coverage gates) + +Измерение: **line coverage** (Vitest v8 / pytest-cov). CI падает при падении ниже порога. + +| Область | Минимум | Примечание | +|---------|---------|------------| +| `apps/api/app/modules/*/service.py` | **≥ 95%** | Бизнес-логика backend | +| `apps/api/app/modules/*/repository.py` | **≥ 90%** | SQL, edge cases | +| `apps/api/app/core/security.py` | **100%** | JWT, hash, lockout | +| `apps/web/src/modules/*/hooks`, `store`, `api` | **≥ 90%** | Логика frontend | +| `apps/web/src/shared/lib`, `shared/api` | **≥ 90%** | Общие утилиты | +| `apps/web/src/modules/*/components` | **≥ 80%** | UI; допускается исключать pure layout | +| **Overall backend** `app/` | **≥ 90%** | `--cov-fail-under=90` | +| **Overall frontend** `src/` | **≥ 85%** | `--coverage.thresholds.lines=85` | + +**Diff coverage (рекомендуется):** новые/changed строки в PR — **≥ 95%** (Codecov / diff-cover). + +**Критические модули** (`auth`, `admin`, `core/security`) — **100% branch coverage** для функций авторизации и token rotation. + +### 15.3. Структура E2E (Playwright) + +``` +apps/web/e2e/ +├── fixtures/ +│ ├── auth.fixture.ts # login helpers, test users +│ └── api.fixture.ts # seed via API +├── landing/ +│ └── landing.spec.ts +├── auth/ +│ ├── register.spec.ts +│ ├── login.spec.ts +│ └── password-reset.spec.ts +├── profile/ +│ └── profile.spec.ts +├── content/ +│ └── content-pages.spec.ts +├── admin/ +│ └── admin-users.spec.ts +└── playwright.config.ts +``` + +**Конфигурация:** +- Бrowsers: Chromium (CI), + Firefox/WebKit локально. +- Retry: 1 в CI только для infra flakes; повторный flake → bug. +- Trace/video: on-first-retry. +- Параллельность: по файлам, isolated test users. +- E2E против **staging** в CI post-deploy; PR — против docker-compose stack. + +### 15.4. Матрица тестов по модулям (MVP) + +Каждая строка — **минимальный обязательный набор** перед merge задачи. + +| Модуль | Unit / Integration (логика) | E2E (Playwright) | +|--------|----------------------------|------------------| +| **landing** | Hero/Marquee render, breakpoints, reduced-motion | `/` загрузка, marquee visible, a11y snapshot | +| **auth** | register, login, refresh rotation, reuse detection, lockout, anti-enumeration, verify-email | register → verify → login; wrong password; pending/blocked redirect | +| **profile** | PATCH whitelist, avatar validation (MIME, size), IDOR | edit name, upload avatar, change password | +| **content** | slug unique, draft vs published, HTML sanitize | public page by slug; admin publish → visible | +| **admin** | last-admin rule, self-block forbidden, RBAC | admin list users, block user, blocked cannot login | +| **shared/ui** | Button, Input, Form validation states | используются в module E2E | + +### 15.5. Тесты на каждом шаге реализации (workflow) + +```mermaid +flowchart TD + Task[Задача из §16] --> Impl[Реализация] + Impl --> Unit[Unit tests] + Unit --> Int[Integration tests] + Int --> Comp[Component tests если UI] + Comp --> E2E[E2E spec для flow] + E2E --> CI[CI green] + CI --> DoD[Definition of Done §15.6] + DoD --> Merge[Merge allowed] +``` + +**Порядок TDD (рекомендуется, не optional для auth/security):** +1. Написать failing test (unit или e2e). +2. Реализовать минимальный код. +3. Довести coverage до порога. +4. Открыть PR — CI должен быть green. + +**Запрещено:** +- Откладывать тесты «на потом» / отдельным PR. +- Merge с `@pytest.mark.skip` / `test.todo` / `it.skip` без linked issue и срока. +- Mock всего подряд в integration — DB/Redis должны быть real (testcontainers или docker-compose). + +### 15.6. Definition of Done (модуль / задача) + +- [ ] TypeScript strict / mypy — 0 errors +- [ ] **Unit tests** для всей новой/изменённой бизнес-логики +- [ ] **Integration tests** для новых/изменённых API endpoints +- [ ] **Component tests** для новых/изменённых React-компонентов +- [ ] **E2E spec** для затронутых user flows (§15.4) +- [ ] Coverage ≥ порогов §15.2 (CI enforced) +- [ ] API documented in OpenAPI +- [ ] ESLint boundaries pass +- [ ] Security tests пройдены (если модуль auth/admin/media) +- [ ] Code review approved +- [ ] 0 flaky E2E за 3 прогона CI + +### 15.7. Critical E2E scenarios (регрессия релиза) + +Полный прогон перед каждым релизом MVP/v1.0: + +1. Landing: LCP < 2.5s, hero + marquee, `prefers-reduced-motion`. +2. Register → verify email → login → profile edit → change password → logout. +3. Forgot password → reset → login with new password. +4. Admin: create content → publish → visible on `/pages/:slug`. +5. Admin: block user → blocked user gets 403 on login. +6. Pending user cannot access `/profile`. +7. Refresh rotation: old refresh token rejected; reuse revokes family. +8. IDOR: user A cannot GET/PATCH user B profile. +9. Admin cannot demote/block self; last admin protected. +10. Avatar: reject .svg, oversize, wrong MIME. + +### 15.8. Test data и изоляция + +| Аспект | Правило | +|--------|---------| +| Test DB | Отдельная `compton_test`; транзакционный rollback per test | +| Seed users | factories.py: `user`, `admin`, `pending_user`, `blocked_user` | +| E2E users | Создаются через API fixture перед spec, cleanup after | +| Secrets in tests | Только `test-*` keys из `.env.test` | +| Parallel | Изolated data via UUID suffix emails | + +### 15.9. Команды (локально) + +```bash +# Frontend +pnpm --filter web test # vitest watch +pnpm --filter web test:ci # vitest run --coverage +pnpm --filter web e2e # playwright test +pnpm --filter web e2e:ui # playwright --ui + +# Backend +cd apps/api && pytest # all +pytest tests/modules/auth -v # module +pytest --cov=app --cov-report=term-missing --cov-fail-under=90 + +# Full stack (pre-push) +docker compose -f docker-compose.test.yml up -d +pnpm test:ci && pytest && playwright test +``` + +--- + +## 16. Этапы разработки + +### Фаза 0 — Подготовка (1–2 недели) + +| # | Задача | Результат | Тесты (обязательно) | +|---|--------|-----------|---------------------| +| 0.1 | Monorepo: Vite, FastAPI skeleton | `apps/web`, `apps/api` | smoke: `vitest` 1 test, `pytest` health 1 test | +| 0.2 | Docker Compose + `docker-compose.test.yml` | dev + test stack | CI job: lint + smoke green | +| 0.3 | Shared UI kit + design tokens | Button, Input, Layout | component tests ≥80%; Storybook optional | +| 0.4 | ESLint boundaries, OpenAPI codegen, coverage gates | Quality gates в CI | CI блокирует merge без coverage config | +| 0.5 | Playwright + pytest fixtures | `e2e/`, `conftest.py`, factories | E2E smoke: landing page loads | + +### Фаза 1 — MVP (4–6 недель) + +> Каждая задача завершается **своим** набором unit + integration + E2E. Задача 1.6 — финальный регресс, не «первые E2E». + +| # | Задача | Модули | Тесты (обязательно в том же PR) | +|---|--------|--------|----------------------------------| +| 1.1 | Landing (перенос заглушки) | landing | unit: breakpoints; E2E: `landing.spec.ts` | +| 1.2 | Auth + SMTP | auth | unit: service/security; integration: all auth routes; E2E: register/login/verify/reset | +| 1.2b | Security baseline | core, auth | 100% coverage `security.py`; tests: lockout, rotation, enumeration | +| 1.3 | Profile CRUD | profile | unit: validation; integration: `/users/me`; E2E: edit + avatar + password | +| 1.4 | Content pages | content | unit: sanitize; integration: CRUD; E2E: publish → public view | +| 1.5 | Admin panel | admin | unit: business rules; integration: admin routes; E2E: block user flow | +| 1.6 | Staging deploy + **полный регресс** | — | Playwright §15.7 (all 10 scenarios); coverage ≥ §15.2 | + +### Фаза 2 — v1.0 (4–6 недель) + +| # | Задача | Модули | Тесты (обязательно в том же PR) | +|---|--------|--------|----------------------------------| +| 2.1 | Celery + email queue | notifications | unit: task handlers; integration: queue + mock SMTP; E2E: notification received | +| 2.2 | Catalog + Orders | catalog, orders | unit + integration per module; E2E: browse → cart → submit order | +| 2.3 | Analytics dashboard | admin | integration: stats API; E2E: dashboard renders metrics | +| 2.4 | Performance + monitoring | infra | k6 load test §17.2; no coverage regression | + +### Фаза 3 — Scale prep (по необходимости) + +- Read replica PostgreSQL +- Celery workers +- CDN + object storage migration +- Horizontal API scaling + +--- + +## 17. Критерии приёмки + +### 17.1. MVP + +**Функциональность:** +- [ ] Landing визуально соответствует текущей заглушке (hero, marquee, цвета). +- [ ] Регистрация и вход работают; load test **50 CCU** на API. +- [ ] Профиль редактируется, аватар загружается. +- [ ] Admin создаёт/редактирует контент-страницы. + +**Производительность и безопасность:** +- [ ] API p95 < 300 ms при 50 CCU (k6). +- [ ] Lighthouse Performance ≥ 85 на mobile. +- [ ] Security checklist §14.13 — все пункты закрыты. +- [ ] OWASP ZAP baseline — 0 high/critical на staging. + +**Тестирование (обязательно):** +- [ ] Backend coverage ≥ **90%** (`app/`), auth/security ≥ **95–100%** (§15.2). +- [ ] Frontend coverage ≥ **85%** (`src/`), hooks/api ≥ **90%**. +- [ ] Все E2E сценарии §15.7 — **10/10 pass** на staging. +- [ ] 0 skipped tests без waiver. +- [ ] CI pipeline §13.3 — все gates green на `main`. +- [ ] Каждый модуль MVP имеет строки в матрице §15.4 (закрыты). + +**Документация:** +- [ ] README: как запускать `test`, `test:ci`, `e2e`. +- [ ] OpenAPI, `.env.example`. + +### 17.2. Load test сценарий (k6, API-only) + +```javascript +// 50 VU, ramp 5 min, target: api:8000 через Nginx +// 35% GET /api/v1/content/pages +// 25% POST /api/v1/auth/login (test credentials pool) +// 20% GET /api/v1/users/me (authenticated) +// 10% POST /api/v1/auth/refresh (cookie) +// 10% GET /api/v1/content/pages/{slug} +``` + +**Pass:** error rate < 1%, p95 < 300ms, 0 auth bypass. + +> Frontend (`GET /`) — отдельный Lighthouse-тест, не смешивать с API load test. + +--- + +## 18. Риски и допущения + +### 18.1. Допущения + +1. 1000 пользователей — registered, не 1000 RPS. +2. Контент преимущественно текст + изображения, без video streaming. +3. Один регион хостинга (RU/EU), latency < 100ms для целевой аудитории. +4. Команда: 1–3 fullstack-разработчика. + +### 18.2. Риски + +| Риск | Вероятность | Митигация | +|------|-------------|-----------| +| Scope creep (catalog/orders раньше MVP) | Высокая | Жёсткое следование фазам | +| SPA SEO проблемы | Средняя | Prerender landing, meta tags | +| Cross-module imports ломают архитектуру | Средняя | ESLint boundaries в CI | +| Auth flow complexity (rotation, verify) | Средняя | TDD + integration tests §15.4–15.7 | +| Flaky E2E блокируют CI | Средняя | fixtures, isolated data §15.8, trace-on-retry | +| Перегрузка monolith при росте | Низкая (до 10K) | Redis cache, horizontal scale | + +--- + +## Приложение A — Карта модулей (summary) + +```mindmap + root((Комpton)) + Frontend + app + pages + modules + landing + auth + profile + content + admin + catalog + shared + Backend + core + modules + auth + users + content + media + admin + Infra + PostgreSQL + Redis + S3 + Nginx + "CI/CD" +``` + +--- + +## Приложение B — Переменные окружения + +### Frontend (`apps/web/.env`) + +```env +VITE_API_URL=http://localhost:8000 +VITE_APP_NAME=Комpton +VITE_SENTRY_DSN= +``` + +### Backend (`apps/api/.env`) + +```env +DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/compton +REDIS_URL=redis://localhost:6379/0 +JWT_ACCESS_SECRET= # ≥ 32 bytes random, NOT commit +JWT_REFRESH_PEPPER= # отдельный секрет для hash refresh tokens +JWT_ACCESS_TTL_MIN=15 +JWT_REFRESH_TTL_DAYS=30 +ENABLE_DOCS=true # false в production +CORS_ORIGINS=http://localhost:5173 +S3_ENDPOINT=http://localhost:9000 +S3_BUCKET=compton-media +S3_ACCESS_KEY= +S3_SECRET_KEY= +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@compton.example +ADMIN_INITIAL_PASSWORD= # только first deploy, затем сменить +``` + +--- + +## Приложение C — Согласование + +| Роль | ФИО | Подпись | Дата | +|------|-----|---------|------| +| Product Owner | | | | +| Tech Lead | | | | +| Developer | | | | + +--- + +*Документ является живым артеfactом. Изменения фиксируются через PR в `docs/TZ.md` с bump версии.* diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..8cd7437 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,42 @@ +DATABASE_URL=postgresql+psycopg://user:pass@localhost:5432/compton +REDIS_URL=redis://localhost:6379/0 +JWT_ACCESS_SECRET=replace-with-32-byte-secret +JWT_REFRESH_PEPPER=replace-with-32-byte-pepper +JWT_ACCESS_TTL_MIN=15 +JWT_REFRESH_TTL_DAYS=30 +ENABLE_DOCS=true +COOKIE_SECURE=false +ENABLE_RATE_LIMIT=true +APP_ENV=development +AUTH_LOCKOUT_ATTEMPTS=5 +AUTH_LOCKOUT_MINUTES=15 +CORS_ORIGINS=["http://localhost:5173"] +TRUSTED_PROXY_IPS= +SMTP_HOST=localhost +SMTP_PORT=1025 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@compton.example +FRONTEND_URL=http://localhost:5173 +PUBLIC_BASE_URL=http://localhost:5173 +AUTH_TOKEN_TTL_HOURS=1 +EMAIL_DELIVERY_MODE=memory +S3_ENDPOINT=http://localhost:9000 +S3_ACCESS_KEY=minio +S3_SECRET_KEY=minio123 +S3_BUCKET=compton +S3_REGION=us-east-1 +STORAGE_MODE=s3 +AVATAR_MAX_BYTES=2097152 +MEDIA_URL_TTL_SECONDS=600 +LOG_LEVEL=INFO +AUDIT_RETENTION_DAYS=90 +COMPTON_SETTINGS_PATH=data/compton_settings.json +ADMIN_AUDIT_LOG_PATH=data/logs/admin-audit.jsonl +SERVER_LOG_PATH=data/logs/server.log +PASSWORD_DENYLIST_PATH=data/security/password-denylist.txt +ADMIN_INITIAL_PASSWORD=Admin1234 +DEMO_USER_PASSWORD=User1234 +DEMO_OPS_PASSWORD=OpsAdmin1234 +# E2E only, never enable in production. +ENABLE_TEST_ROUTES=false diff --git a/apps/api/.env.test b/apps/api/.env.test new file mode 100644 index 0000000..04a1725 --- /dev/null +++ b/apps/api/.env.test @@ -0,0 +1,11 @@ +DATABASE_URL=postgresql+psycopg://test:test@localhost:5433/compton_test +REDIS_URL=redis://localhost:6380/0 +JWT_ACCESS_SECRET=test-access-secret-32-bytes-minimum +JWT_REFRESH_PEPPER=test-refresh-pepper-32-bytes-min +JWT_ACCESS_TTL_MIN=15 +JWT_REFRESH_TTL_DAYS=30 +ENABLE_DOCS=true +CORS_ORIGINS=["http://localhost:5175"] +EMAIL_DELIVERY_MODE=memory +STORAGE_MODE=memory +ENABLE_TEST_ROUTES=true diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..96db24e --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +ENV PYTHONPATH=/app +COPY requirements-dev.txt . +RUN pip install --no-cache-dir -r requirements-dev.txt +COPY . . +EXPOSE 8000 +CMD ["python", "scripts/docker_entrypoint.py"] diff --git a/apps/api/alembic.ini b/apps/api/alembic.ini new file mode 100644 index 0000000..8a6ad0a --- /dev/null +++ b/apps/api/alembic.ini @@ -0,0 +1,42 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +version_path_separator = os + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[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/apps/api/app/__init__.py b/apps/api/app/__init__.py new file mode 100644 index 0000000..69d681a --- /dev/null +++ b/apps/api/app/__init__.py @@ -0,0 +1 @@ +"""Compton API application package.""" diff --git a/apps/api/app/core/app_settings.py b/apps/api/app/core/app_settings.py new file mode 100644 index 0000000..98a146f --- /dev/null +++ b/apps/api/app/core/app_settings.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from app.core.config import settings + +SETTINGS_ENV_KEYS: dict[str, str] = { + "enable_rate_limit": "ENABLE_RATE_LIMIT", + "enable_docs": "ENABLE_DOCS", + "cookie_secure": "COOKIE_SECURE", + "jwt_access_ttl_min": "JWT_ACCESS_TTL_MIN", + "auth_lockout_attempts": "AUTH_LOCKOUT_ATTEMPTS", + "auth_lockout_minutes": "AUTH_LOCKOUT_MINUTES", + "cors_origins": "CORS_ORIGINS", + "frontend_url": "FRONTEND_URL", + "public_base_url": "PUBLIC_BASE_URL", + "smtp_host": "SMTP_HOST", + "smtp_port": "SMTP_PORT", + "smtp_from": "SMTP_FROM", + "avatar_max_bytes": "AVATAR_MAX_BYTES", + "media_url_ttl_seconds": "MEDIA_URL_TTL_SECONDS", + "log_level": "LOG_LEVEL", + "audit_retention_days": "AUDIT_RETENTION_DAYS", + "jwt_refresh_ttl_days": "JWT_REFRESH_TTL_DAYS", +} + +MANAGED_KEYS = tuple(SETTINGS_ENV_KEYS.keys()) + + +def _settings_file() -> Path: + return Path(settings.compton_settings_path) + + +def get_settings_values() -> dict[str, Any]: + return {key: getattr(settings, key) for key in MANAGED_KEYS} + + +def _coerce_value(key: str, value: Any) -> Any: + current = getattr(settings, key) + if isinstance(current, bool): + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in {"1", "true", "yes", "on"} + return bool(value) + if isinstance(current, int): + return int(value) + if isinstance(current, list): + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + raise ValueError(f"INVALID_LIST_{key}") + return value + + +def env_locks() -> dict[str, bool]: + return {key: os.getenv(env_key) is not None for key, env_key in SETTINGS_ENV_KEYS.items()} + + +def apply_settings_to_app(values: dict[str, Any]) -> None: + for key, value in values.items(): + if key not in MANAGED_KEYS: + continue + setattr(settings, key, _coerce_value(key, value)) + + +def read_settings() -> dict[str, Any]: + path = _settings_file() + if not path.exists(): + return {} + with path.open("r", encoding="utf-8") as file: + payload = json.load(file) + if not isinstance(payload, dict): + return {} + return {key: payload[key] for key in MANAGED_KEYS if key in payload} + + +def write_settings(partial: dict[str, Any]) -> dict[str, Any]: + locks = env_locks() + current = get_settings_values() + for key, value in partial.items(): + if key not in MANAGED_KEYS: + continue + if locks[key]: + continue + current[key] = _coerce_value(key, value) + + path = _settings_file() + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as file: + json.dump(current, file, ensure_ascii=False, indent=2) + return current + + +def bootstrap_settings() -> None: + apply_settings_to_app(read_settings()) + + +def get_settings_payload() -> dict[str, Any]: + locks = env_locks() + values = get_settings_values() + secrets = { + "jwt_access_secret_configured": bool(settings.jwt_access_secret), + "jwt_refresh_pepper_configured": bool(settings.jwt_refresh_pepper), + "smtp_password_configured": bool(settings.smtp_password), + "s3_secret_key_configured": bool(settings.s3_secret_key), + } + return { + "values": values, + "locks": locks, + "settings_path": str(_settings_file()), + "secrets": secrets, + } diff --git a/apps/api/app/core/audit_log.py b/apps/api/app/core/audit_log.py new file mode 100644 index 0000000..ebe9955 --- /dev/null +++ b/apps/api/app/core/audit_log.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from app.core.config import settings + + +def _audit_path() -> Path: + return Path(settings.admin_audit_log_path) + + +def write_audit_event( + action: str, + actor_user_id: str, + actor_email: str, + details: dict[str, Any] | None = None, +) -> None: + payload = { + "timestamp": datetime.now(UTC).isoformat(), + "action": action, + "actor_user_id": actor_user_id, + "actor_email": actor_email, + "details": details or {}, + } + path = _audit_path() + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as file: + file.write(json.dumps(payload, ensure_ascii=False)) + file.write("\n") + + +def read_audit_events(limit: int = 200) -> list[dict[str, Any]]: + path = _audit_path() + if not path.exists(): + return [] + lines = path.read_text(encoding="utf-8").splitlines() + tail = lines[-limit:] + events: list[dict[str, Any]] = [] + for line in tail: + if not line.strip(): + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + return list(reversed(events)) diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py new file mode 100644 index 0000000..9b5254f --- /dev/null +++ b/apps/api/app/core/config.py @@ -0,0 +1,54 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + +from app.core.install_secrets import load_install_secrets_to_env + +load_install_secrets_to_env() + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + database_url: str = "postgresql+psycopg://user:pass@localhost:5432/compton" + redis_url: str = "redis://localhost:6379/0" + jwt_access_secret: str = "change-me-access-secret-with-at-least-32-bytes" + jwt_refresh_pepper: str = "change-me-refresh-pepper-with-at-least-32-bytes" + jwt_access_ttl_min: int = 15 + jwt_refresh_ttl_days: int = 30 + enable_docs: bool = True + cookie_secure: bool = False + cors_origins: list[str] = ["http://localhost:5173"] + enable_rate_limit: bool = True + auth_lockout_attempts: int = 5 + auth_lockout_minutes: int = 15 + admin_initial_password: str = "Admin1234" + demo_user_password: str = "User1234" + demo_ops_password: str = "OpsAdmin1234" + smtp_host: str = "localhost" + smtp_port: int = 1025 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "noreply@compton.example" + frontend_url: str = "http://localhost:5173" + public_base_url: str = "http://localhost:5173" + auth_token_ttl_hours: int = 1 + email_delivery_mode: str = "smtp" + s3_endpoint: str = "http://localhost:9000" + s3_access_key: str = "minio" + s3_secret_key: str = "minio123" + s3_bucket: str = "compton" + s3_region: str = "us-east-1" + storage_mode: str = "s3" + avatar_max_bytes: int = 2 * 1024 * 1024 + media_url_ttl_seconds: int = 600 + log_level: str = "INFO" + audit_retention_days: int = 90 + password_denylist_path: str = "data/security/password-denylist.txt" + compton_settings_path: str = "data/compton_settings.json" + admin_audit_log_path: str = "data/logs/admin-audit.jsonl" + server_log_path: str = "data/logs/server.log" + enable_test_routes: bool = False + app_env: str = "development" + trusted_proxy_ips: str = "" + + +settings = Settings() diff --git a/apps/api/app/core/crypto.py b/apps/api/app/core/crypto.py new file mode 100644 index 0000000..880b77f --- /dev/null +++ b/apps/api/app/core/crypto.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import hashlib +import hmac +import secrets +import time +from datetime import UTC, datetime, timedelta +from urllib.parse import urlencode + +import bcrypt +from jose import jwt + +def generate_secret_token_urlsafe(length: int = 32) -> str: + return secrets.token_urlsafe(length) + + +def generate_secret_token_hex(length: int = 32) -> str: + return secrets.token_hex(length) + + +def generate_install_bundle() -> dict[str, str]: + postgres_user = "compton_app" + postgres_password = generate_secret_token_urlsafe(32) + postgres_db = "compton" + minio_root_user = "minio" + minio_root_password = generate_secret_token_urlsafe(32) + return { + "POSTGRES_USER": postgres_user, + "POSTGRES_PASSWORD": postgres_password, + "POSTGRES_DB": postgres_db, + "DATABASE_URL": f"postgresql+psycopg://{postgres_user}:{postgres_password}@postgres:5432/{postgres_db}", + "JWT_ACCESS_SECRET": generate_secret_token_hex(32), + "JWT_REFRESH_PEPPER": generate_secret_token_hex(32), + "S3_ACCESS_KEY": minio_root_user, + "S3_SECRET_KEY": minio_root_password, + "MINIO_ROOT_USER": minio_root_user, + "MINIO_ROOT_PASSWORD": minio_root_password, + } + + +def hash_password(raw_password: str) -> str: + return bcrypt.hashpw(raw_password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8") + + +def verify_password(raw_password: str, password_hash: str) -> bool: + return bcrypt.checkpw(raw_password.encode("utf-8"), password_hash.encode("utf-8")) + + +def create_access_token(user_id: str, role: str, is_superuser: bool = False) -> str: + from app.core.config import settings + + now = datetime.now(UTC) + payload = { + "sub": user_id, + "role": role, + "is_superuser": is_superuser, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(minutes=settings.jwt_access_ttl_min)).timestamp()), + "jti": generate_secret_token_hex(16), + } + return jwt.encode(payload, settings.jwt_access_secret, algorithm="HS256") + + +def decode_access_token(token: str) -> dict: + from app.core.config import settings + + return jwt.decode(token, settings.jwt_access_secret, algorithms=["HS256"]) + + +def generate_refresh_token() -> str: + return generate_secret_token_urlsafe(48) + + +def generate_opaque_token() -> str: + return generate_secret_token_urlsafe(32) + + +def hash_opaque_token(token: str) -> str: + return hash_refresh_token(token) + + +def hash_refresh_token(token: str) -> str: + from app.core.config import settings + + return hashlib.sha256(f"{token}:{settings.jwt_refresh_pepper}".encode("utf-8")).hexdigest() + + +def build_signed_media_url(stored_url: str | None) -> str | None: + from app.core.config import settings + + if not stored_url: + return None + if not stored_url.startswith("/api/v1/media/files/"): + return stored_url + path = stored_url.removeprefix("/api/v1/media/files/") + expires = int(time.time()) + settings.media_url_ttl_seconds + signature = _sign_media_path(path, expires) + query = urlencode({"expires": expires, "sig": signature}) + return f"/api/v1/media/files/{path}?{query}" + + +def verify_signed_media(path: str, expires: int, signature: str) -> bool: + if expires < int(time.time()): + return False + expected = _sign_media_path(path, expires) + return hmac.compare_digest(expected, signature) + + +def _sign_media_path(path: str, expires: int) -> str: + from app.core.config import settings + + payload = f"{path}:{expires}" + return hmac.new( + settings.jwt_access_secret.encode("utf-8"), + payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() diff --git a/apps/api/app/core/database.py b/apps/api/app/core/database.py new file mode 100644 index 0000000..46dd17f --- /dev/null +++ b/apps/api/app/core/database.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.config import settings + +_connect_args: dict[str, object] = {} +_engine_kwargs: dict[str, object] = { + "pool_pre_ping": True, + "future": True, + "pool_size": 5, + "max_overflow": 10, + "pool_recycle": 1800, +} + +if settings.database_url.startswith("sqlite"): + _connect_args["check_same_thread"] = False + _engine_kwargs["poolclass"] = StaticPool + _engine_kwargs.pop("pool_size", None) + _engine_kwargs.pop("max_overflow", None) + _engine_kwargs.pop("pool_recycle", None) + +engine = create_engine(settings.database_url, connect_args=_connect_args, **_engine_kwargs) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + + +@contextmanager +def session_scope() -> Generator[Session, None, None]: + session = SessionLocal() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +def get_db() -> Generator[Session, None, None]: + session = SessionLocal() + try: + yield session + finally: + session.close() diff --git a/apps/api/app/core/datetime_utils.py b/apps/api/app/core/datetime_utils.py new file mode 100644 index 0000000..edae980 --- /dev/null +++ b/apps/api/app/core/datetime_utils.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from datetime import UTC, datetime + + +def ensure_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def utc_now() -> datetime: + return datetime.now(UTC) diff --git a/apps/api/app/core/dependencies.py b/apps/api/app/core/dependencies.py new file mode 100644 index 0000000..6b868e4 --- /dev/null +++ b/apps/api/app/core/dependencies.py @@ -0,0 +1,38 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.core.security import decode_access_token +from app.modules.users.repository import get_user_by_id + +bearer = HTTPBearer(auto_error=False) + + +def get_current_user(credentials: HTTPAuthorizationCredentials | None = Depends(bearer)): + if credentials is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED") + try: + payload = decode_access_token(credentials.credentials) + except Exception as exc: # pragma: no cover - defensive + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc + user = get_user_by_id(payload["sub"]) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED") + if user.status == "pending": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED") + if user.status == "blocked": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED") + return user + + +def require_admin(user=Depends(get_current_user)): + if user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ADMIN_ONLY") + return user + + +def require_superuser(user=Depends(get_current_user)): + if user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ADMIN_ONLY") + if not user.is_superuser: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPERUSER_ONLY") + return user diff --git a/apps/api/app/core/email.py b/apps/api/app/core/email.py new file mode 100644 index 0000000..cb9a136 --- /dev/null +++ b/apps/api/app/core/email.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import smtplib +from dataclasses import dataclass +from email.message import EmailMessage + +from app.core.config import settings + + +@dataclass +class SentEmail: + to: str + subject: str + body: str + template: str + + +class MemoryMailer: + def __init__(self) -> None: + self.sent: list[SentEmail] = [] + + def send(self, to: str, subject: str, body: str, template: str) -> None: + self.sent.append(SentEmail(to=to, subject=subject, body=body, template=template)) + + def clear(self) -> None: + self.sent.clear() + + def latest_token(self, recipient: str, template: str) -> str | None: + for message in reversed(self.sent): + if message.to == recipient and message.template == template: + for line in message.body.splitlines(): + if line.startswith("TOKEN:"): + return line.split(":", 1)[1].strip() + return None + + +class SmtpMailer: + def send(self, to: str, subject: str, body: str, template: str) -> None: + _ = template + message = EmailMessage() + message["From"] = settings.smtp_from + message["To"] = to + message["Subject"] = subject + message.set_content(body) + with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp: + if settings.smtp_user: + smtp.login(settings.smtp_user, settings.smtp_password) + smtp.send_message(message) + + +memory_mailer = MemoryMailer() + + +def get_mailer(): + if settings.email_delivery_mode == "memory": + return memory_mailer + return SmtpMailer() + + +def send_template_email(to: str, template: str, subject: str, body: str) -> None: + get_mailer().send(to=to, subject=subject, body=body, template=template) diff --git a/apps/api/app/core/exceptions.py b/apps/api/app/core/exceptions.py new file mode 100644 index 0000000..0f15a8c --- /dev/null +++ b/apps/api/app/core/exceptions.py @@ -0,0 +1,2 @@ +class DomainError(Exception): + """Base domain error.""" diff --git a/apps/api/app/core/install_secrets.py b/apps/api/app/core/install_secrets.py new file mode 100644 index 0000000..bccbf32 --- /dev/null +++ b/apps/api/app/core/install_secrets.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from urllib.parse import ParseResult, urlparse, urlunparse +from uuid import uuid4 + +from app.core.crypto import generate_install_bundle + +INSTALL_SECRETS_DIR = Path("data/secrets") +INSTALL_SECRETS_FILE = INSTALL_SECRETS_DIR / "install.env" +INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json" +REQUIRED_KEYS = ( + "POSTGRES_USER", + "POSTGRES_PASSWORD", + "POSTGRES_DB", + "DATABASE_URL", + "JWT_ACCESS_SECRET", + "JWT_REFRESH_PEPPER", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "MINIO_ROOT_USER", + "MINIO_ROOT_PASSWORD", +) + + +@dataclass +class InstallSecretsStatus: + initialized: bool + locked: bool + path: str + created: bool + + +def _parse_env_text(raw: str) -> dict[str, str]: + values: dict[str, str] = {} + for line in raw.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + return values + + +def _render_env(values: dict[str, str]) -> str: + ordered = [f"{key}={values[key]}" for key in sorted(values.keys())] + return "\n".join(ordered) + "\n" + + +def read_install_secrets() -> dict[str, str]: + if not INSTALL_SECRETS_FILE.exists(): + return {} + return _parse_env_text(INSTALL_SECRETS_FILE.read_text(encoding="utf-8")) + + +def _write_install_secrets(values: dict[str, str]) -> None: + INSTALL_SECRETS_DIR.mkdir(parents=True, exist_ok=True) + INSTALL_SECRETS_FILE.write_text(_render_env(values), encoding="utf-8") + + +def _write_meta() -> None: + payload = ( + "{\n" + f' "install_id": "{uuid4()}",\n' + f' "locked_at": "{datetime.now(UTC).isoformat()}"\n' + "}\n" + ) + INSTALL_SECRETS_META_FILE.write_text(payload, encoding="utf-8") + + +def _adopt_from_environment() -> dict[str, str]: + env_values = {key: os.getenv(key, "") for key in REQUIRED_KEYS} + db_url = os.getenv("DATABASE_URL", "") + if db_url: + parsed = urlparse(db_url) + if parsed.username: + env_values["POSTGRES_USER"] = parsed.username + if parsed.password: + env_values["POSTGRES_PASSWORD"] = parsed.password + if parsed.path and parsed.path != "/": + env_values["POSTGRES_DB"] = parsed.path.lstrip("/") + return {key: value for key, value in env_values.items() if value} + + +def _sync_minio_s3_secrets(values: dict[str, str]) -> dict[str, str]: + """MinIO root credentials are the S3 access key pair — keep them aligned.""" + if ( + values.get("S3_ACCESS_KEY") == values.get("MINIO_ROOT_USER") + and values.get("MINIO_ROOT_PASSWORD") + and values.get("S3_SECRET_KEY") != values["MINIO_ROOT_PASSWORD"] + ): + values = dict(values) + values["S3_SECRET_KEY"] = values["MINIO_ROOT_PASSWORD"] + return values + + +def load_install_secrets_to_env() -> None: + values = _sync_minio_s3_secrets(read_install_secrets()) + for key, value in values.items(): + os.environ[key] = value + + +def ensure_install_secrets() -> InstallSecretsStatus: + existing = read_install_secrets() + if existing: + synced = _sync_minio_s3_secrets(existing) + if synced != existing: + synced["SECRETS_LOCKED"] = existing.get("SECRETS_LOCKED", "true") + _write_install_secrets(synced) + existing = synced + load_install_secrets_to_env() + return InstallSecretsStatus(True, existing.get("SECRETS_LOCKED", "false") == "true", str(INSTALL_SECRETS_FILE), False) + + adopted = _adopt_from_environment() + generated = generate_install_bundle() + values = generated | adopted + values["SECRETS_LOCKED"] = "true" + _write_install_secrets(values) + _write_meta() + load_install_secrets_to_env() + return InstallSecretsStatus(True, True, str(INSTALL_SECRETS_FILE), True) + + +def masked_database_url(database_url: str) -> str: + parsed = urlparse(database_url) + if not parsed.username: + return database_url + password = "***" if parsed.password else "" + credentials = f"{parsed.username}:{password}" if password else parsed.username + host = parsed.hostname or "" + if parsed.port: + host = f"{host}:{parsed.port}" + netloc = f"{credentials}@{host}" + sanitized = ParseResult( + scheme=parsed.scheme, + netloc=netloc, + path=parsed.path, + params=parsed.params, + query=parsed.query, + fragment=parsed.fragment, + ) + return urlunparse(sanitized) + + +def install_secrets_payload() -> dict: + values = read_install_secrets() + db = urlparse(values.get("DATABASE_URL", "")) + return { + "initialized": bool(values), + "locked": values.get("SECRETS_LOCKED") == "true", + "secrets_path": str(INSTALL_SECRETS_FILE), + "database": { + "host": db.hostname, + "port": db.port, + "database": db.path.lstrip("/") if db.path else "", + "user": db.username, + "password_configured": bool(values.get("POSTGRES_PASSWORD")), + }, + "connection_string_masked": masked_database_url(values.get("DATABASE_URL", "")), + "secrets_status": { + "jwt_access_secret": "configured" if bool(values.get("JWT_ACCESS_SECRET")) else "missing", + "jwt_refresh_pepper": "configured" if bool(values.get("JWT_REFRESH_PEPPER")) else "missing", + "postgres_password": "configured" if bool(values.get("POSTGRES_PASSWORD")) else "missing", + "s3_secret_key": "configured" if bool(values.get("S3_SECRET_KEY")) else "missing", + "password_bcrypt_salt": "per_user_in_db", + }, + } + + +def reveal_install_secret(key: str) -> str: + mapping = { + "database_password": "POSTGRES_PASSWORD", + "jwt_access_secret": "JWT_ACCESS_SECRET", + "jwt_refresh_pepper": "JWT_REFRESH_PEPPER", + "s3_secret_key": "S3_SECRET_KEY", + } + env_key = mapping.get(key) + if not env_key: + raise ValueError("UNSUPPORTED_SECRET_KEY") + values = read_install_secrets() + if env_key in values: + return values[env_key] + if env_key == "POSTGRES_PASSWORD": + database_url = values.get("DATABASE_URL") or os.getenv("DATABASE_URL", "") + parsed = urlparse(database_url) + return parsed.password or "" + return os.getenv(env_key, "") + diff --git a/apps/api/app/core/media_signing.py b/apps/api/app/core/media_signing.py new file mode 100644 index 0000000..abdadf7 --- /dev/null +++ b/apps/api/app/core/media_signing.py @@ -0,0 +1,3 @@ +from app.core.crypto import build_signed_media_url, verify_signed_media + +__all__ = ["build_signed_media_url", "verify_signed_media"] diff --git a/apps/api/app/core/password_denylist.py b/apps/api/app/core/password_denylist.py new file mode 100644 index 0000000..aeb0995 --- /dev/null +++ b/apps/api/app/core/password_denylist.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from pathlib import Path + +from app.core.config import settings + +COMMON_PASSWORDS = frozenset( + { + "password", + "password1", + "password123", + "12345678", + "123456789", + "qwerty123", + "admin123", + "admin1234", + "letmein1", + "welcome1", + "iloveyou1", + "sunshine1", + "football1", + "baseball1", + "monkey123", + "dragon123", + "master123", + "trustno1", + "passw0rd", + "passw0rd1", + } +) + + +def load_denylist() -> set[str]: + denylist = set(COMMON_PASSWORDS) + path = Path(settings.password_denylist_path) + if not path.exists(): + return denylist + for line in path.read_text(encoding="utf-8").splitlines(): + candidate = line.strip().lower() + if candidate and not candidate.startswith("#"): + denylist.add(candidate) + return denylist + + +def is_denied_password(password: str) -> bool: + return password.lower() in load_denylist() diff --git a/apps/api/app/core/password_policy.py b/apps/api/app/core/password_policy.py new file mode 100644 index 0000000..1b0f56e --- /dev/null +++ b/apps/api/app/core/password_policy.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import re + + +from app.core.password_denylist import is_denied_password + + +def validate_password_strength(password: str) -> str: + if len(password) < 8: + raise ValueError("Password must be at least 8 characters long") + if is_denied_password(password): + raise ValueError("Password is too common") + if not re.search(r"[A-Z]", password): + raise ValueError("Password must include at least one uppercase letter") + if not re.search(r"[a-z]", password): + raise ValueError("Password must include at least one lowercase letter") + if not re.search(r"\d", password): + raise ValueError("Password must include at least one digit") + return password diff --git a/apps/api/app/core/redis.py b/apps/api/app/core/redis.py new file mode 100644 index 0000000..647fd80 --- /dev/null +++ b/apps/api/app/core/redis.py @@ -0,0 +1,74 @@ +"""Redis-first rate limiter with in-memory fallback.""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import UTC, datetime, timedelta + +from fastapi import HTTPException, Request, status +from redis import Redis +from redis.exceptions import RedisError + +from app.core.config import settings + +_buckets: dict[str, list[datetime]] = defaultdict(list) +_redis_client: Redis | None = None + + +def get_redis_client() -> Redis | None: + global _redis_client + if _redis_client is not None: + return _redis_client + try: + _redis_client = Redis.from_url(settings.redis_url, decode_responses=True) + _redis_client.ping() + return _redis_client + except RedisError: + _redis_client = None + return None + + +def check_rate_limit(key: str, limit: int, window_seconds: int) -> None: + if not settings.enable_rate_limit: + return + redis_client = get_redis_client() + if redis_client is not None: + redis_key = f"rl:{key}" + try: + current = redis_client.incr(redis_key) + if current == 1: + redis_client.expire(redis_key, window_seconds) + if current > limit: + ttl = redis_client.ttl(redis_key) + retry_after = ttl if ttl and ttl > 0 else window_seconds + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="RATE_LIMIT_EXCEEDED", + headers={"Retry-After": str(retry_after)}, + ) + return + except RedisError: + pass + + now = datetime.now(UTC) + cutoff = now - timedelta(seconds=window_seconds) + timestamps = [moment for moment in _buckets[key] if moment > cutoff] + if len(timestamps) >= limit: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="RATE_LIMIT_EXCEEDED", + headers={"Retry-After": str(window_seconds)}, + ) + timestamps.append(now) + _buckets[key] = timestamps + + +def client_ip(request: Request) -> str: + trusted_proxy_ips = {item.strip() for item in settings.trusted_proxy_ips.split(",") if item.strip()} + forwarded = request.headers.get("X-Forwarded-For") + request_ip = request.client.host if request.client else "" + if forwarded and request_ip in trusted_proxy_ips: + return forwarded.split(",")[0].strip() + if request.client: + return request_ip + return "unknown" diff --git a/apps/api/app/core/security.py b/apps/api/app/core/security.py new file mode 100644 index 0000000..72d8460 --- /dev/null +++ b/apps/api/app/core/security.py @@ -0,0 +1,21 @@ +from app.core.crypto import ( + create_access_token, + decode_access_token, + generate_opaque_token, + generate_refresh_token, + hash_opaque_token, + hash_password, + hash_refresh_token, + verify_password, +) + +__all__ = [ + "create_access_token", + "decode_access_token", + "generate_opaque_token", + "generate_refresh_token", + "hash_opaque_token", + "hash_password", + "hash_refresh_token", + "verify_password", +] diff --git a/apps/api/app/core/storage.py b/apps/api/app/core/storage.py new file mode 100644 index 0000000..488f045 --- /dev/null +++ b/apps/api/app/core/storage.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from app.core.config import settings + +_s3_client = None + + +def get_s3_client(): + global _s3_client + if _s3_client is None: + import boto3 + + _s3_client = boto3.client( + "s3", + endpoint_url=settings.s3_endpoint, + aws_access_key_id=settings.s3_access_key, + aws_secret_access_key=settings.s3_secret_key, + region_name=settings.s3_region, + ) + return _s3_client + + +def ensure_bucket() -> None: + if settings.storage_mode != "s3": + return + client = get_s3_client() + bucket = settings.s3_bucket + try: + client.head_bucket(Bucket=bucket) + except Exception: + client.create_bucket(Bucket=bucket) + + +def upload_object(key: str, body: bytes, content_type: str) -> None: + if settings.storage_mode == "memory": + memory_store[key] = (body, content_type) + return + client = get_s3_client() + ensure_bucket() + client.put_object( + Bucket=settings.s3_bucket, + Key=key, + Body=body, + ContentType=content_type, + ) + + +def download_object(key: str) -> tuple[bytes, str] | None: + if settings.storage_mode == "memory": + return memory_store.get(key) + client = get_s3_client() + try: + response = client.get_object(Bucket=settings.s3_bucket, Key=key) + body = response["Body"].read() + content_type = response.get("ContentType", "application/octet-stream") + return body, content_type + except Exception: + return None + + +memory_store: dict[str, tuple[bytes, str]] = {} diff --git a/apps/api/app/db/__init__.py b/apps/api/app/db/__init__.py new file mode 100644 index 0000000..5367486 --- /dev/null +++ b/apps/api/app/db/__init__.py @@ -0,0 +1,3 @@ +from app.db.base import Base + +__all__ = ["Base"] diff --git a/apps/api/app/db/base.py b/apps/api/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/apps/api/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/apps/api/app/db/models.py b/apps/api/app/db/models.py new file mode 100644 index 0000000..55449eb --- /dev/null +++ b/apps/api/app/db/models.py @@ -0,0 +1,12 @@ +from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken +from app.modules.content.models import ContentPage +from app.modules.users.models import User, UserProfile + +__all__ = [ + "User", + "UserProfile", + "RefreshToken", + "PasswordResetToken", + "EmailVerificationToken", + "ContentPage", +] diff --git a/apps/api/app/db/seed.py b/apps/api/app/db/seed.py new file mode 100644 index 0000000..2750b0f --- /dev/null +++ b/apps/api/app/db/seed.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from app.core.config import settings +from app.core.security import hash_password +from app.modules.content.repository import create_page, get_page_by_slug +from app.modules.users.repository import create_user, get_user_by_email + + +def _ensure_user(email: str, password: str, role: str, is_superuser: bool, status: str): + from app.modules.users import repository + + user = get_user_by_email(email) + if user: + changed = False + if user.role != role: + user.role = role + changed = True + if user.is_superuser != is_superuser: + user.is_superuser = is_superuser + changed = True + if user.status != status: + user.status = status + changed = True + if user.email_verified_at is None and status == "active": + user.email_verified_at = datetime.now(UTC) + changed = True + if changed: + repository.update_user(user) + return user + + user = create_user( + email=email, + password_hash=hash_password(password), + role=role, + is_superuser=is_superuser, + status=status, + ) + user.email_verified_at = datetime.now(UTC) + repository.update_user(user) + return user + + +def run_seed(include_demo_pages: bool = True) -> None: + admin = _ensure_user( + email="admin@compton.example", + password=settings.admin_initial_password, + role="admin", + is_superuser=True, + status="active", + ) + _ensure_user( + email="user@compton.example", + password=settings.demo_user_password, + role="user", + is_superuser=False, + status="active", + ) + _ensure_user( + email="ops@compton.example", + password=settings.demo_ops_password, + role="admin", + is_superuser=False, + status="active", + ) + + if not include_demo_pages: + return + + demo_pages = [ + { + "slug": "about", + "title": "О бренде", + "body": "

Compton — платформа Organic Tech.

", + }, + { + "slug": "privacy", + "title": "Политика конфиденциальности", + "body": "

Мы обрабатываем персональные данные согласно политике.

", + }, + { + "slug": "terms", + "title": "Условия использования", + "body": "

Используя сервис, вы принимаете условия.

", + }, + ] + + for page in demo_pages: + if get_page_by_slug(page["slug"], include_draft=True): + continue + create_page( + slug=page["slug"], + title=page["title"], + body=page["body"], + status="published", + author_id=admin.id, + ) diff --git a/apps/api/app/db/token_cleanup.py b/apps/api/app/db/token_cleanup.py new file mode 100644 index 0000000..b1ddc60 --- /dev/null +++ b/apps/api/app/db/token_cleanup.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import delete, or_ + +from app.core.database import session_scope +from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken + + +def cleanup_expired_tokens(retention_days: int = 30) -> dict[str, int]: + now = datetime.now(UTC) + revoked_cutoff = now - timedelta(days=retention_days) + with session_scope() as db: + refresh_deleted = db.execute( + delete(RefreshToken).where( + or_( + RefreshToken.expires_at < now, + RefreshToken.revoked_at.is_not(None) & (RefreshToken.revoked_at < revoked_cutoff), + ) + ) + ).rowcount or 0 + reset_deleted = db.execute( + delete(PasswordResetToken).where( + or_( + PasswordResetToken.expires_at < now, + PasswordResetToken.used_at.is_not(None) & (PasswordResetToken.used_at < now), + ) + ) + ).rowcount or 0 + verify_deleted = db.execute( + delete(EmailVerificationToken).where( + or_( + EmailVerificationToken.expires_at < now, + EmailVerificationToken.used_at.is_not(None) & (EmailVerificationToken.used_at < now), + ) + ) + ).rowcount or 0 + return { + "refresh_tokens_deleted": int(refresh_deleted), + "password_reset_tokens_deleted": int(reset_deleted), + "email_verification_tokens_deleted": int(verify_deleted), + } diff --git a/apps/api/app/main.py b/apps/api/app/main.py new file mode 100644 index 0000000..535dc61 --- /dev/null +++ b/apps/api/app/main.py @@ -0,0 +1,90 @@ +from contextlib import asynccontextmanager +from urllib.parse import urlparse, parse_qs + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.install_secrets import ensure_install_secrets +from app.core.config import settings +from app.core.app_settings import bootstrap_settings +from app.core.storage import ensure_bucket +from app.core import database as db_module +from app.db.token_cleanup import cleanup_expired_tokens +from app.db.base import Base +from app.db import models as _models # noqa: F401 +from app.db.seed import run_seed +from app.modules.auth.router import router as auth_router +from app.modules.users.router import router as users_router +from app.modules.content.router import router as content_router +from app.modules.admin.router import router as admin_router +from app.modules.media.router import router as media_router +from app.modules.test.router import router as test_router + + +def _assert_production_guards() -> None: + if settings.app_env.lower() != "production": + return + if settings.enable_test_routes: + raise RuntimeError("ENABLE_TEST_ROUTES must be false in production") + if settings.enable_docs: + raise RuntimeError("ENABLE_DOCS must be false in production") + if not settings.enable_rate_limit: + raise RuntimeError("ENABLE_RATE_LIMIT must be true in production") + if not settings.cookie_secure: + raise RuntimeError("COOKIE_SECURE must be true in production") + if settings.jwt_access_secret.startswith("change-me-"): + raise RuntimeError("JWT_ACCESS_SECRET placeholder is not allowed in production") + if settings.jwt_refresh_pepper.startswith("change-me-"): + raise RuntimeError("JWT_REFRESH_PEPPER placeholder is not allowed in production") + parsed = urlparse(settings.database_url) + if parsed.username == "user" and parsed.password == "pass": + raise RuntimeError("Default database credentials are not allowed in production") + if parsed.scheme.startswith("postgresql"): + sslmode = parse_qs(parsed.query).get("sslmode", [""])[0] + if sslmode != "require": + raise RuntimeError("DATABASE_URL must contain sslmode=require in production") + + +def create_app() -> FastAPI: + @asynccontextmanager + async def lifespan(_: FastAPI): + ensure_install_secrets() + bootstrap_settings() + _assert_production_guards() + if settings.enable_test_routes: + Base.metadata.create_all(db_module.engine) + run_seed() + cleanup_expired_tokens() + ensure_bucket() + yield + + app = FastAPI( + title="Compton API", + version="1.0.0", + docs_url="/api/v1/docs" if settings.enable_docs else None, + openapi_url="/api/v1/openapi.json" if settings.enable_docs else None, + lifespan=lifespan, + ) + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "X-Request-ID"], + ) + + @app.get("/api/v1/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth"]) + app.include_router(users_router, prefix="/api/v1/users", tags=["users"]) + app.include_router(content_router, prefix="/api/v1/content", tags=["content"]) + app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"]) + app.include_router(media_router, prefix="/api/v1/media", tags=["media"]) + if settings.enable_test_routes: + app.include_router(test_router, prefix="/api/v1/test", tags=["test"]) + return app + + +app = create_app() diff --git a/apps/api/app/modules/__init__.py b/apps/api/app/modules/__init__.py new file mode 100644 index 0000000..51f2905 --- /dev/null +++ b/apps/api/app/modules/__init__.py @@ -0,0 +1 @@ +"""Application modules.""" diff --git a/apps/api/app/modules/admin/__init__.py b/apps/api/app/modules/admin/__init__.py new file mode 100644 index 0000000..ace7d15 --- /dev/null +++ b/apps/api/app/modules/admin/__init__.py @@ -0,0 +1 @@ +"""Admin module.""" diff --git a/apps/api/app/modules/admin/router.py b/apps/api/app/modules/admin/router.py new file mode 100644 index 0000000..4b1d47b --- /dev/null +++ b/apps/api/app/modules/admin/router.py @@ -0,0 +1,142 @@ +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.core.dependencies import require_admin, require_superuser +from app.modules.admin.schemas import ( + AdminSettingsPatchIn, + AdminRevealSecretIn, + AdminUiActivityIn, + AdminUserCreateIn, + AdminUserPasswordPatchIn, + AdminUserPatchIn, +) +from app.modules.admin.service import ( + create_admin_user, + delete_admin_user, + get_admin_settings, + get_admin_summary, + get_install_secrets, + get_diagnostics_report, + get_server_log_tail, + list_activity_feed, + list_admin_users, + patch_admin_settings, + patch_user, + record_ui_activity, + reveal_secret, + reset_user_password, +) +from app.modules.analytics.service import get_admin_dashboard_metrics + +router = APIRouter() + + +@router.get("/users") +async def list_users_route( + page: int = Query(default=1, ge=1), + limit: int = Query(default=20, ge=1, le=100), + _admin=Depends(require_admin), +): + return list_admin_users(page, limit) + + +@router.patch("/users/{user_id}") +async def patch_user_route( + user_id: str, + payload: AdminUserPatchIn, + admin=Depends(require_admin), +): + if payload.is_superuser is not None and not admin.is_superuser: + raise HTTPException(status_code=403, detail="SUPERUSER_ONLY") + try: + return patch_user(admin, user_id, payload.role, payload.status, payload.is_superuser) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.post("/users") +async def create_user_route(payload: AdminUserCreateIn, admin=Depends(require_superuser)): + try: + return create_admin_user( + admin, + email=payload.email, + password=payload.password, + role=payload.role, + is_superuser=payload.is_superuser, + status=payload.status, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.patch("/users/{user_id}/password") +async def reset_user_password_route( + user_id: str, payload: AdminUserPasswordPatchIn, admin=Depends(require_superuser) +): + try: + return reset_user_password(admin, user_id, payload.password) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.delete("/users/{user_id}") +async def delete_user_route(user_id: str, admin=Depends(require_superuser)): + try: + return delete_admin_user(admin, user_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.get("/summary") +async def summary_route(_admin=Depends(require_admin)): + return get_admin_summary() + + +@router.get("/stats") +async def stats_route(_admin=Depends(require_admin)): + return get_admin_dashboard_metrics() + + +@router.get("/settings") +async def admin_settings_route(_admin=Depends(require_superuser)): + return get_admin_settings() + + +@router.patch("/settings") +async def admin_settings_patch_route(payload: AdminSettingsPatchIn, admin=Depends(require_superuser)): + return patch_admin_settings(admin, payload.values) + + +@router.get("/diagnostics/report") +async def diagnostics_route(_admin=Depends(require_superuser)): + return get_diagnostics_report() + + +@router.get("/activity-feed") +async def activity_feed_route(limit: int = Query(default=200, ge=1, le=1000), _admin=Depends(require_admin)): + return list_activity_feed(limit=limit) + + +@router.post("/ui-activity") +async def ui_activity_route(payload: AdminUiActivityIn, admin=Depends(require_admin)): + return record_ui_activity(admin, payload.event, payload.meta) + + +@router.get("/server-log") +async def server_log_route( + lines: int = Query(default=200, ge=1, le=1000), + _admin=Depends(require_superuser), +): + return get_server_log_tail(lines=lines) + + +@router.get("/secrets") +async def install_secrets_route(_admin=Depends(require_superuser)): + return get_install_secrets() + + +@router.post("/secrets/reveal") +async def reveal_secret_route(payload: AdminRevealSecretIn, admin=Depends(require_superuser)): + try: + return reveal_secret(admin, payload.key) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) diff --git a/apps/api/app/modules/admin/schemas.py b/apps/api/app/modules/admin/schemas.py new file mode 100644 index 0000000..8620a8a --- /dev/null +++ b/apps/api/app/modules/admin/schemas.py @@ -0,0 +1,46 @@ +from typing import Literal + +from pydantic import BaseModel, EmailStr, Field, field_validator + +from app.core.password_policy import validate_password_strength + + +class AdminUserPatchIn(BaseModel): + role: Literal["user", "admin"] | None = None + status: Literal["pending", "active", "blocked"] | None = None + is_superuser: bool | None = None + + +class AdminUserCreateIn(BaseModel): + email: EmailStr + password: str = Field(min_length=8) + role: Literal["user", "admin"] = "user" + is_superuser: bool = False + status: Literal["pending", "active", "blocked"] = "active" + + @field_validator("password") + @classmethod + def password_policy(cls, value: str) -> str: + return validate_password_strength(value) + + +class AdminUserPasswordPatchIn(BaseModel): + password: str = Field(min_length=8) + + @field_validator("password") + @classmethod + def password_policy(cls, value: str) -> str: + return validate_password_strength(value) + + +class AdminSettingsPatchIn(BaseModel): + values: dict + + +class AdminUiActivityIn(BaseModel): + event: str + meta: dict | None = None + + +class AdminRevealSecretIn(BaseModel): + key: Literal["database_password", "jwt_access_secret", "jwt_refresh_pepper", "s3_secret_key"] diff --git a/apps/api/app/modules/admin/security_diagnostics.py b/apps/api/app/modules/admin/security_diagnostics.py new file mode 100644 index 0000000..8243a3f --- /dev/null +++ b/apps/api/app/modules/admin/security_diagnostics.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from urllib.parse import parse_qs, urlparse + +from app.core.config import settings +from app.core.install_secrets import read_install_secrets +from app.core.password_denylist import load_denylist +from app.core.redis import get_redis_client +from app.core.database import session_scope +from app.modules.auth.models import RefreshToken +from app.modules.users import repository +from sqlalchemy import func, select + + +def build_security_diagnostics_report() -> dict: + redis_client = get_redis_client() + db = urlparse(settings.database_url) + sslmode = parse_qs(db.query).get("sslmode", [""])[0] + install_secrets = read_install_secrets() + with session_scope() as session: + refresh_count = session.scalar(select(func.count()).select_from(RefreshToken)) or 0 + checks = [ + { + "id": "jwt_access_secret", + "status": "ok" if len(settings.jwt_access_secret) >= 32 else "fail", + "message": "JWT access secret configured", + }, + { + "id": "jwt_refresh_pepper", + "status": "ok" if len(settings.jwt_refresh_pepper) >= 32 else "fail", + "message": "JWT refresh pepper configured", + }, + { + "id": "cookie_secure", + "status": "ok" if settings.cookie_secure else "warn", + "message": "Refresh cookie Secure flag is enabled", + }, + { + "id": "rate_limit_backend", + "status": "ok" if redis_client is not None else "warn", + "message": "Rate limiter uses Redis backend", + }, + { + "id": "password_denylist", + "status": "ok" if len(load_denylist()) >= 1000 else "warn", + "message": "Password denylist has strong coverage", + }, + { + "id": "cors_wildcard", + "status": "ok" if "*" not in settings.cors_origins else "fail", + "message": "CORS does not include wildcard origin", + }, + { + "id": "superuser_exists", + "status": "ok" if repository.count_superusers() > 0 else "fail", + "message": "At least one superuser exists", + }, + { + "id": "docs_production", + "status": "warn" if settings.enable_docs else "ok", + "message": "API docs are disabled in production", + }, + { + "id": "db_default_credentials", + "status": "fail" if db.username == "user" and db.password == "pass" else "ok", + "message": "Database does not use default credentials", + }, + { + "id": "db_localhost_exposed", + "status": "fail" if settings.app_env.lower() == "production" and db.hostname in {"localhost", "127.0.0.1"} else "ok", + "message": "Production database host is not localhost", + }, + { + "id": "db_ssl_mode", + "status": "ok" if settings.app_env.lower() != "production" or sslmode == "require" else "warn", + "message": "Production database URL uses sslmode=require", + }, + { + "id": "db_refresh_token_table_size", + "status": "warn" if refresh_count > 10000 else "ok", + "message": "Refresh token table size is under threshold", + }, + { + "id": "install_secrets_initialized", + "status": "ok" if bool(install_secrets) else "fail", + "message": "Install secrets file is initialized", + }, + { + "id": "install_secrets_locked", + "status": "ok" if install_secrets.get("SECRETS_LOCKED") == "true" else "warn", + "message": "Install secrets are locked after bootstrap", + }, + ] + return {"checks": checks} diff --git a/apps/api/app/modules/admin/service.py b/apps/api/app/modules/admin/service.py new file mode 100644 index 0000000..116cd84 --- /dev/null +++ b/apps/api/app/modules/admin/service.py @@ -0,0 +1,206 @@ +from pathlib import Path + +from app.core.app_settings import apply_settings_to_app, get_settings_payload, write_settings +from app.core.audit_log import read_audit_events, write_audit_event +from app.core.config import settings +from app.core.install_secrets import install_secrets_payload, reveal_install_secret +from app.core.security import hash_password +from app.modules.admin.security_diagnostics import build_security_diagnostics_report +from app.modules.auth.service import revoke_user_refresh_family +from app.modules.users import repository + + +def list_admin_users(page: int, limit: int) -> dict: + users, total = repository.list_users(page, limit) + return { + "data": [ + { + "id": user.id, + "email": user.email, + "role": user.role, + "is_superuser": user.is_superuser, + "status": user.status, + } + for user in users + ], + "meta": {"total": total, "page": page, "limit": limit}, + } + + +def _ensure_last_superuser_protection(target, role: str | None, is_superuser: bool | None) -> None: + super_count = repository.count_superusers() + role_becomes_admin = target.role if role is None else role + super_becomes_true = target.is_superuser if is_superuser is None else is_superuser + is_losing_super = target.role == "admin" and target.is_superuser and ( + role_becomes_admin != "admin" or not super_becomes_true + ) + if is_losing_super and super_count <= 1: + raise ValueError("LAST_SUPERUSER_PROTECTED") + + +def patch_user( + admin_user, target_user_id: str, role: str | None, status: str | None, is_superuser: bool | None = None +) -> dict: + target = repository.get_user_by_id(target_user_id) + if not target: + raise ValueError("USER_NOT_FOUND") + if admin_user.id == target_user_id and role and role != "admin": + raise ValueError("SELF_DEMOTION_FORBIDDEN") + if admin_user.id == target_user_id and is_superuser is False: + raise ValueError("SELF_DEMOTION_FORBIDDEN") + if admin_user.id == target_user_id and status == "blocked": + raise ValueError("SELF_BLOCK_FORBIDDEN") + + admin_count = repository.count_admins() + if target.role == "admin" and role and role != "admin" and admin_count <= 1: + raise ValueError("LAST_ADMIN_PROTECTED") + _ensure_last_superuser_protection(target, role, is_superuser) + + if role: + target.role = role + if is_superuser is not None: + target.is_superuser = bool(is_superuser) + if status: + target.status = status + if status == "blocked": + revoke_user_refresh_family(target.id) + repository.update_user(target) + write_audit_event( + action="admin.user.patch", + actor_user_id=admin_user.id, + actor_email=admin_user.email, + details={"target_user_id": target.id}, + ) + return { + "id": target.id, + "email": target.email, + "role": target.role, + "is_superuser": target.is_superuser, + "status": target.status, + } + + +def create_admin_user(admin_user, email: str, password: str, role: str, is_superuser: bool, status: str) -> dict: + if repository.get_user_by_email(email): + raise ValueError("USER_EXISTS") + user = repository.create_user( + email=email, + password_hash=hash_password(password), + role=role, + is_superuser=is_superuser, + status=status, + ) + write_audit_event( + action="admin.user.create", + actor_user_id=admin_user.id, + actor_email=admin_user.email, + details={"target_user_id": user.id}, + ) + return { + "id": user.id, + "email": user.email, + "role": user.role, + "is_superuser": user.is_superuser, + "status": user.status, + } + + +def reset_user_password(admin_user, target_user_id: str, password: str) -> dict: + target = repository.get_user_by_id(target_user_id) + if not target: + raise ValueError("USER_NOT_FOUND") + target.password_hash = hash_password(password) + repository.update_user(target) + revoke_user_refresh_family(target.id) + write_audit_event( + action="admin.user.reset_password", + actor_user_id=admin_user.id, + actor_email=admin_user.email, + details={"target_user_id": target.id}, + ) + return {"status": "ok"} + + +def delete_admin_user(admin_user, target_user_id: str) -> dict: + target = repository.get_user_by_id(target_user_id) + if not target: + raise ValueError("USER_NOT_FOUND") + if admin_user.id == target.id: + raise ValueError("SELF_DELETE_FORBIDDEN") + _ensure_last_superuser_protection(target, "user", False) + if target.role == "admin" and repository.count_admins() <= 1: + raise ValueError("LAST_ADMIN_PROTECTED") + repository.delete_user(target.id) + revoke_user_refresh_family(target.id) + write_audit_event( + action="admin.user.delete", + actor_user_id=admin_user.id, + actor_email=admin_user.email, + details={"target_user_id": target.id}, + ) + return {"status": "deleted"} + + +def get_admin_summary() -> dict: + return { + "users_count": repository.count_users(), + "registrations_day": repository.count_users_registered_today(), + "admins_count": repository.count_admins(), + "superusers_count": repository.count_superusers(), + } + + +def get_admin_settings() -> dict: + return get_settings_payload() + + +def patch_admin_settings(admin_user, values: dict) -> dict: + merged = write_settings(values) + apply_settings_to_app(merged) + write_audit_event( + action="admin.settings.patch", + actor_user_id=admin_user.id, + actor_email=admin_user.email, + details={"updated_keys": sorted(values.keys())}, + ) + return get_settings_payload() + + +def get_diagnostics_report() -> dict: + return build_security_diagnostics_report() + + +def list_activity_feed(limit: int = 200) -> dict: + return {"events": read_audit_events(limit=limit)} + + +def record_ui_activity(admin_user, event: str, meta: dict | None) -> dict: + write_audit_event( + action=f"ui.{event}", + actor_user_id=admin_user.id, + actor_email=admin_user.email, + details=meta or {}, + ) + return {"status": "ok"} + + +def get_server_log_tail(lines: int = 200) -> dict: + path = Path(settings.server_log_path) + if not path.exists(): + return {"lines": []} + return {"lines": path.read_text(encoding="utf-8", errors="ignore").splitlines()[-lines:]} + + +def get_install_secrets() -> dict: + return install_secrets_payload() + + +def reveal_secret(admin_user, key: str) -> dict: + value = reveal_install_secret(key) + write_audit_event( + action="admin.secrets.reveal", + actor_user_id=admin_user.id, + actor_email=admin_user.email, + details={"key": key}, + ) + return {"key": key, "value": value} diff --git a/apps/api/app/modules/analytics/service.py b/apps/api/app/modules/analytics/service.py new file mode 100644 index 0000000..34ab0fb --- /dev/null +++ b/apps/api/app/modules/analytics/service.py @@ -0,0 +1,8 @@ +from app.modules.users import repository + + +def get_admin_dashboard_metrics() -> dict: + return { + "users_count": repository.count_users(), + "registrations_day": repository.count_users_registered_today(), + } diff --git a/apps/api/app/modules/auth/__init__.py b/apps/api/app/modules/auth/__init__.py new file mode 100644 index 0000000..257f2c7 --- /dev/null +++ b/apps/api/app/modules/auth/__init__.py @@ -0,0 +1 @@ +"""Auth module.""" diff --git a/apps/api/app/modules/auth/models.py b/apps/api/app/modules/auth/models.py new file mode 100644 index 0000000..cf27a2d --- /dev/null +++ b/apps/api/app/modules/auth/models.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class RefreshToken(Base): + __tablename__ = "refresh_tokens" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + family_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + + +class PasswordResetToken(Base): + __tablename__ = "password_reset_tokens" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class EmailVerificationToken(Base): + __tablename__ = "email_verification_tokens" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/apps/api/app/modules/auth/repository.py b/apps/api/app/modules/auth/repository.py new file mode 100644 index 0000000..8c71990 --- /dev/null +++ b/apps/api/app/modules/auth/repository.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import delete, select + +from app.core.database import session_scope +from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken + + +def _detach(db, instance): + db.refresh(instance) + db.expunge(instance) + return instance + + +def create_refresh_token( + user_id: str, + token_hash: str, + family_id: str, + expires_at: datetime, +) -> RefreshToken: + with session_scope() as db: + token = RefreshToken( + user_id=user_id, + token_hash=token_hash, + family_id=family_id, + expires_at=expires_at, + ) + db.add(token) + db.flush() + return _detach(db, token) + + +def get_refresh_token(token_hash: str) -> RefreshToken | None: + with session_scope() as db: + token = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash)) + if not token: + return None + return _detach(db, token) + + +def revoke_refresh_token(token_hash: str) -> RefreshToken | None: + with session_scope() as db: + token = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash)) + if not token: + return None + token.revoked_at = datetime.now(UTC) + db.flush() + return _detach(db, token) + + +def revoke_user_families(user_id: str) -> None: + with session_scope() as db: + tokens = db.scalars( + select(RefreshToken).where( + RefreshToken.user_id == user_id, + RefreshToken.revoked_at.is_(None), + ) + ).all() + now = datetime.now(UTC) + for token in tokens: + token.revoked_at = now + + +def revoke_family_tokens(family_id: str) -> None: + with session_scope() as db: + tokens = db.scalars( + select(RefreshToken).where( + RefreshToken.family_id == family_id, + RefreshToken.revoked_at.is_(None), + ) + ).all() + now = datetime.now(UTC) + for token in tokens: + token.revoked_at = now + + +def create_email_verification_token(user_id: str, token_hash: str, expires_at: datetime) -> None: + with session_scope() as db: + db.execute( + delete(EmailVerificationToken).where( + EmailVerificationToken.user_id == user_id, + EmailVerificationToken.used_at.is_(None), + ) + ) + db.add(EmailVerificationToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at)) + + +def get_email_verification_token(token_hash: str) -> EmailVerificationToken | None: + with session_scope() as db: + token = db.scalar( + select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash) + ) + if not token: + return None + return _detach(db, token) + + +def mark_email_verification_token_used(token_hash: str) -> None: + with session_scope() as db: + token = db.scalar( + select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash) + ) + if token: + token.used_at = datetime.now(UTC) + + +def create_password_reset_token(user_id: str, token_hash: str, expires_at: datetime) -> None: + with session_scope() as db: + db.execute( + delete(PasswordResetToken).where( + PasswordResetToken.user_id == user_id, + PasswordResetToken.used_at.is_(None), + ) + ) + db.add(PasswordResetToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at)) + + +def get_password_reset_token(token_hash: str) -> PasswordResetToken | None: + with session_scope() as db: + token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)) + if not token: + return None + return _detach(db, token) + + +def mark_password_reset_token_used(token_hash: str) -> None: + with session_scope() as db: + token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)) + if token: + token.used_at = datetime.now(UTC) diff --git a/apps/api/app/modules/auth/router.py b/apps/api/app/modules/auth/router.py new file mode 100644 index 0000000..ef81cb6 --- /dev/null +++ b/apps/api/app/modules/auth/router.py @@ -0,0 +1,206 @@ +from app.core.datetime_utils import ensure_utc, utc_now +from fastapi import APIRouter, HTTPException, Request, Response, status + +from app.core.config import settings +from app.core.redis import check_rate_limit, client_ip +from app.modules.auth.schemas import ( + ForgotPasswordIn, + LoginIn, + LoginOut, + RegisterIn, + ResendVerificationIn, + ResetPasswordIn, + VerifyEmailIn, +) +from app.modules.auth.service import ( + forgot_password, + login, + refresh, + register, + resend_verification, + reset_password, + revoke_refresh_token, + verify_email_token, +) +from app.modules.users import repository + +router = APIRouter() + + +def _allowed_origins() -> set[str]: + origins: set[str] = set() + for raw in (settings.frontend_url, settings.public_base_url, *settings.cors_origins): + if not raw: + continue + normalized = raw.rstrip("/") + origins.add(normalized) + if "://localhost" in normalized: + origins.add(normalized.replace("://localhost", "://127.0.0.1")) + if "://127.0.0.1" in normalized: + origins.add(normalized.replace("://127.0.0.1", "://localhost")) + return origins + + +def _enforce_origin(request: Request, *, require_header: bool = False) -> None: + allowed = _allowed_origins() + origin = (request.headers.get("Origin") or "").rstrip("/") + referer = request.headers.get("Referer") or "" + if require_header and not origin and not referer: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN") + if origin and origin not in allowed: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN") + if not origin and referer: + if not any(referer.startswith(base) for base in allowed): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN") + + +def _set_refresh_cookie(response: Response, refresh_token: str) -> None: + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=settings.cookie_secure, + samesite="lax", + path="/api/v1/auth", + max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60, + ) + + +@router.post("/register") +async def register_route(payload: RegisterIn, request: Request): + _enforce_origin(request) + check_rate_limit(f"register:{client_ip(request)}", limit=3, window_seconds=3600) + user = register(payload.email, payload.password) + _ = user + return {"message": "If email is valid, verification has been sent."} + + +@router.post("/verify-email") +async def verify_email_route(payload: VerifyEmailIn): + try: + user = verify_email_token(payload.token) + except ValueError as exc: + detail = str(exc) + if detail == "TOKEN_EXPIRED": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN") + _ = user + return {"status": "active"} + + +@router.post("/login", response_model=LoginOut) +async def login_route(payload: LoginIn, request: Request, response: Response): + _enforce_origin(request) + user = repository.get_user_by_email(payload.email) + if user: + locked_until = ensure_utc(user.locked_until) + if locked_until and locked_until > utc_now(): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="ACCOUNT_TEMPORARILY_LOCKED", + ) + check_rate_limit( + f"login:{client_ip(request)}:{payload.email.lower()}", + limit=5, + window_seconds=60, + ) + try: + access_token, refresh_token, user = login(payload.email, payload.password) + except ValueError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_CREDENTIALS") + except PermissionError as exc: + detail = str(exc) + if detail == "EMAIL_NOT_VERIFIED": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED") + if detail == "ACCOUNT_TEMPORARILY_LOCKED": + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="ACCOUNT_TEMPORARILY_LOCKED") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED") + + _set_refresh_cookie(response, refresh_token) + return { + "access_token": access_token, + "expires_in": settings.jwt_access_ttl_min * 60, + "user": { + "id": user.id, + "email": user.email, + "role": user.role, + "is_superuser": user.is_superuser, + "status": user.status, + }, + } + + +@router.post("/refresh", response_model=LoginOut) +async def refresh_route(request: Request, response: Response): + refresh_token = request.cookies.get("refresh_token") + if not refresh_token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="REFRESH_MISSING") + _enforce_origin(request, require_header=True) + check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60) + try: + access_token, new_refresh, user = refresh(refresh_token) + except PermissionError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH") + _set_refresh_cookie(response, new_refresh) + return { + "access_token": access_token, + "expires_in": settings.jwt_access_ttl_min * 60, + "user": { + "id": user.id, + "email": user.email, + "role": user.role, + "is_superuser": user.is_superuser, + "status": user.status, + }, + } + + +@router.post("/logout") +async def logout_route(request: Request, response: Response): + _enforce_origin(request, require_header=True) + refresh_token = request.cookies.get("refresh_token") + if refresh_token: + revoke_refresh_token(refresh_token) + response.delete_cookie( + "refresh_token", + path="/api/v1/auth", + secure=settings.cookie_secure, + samesite="lax", + ) + return {"message": "logged_out"} + + +@router.post("/forgot-password") +async def forgot_password_route(payload: ForgotPasswordIn, request: Request): + _enforce_origin(request) + check_rate_limit( + f"forgot:{client_ip(request)}:{payload.email.lower()}", + limit=3, + window_seconds=3600, + ) + forgot_password(payload.email) + return {"message": "If email is registered, reset instructions have been sent."} + + +@router.post("/reset-password") +async def reset_password_route(payload: ResetPasswordIn): + try: + reset_password(payload.token, payload.new_password) + except ValueError as exc: + detail = str(exc) + if detail == "TOKEN_EXPIRED": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN") + return {"message": "password_updated"} + + +@router.post("/resend-verification") +async def resend_verification_route(payload: ResendVerificationIn, request: Request): + _enforce_origin(request) + check_rate_limit( + f"resend:{client_ip(request)}:{payload.email.lower()}", + limit=3, + window_seconds=3600, + ) + resend_verification(payload.email) + return {"message": "If email is registered, verification has been sent."} diff --git a/apps/api/app/modules/auth/schemas.py b/apps/api/app/modules/auth/schemas.py new file mode 100644 index 0000000..f2058f3 --- /dev/null +++ b/apps/api/app/modules/auth/schemas.py @@ -0,0 +1,69 @@ +from pydantic import BaseModel, EmailStr, Field, field_validator + +from app.core.password_policy import validate_password_strength + + +class RegisterIn(BaseModel): + email: EmailStr + password: str = Field(min_length=8) + + @field_validator("email", "password", mode="before") + @classmethod + def strip_whitespace(cls, value: str) -> str: + if isinstance(value, str): + return value.strip() + return value + + @field_validator("password") + @classmethod + def password_policy(cls, value: str) -> str: + return validate_password_strength(value) + + +class LoginIn(BaseModel): + email: EmailStr + password: str = Field(min_length=8) + + @field_validator("email", "password", mode="before") + @classmethod + def strip_whitespace(cls, value: str) -> str: + if isinstance(value, str): + return value.strip() + return value + + +class VerifyEmailIn(BaseModel): + token: str + + +class ForgotPasswordIn(BaseModel): + email: EmailStr + + +class ResendVerificationIn(BaseModel): + email: EmailStr + + +class ResetPasswordIn(BaseModel): + token: str + new_password: str = Field(min_length=8) + + @field_validator("new_password") + @classmethod + def password_policy(cls, value: str) -> str: + return validate_password_strength(value) + + +class AuthUserOut(BaseModel): + id: str + email: EmailStr + role: str + is_superuser: bool + status: str + + +class LoginOut(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int = 900 + user: AuthUserOut diff --git a/apps/api/app/modules/auth/service.py b/apps/api/app/modules/auth/service.py new file mode 100644 index 0000000..879a8df --- /dev/null +++ b/apps/api/app/modules/auth/service.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +from datetime import timedelta +from uuid import uuid4 + +from app.core.config import settings +from app.core.datetime_utils import ensure_utc, utc_now +from app.core.email import send_template_email +from app.core.security import ( + create_access_token, + generate_opaque_token, + generate_refresh_token, + hash_opaque_token, + hash_password, + hash_refresh_token, + verify_password, +) +from app.modules.auth import repository as auth_repository +from app.modules.users import repository +from app.modules.users.models import User + +def _token_expires_at(): + return utc_now() + timedelta(hours=settings.auth_token_ttl_hours) + + +def _send_verification_email(user: User, token: str) -> None: + verify_url = f"{settings.frontend_url}/verify?token={token}" + show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes + token_line = f"TOKEN:{token}\n" if show_token else "" + body = "Confirm your Compton account.\n\n" + f"Open: {verify_url}\n" + token_line + send_template_email( + to=user.email, + template="verify_email", + subject="Confirm your Compton account", + body=body, + ) + + +def _send_password_reset_email(user: User, token: str) -> None: + reset_url = f"{settings.frontend_url}/reset-password?token={token}" + show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes + token_line = f"TOKEN:{token}\n" if show_token else "" + body = "Reset your Compton password.\n\n" + f"Open: {reset_url}\n" + token_line + send_template_email( + to=user.email, + template="reset_password", + subject="Reset your Compton password", + body=body, + ) + + +def _issue_verification_token(user_id: str) -> str: + token = generate_opaque_token() + auth_repository.create_email_verification_token( + user_id=user_id, + token_hash=hash_opaque_token(token), + expires_at=_token_expires_at(), + ) + return token + + +def _issue_password_reset_token(user_id: str) -> str: + token = generate_opaque_token() + auth_repository.create_password_reset_token( + user_id=user_id, + token_hash=hash_opaque_token(token), + expires_at=_token_expires_at(), + ) + return token + + +def register(email: str, password: str) -> User: + existing = repository.get_user_by_email(email) + if existing: + if existing.status == "pending": + token = _issue_verification_token(existing.id) + _send_verification_email(existing, token) + return existing + user = repository.create_user(email=email, password_hash=hash_password(password), status="pending") + token = _issue_verification_token(user.id) + _send_verification_email(user, token) + return user + + +def verify_email_token(token: str) -> User: + token_hash = hash_opaque_token(token) + token_row = auth_repository.get_email_verification_token(token_hash) + if not token_row or token_row.used_at is not None: + raise ValueError("INVALID_TOKEN") + if ensure_utc(token_row.expires_at) < utc_now(): + raise ValueError("TOKEN_EXPIRED") + + user = repository.get_user_by_id(token_row.user_id) + if not user: + raise ValueError("INVALID_TOKEN") + + user.status = "active" + user.email_verified_at = utc_now() + repository.update_user(user) + auth_repository.mark_email_verification_token_used(token_hash) + return user + + +def resend_verification(email: str) -> None: + user = repository.get_user_by_email(email) + if not user or user.status != "pending": + return + token = _issue_verification_token(user.id) + _send_verification_email(user, token) + + +def forgot_password(email: str) -> None: + user = repository.get_user_by_email(email) + if not user: + return + token = _issue_password_reset_token(user.id) + _send_password_reset_email(user, token) + + +def reset_password(token: str, new_password: str) -> None: + token_hash = hash_opaque_token(token) + token_row = auth_repository.get_password_reset_token(token_hash) + if not token_row or token_row.used_at is not None: + raise ValueError("INVALID_TOKEN") + if ensure_utc(token_row.expires_at) < utc_now(): + raise ValueError("TOKEN_EXPIRED") + + user = repository.get_user_by_id(token_row.user_id) + if not user: + raise ValueError("INVALID_TOKEN") + + user.password_hash = hash_password(new_password) + repository.update_user(user) + auth_repository.mark_password_reset_token_used(token_hash) + revoke_user_refresh_family(user.id) + + +def _is_locked(user: User) -> bool: + locked_until = ensure_utc(user.locked_until) + if locked_until and locked_until > utc_now(): + return True + if locked_until and locked_until <= utc_now(): + user.failed_login_attempts = 0 + user.locked_until = None + repository.update_user(user) + return False + + +def _record_failed_login(user: User) -> None: + locked_until = ensure_utc(user.locked_until) + if locked_until and locked_until <= utc_now(): + user.failed_login_attempts = 0 + user.locked_until = None + user.failed_login_attempts += 1 + if user.failed_login_attempts >= settings.auth_lockout_attempts: + user.locked_until = utc_now() + timedelta(minutes=settings.auth_lockout_minutes) + repository.update_user(user) + + +def _reset_login_attempts(user: User) -> None: + user.failed_login_attempts = 0 + user.locked_until = None + repository.update_user(user) + + +def login(email: str, password: str) -> tuple[str, str, User]: + user = repository.get_user_by_email(email) + if user and _is_locked(user): + raise PermissionError("ACCOUNT_TEMPORARILY_LOCKED") + if not user or not verify_password(password, user.password_hash): + if user: + _record_failed_login(user) + raise ValueError("INVALID_CREDENTIALS") + if user.status == "pending": + raise PermissionError("EMAIL_NOT_VERIFIED") + if user.status == "blocked": + raise PermissionError("ACCOUNT_BLOCKED") + _reset_login_attempts(user) + access_token = create_access_token(user.id, user.role, user.is_superuser) + refresh_token = issue_refresh_token(user.id) + return access_token, refresh_token, user + + +def issue_refresh_token(user_id: str, family_id: str | None = None) -> str: + token = generate_refresh_token() + token_hash = hash_refresh_token(token) + family = family_id or str(uuid4()) + auth_repository.create_refresh_token( + user_id=user_id, + token_hash=token_hash, + family_id=family, + expires_at=utc_now() + timedelta(days=settings.jwt_refresh_ttl_days), + ) + return token + + +def refresh(refresh_token: str) -> tuple[str, str, User]: + token_hash = hash_refresh_token(refresh_token) + token_row = auth_repository.get_refresh_token(token_hash) + if not token_row: + raise PermissionError("INVALID_REFRESH") + if token_row.revoked_at is not None: + auth_repository.revoke_family_tokens(token_row.family_id) + raise PermissionError("INVALID_REFRESH") + if ensure_utc(token_row.expires_at) < utc_now(): + raise PermissionError("EXPIRED_REFRESH") + + user = repository.get_user_by_id(token_row.user_id) + if not user: + raise PermissionError("INVALID_REFRESH") + if user.status in {"pending", "blocked"}: + raise PermissionError("INVALID_REFRESH") + + auth_repository.revoke_refresh_token(token_hash) + new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id) + access = create_access_token(user.id, user.role, user.is_superuser) + return access, new_refresh, user + + +def revoke_refresh_token(refresh_token: str) -> None: + token_hash = hash_refresh_token(refresh_token) + token_row = auth_repository.revoke_refresh_token(token_hash) + if token_row: + auth_repository.revoke_family_tokens(token_row.family_id) + + +def revoke_user_refresh_family(user_id: str) -> None: + auth_repository.revoke_user_families(user_id) diff --git a/apps/api/app/modules/content/__init__.py b/apps/api/app/modules/content/__init__.py new file mode 100644 index 0000000..23a1791 --- /dev/null +++ b/apps/api/app/modules/content/__init__.py @@ -0,0 +1 @@ +"""Content module.""" diff --git a/apps/api/app/modules/content/models.py b/apps/api/app/modules/content/models.py new file mode 100644 index 0000000..93c98f0 --- /dev/null +++ b/apps/api/app/modules/content/models.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ContentPage(Base): + __tablename__ = "content_pages" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + slug: Mapped[str] = mapped_column(String(120), unique=True, nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + body: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft", index=True) + author_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True) + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) diff --git a/apps/api/app/modules/content/repository.py b/apps/api/app/modules/content/repository.py new file mode 100644 index 0000000..796892e --- /dev/null +++ b/apps/api/app/modules/content/repository.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import select + +from app.core.database import session_scope +from app.modules.content.models import ContentPage + +ALLOWED_PAGE_STATUSES = {"draft", "published"} + + +def _detach(db, instance): + db.refresh(instance) + db.expunge(instance) + return instance + + +def list_published_pages() -> list[ContentPage]: + with session_scope() as db: + pages = list( + db.scalars(select(ContentPage).where(ContentPage.status == "published").order_by(ContentPage.slug)) + ) + return [_detach(db, page) for page in pages] + + +def list_all_pages() -> list[ContentPage]: + with session_scope() as db: + pages = list(db.scalars(select(ContentPage).order_by(ContentPage.slug))) + return [_detach(db, page) for page in pages] + + +def get_page_by_slug(slug: str, include_draft: bool = False) -> ContentPage | None: + with session_scope() as db: + page = db.scalar(select(ContentPage).where(ContentPage.slug == slug)) + if not page: + return None + if include_draft or page.status == "published": + return _detach(db, page) + return None + + +def get_page_by_id(page_id: str) -> ContentPage | None: + with session_scope() as db: + page = db.get(ContentPage, page_id) + if not page: + return None + return _detach(db, page) + + +def create_page( + slug: str, + title: str, + body: str, + status: str, + author_id: str, +) -> ContentPage: + if status not in ALLOWED_PAGE_STATUSES: + raise ValueError("INVALID_STATUS") + with session_scope() as db: + page = ContentPage( + id=str(uuid4()), + slug=slug, + title=title, + body=body, + status=status, + author_id=author_id, + published_at=datetime.now(UTC) if status == "published" else None, + ) + db.add(page) + db.flush() + return _detach(db, page) + + +def update_page( + page_id: str, + title: str | None, + body: str | None, + status: str | None, +) -> ContentPage: + with session_scope() as db: + page = db.get(ContentPage, page_id) + if not page: + raise KeyError(page_id) + if title: + page.title = title + if body: + page.body = body + if status: + if status not in ALLOWED_PAGE_STATUSES: + raise ValueError("INVALID_STATUS") + page.status = status + if status == "published" and page.published_at is None: + page.published_at = datetime.now(UTC) + page.updated_at = datetime.now(UTC) + db.flush() + return _detach(db, page) + + +def delete_page(page_id: str) -> None: + with session_scope() as db: + page = db.get(ContentPage, page_id) + if page: + db.delete(page) diff --git a/apps/api/app/modules/content/router.py b/apps/api/app/modules/content/router.py new file mode 100644 index 0000000..407d48e --- /dev/null +++ b/apps/api/app/modules/content/router.py @@ -0,0 +1,56 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.core.dependencies import require_admin +from app.modules.content.schemas import ContentPageIn, ContentPagePatchIn +from app.modules.content.service import ( + create_page, + delete_page, + get_page_by_slug, + list_all_pages, + list_published_pages, + page_exists, + page_to_dict, + update_page, +) + +router = APIRouter() + + +@router.get("/pages") +async def list_pages_route(): + pages = list_published_pages() + return {"data": [page_to_dict(page) for page in pages]} + + +@router.get("/pages/manage/all") +async def list_all_pages_route(_admin=Depends(require_admin)): + pages = list_all_pages() + return {"data": [page_to_dict(page) for page in pages]} + + +@router.get("/pages/{slug}") +async def get_page_route(slug: str): + page = get_page_by_slug(slug) + if not page: + raise HTTPException(status_code=404, detail="PAGE_NOT_FOUND") + return page_to_dict(page) + + +@router.post("/pages") +async def create_page_route(payload: ContentPageIn, admin=Depends(require_admin)): + page = create_page(payload.slug, payload.title, payload.body, payload.status, admin.id) + return page_to_dict(page) + + +@router.patch("/pages/{page_id}") +async def update_page_route(page_id: str, payload: ContentPagePatchIn, _admin=Depends(require_admin)): + if not page_exists(page_id): + raise HTTPException(status_code=404, detail="PAGE_NOT_FOUND") + page = update_page(page_id, payload.title, payload.body, payload.status) + return page_to_dict(page) + + +@router.delete("/pages/{page_id}") +async def delete_page_route(page_id: str, _admin=Depends(require_admin)): + delete_page(page_id) + return {"message": "deleted"} diff --git a/apps/api/app/modules/content/schemas.py b/apps/api/app/modules/content/schemas.py new file mode 100644 index 0000000..61afe9c --- /dev/null +++ b/apps/api/app/modules/content/schemas.py @@ -0,0 +1,24 @@ +from typing import Literal + +from pydantic import BaseModel, Field + + +class ContentPageIn(BaseModel): + slug: str = Field(min_length=2, max_length=120) + title: str = Field(min_length=2, max_length=200) + body: str + status: Literal["draft", "published"] = "draft" + + +class ContentPagePatchIn(BaseModel): + title: str | None = Field(default=None, min_length=2, max_length=200) + body: str | None = None + status: Literal["draft", "published"] | None = None + + +class ContentPageOut(BaseModel): + id: str + slug: str + title: str + body: str + status: str diff --git a/apps/api/app/modules/content/service.py b/apps/api/app/modules/content/service.py new file mode 100644 index 0000000..2302ebb --- /dev/null +++ b/apps/api/app/modules/content/service.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import bleach + +from app.modules.content import repository +from app.modules.content.models import ContentPage + +ALLOWED_TAGS = ["p", "h1", "h2", "h3", "h4", "ul", "ol", "li", "a", "strong", "em", "br", "img"] + + +def sanitize_html(raw_html: str) -> str: + return bleach.clean( + raw_html, + tags=ALLOWED_TAGS, + attributes={"a": ["href"], "img": ["src", "alt"]}, + protocols=["http", "https", "mailto"], + strip=True, + ) + + +def page_to_dict(page: ContentPage) -> dict: + return { + "id": page.id, + "slug": page.slug, + "title": page.title, + "body": page.body, + "status": page.status, + "author_id": page.author_id, + "published_at": page.published_at.isoformat() if page.published_at else None, + "updated_at": page.updated_at.isoformat() if page.updated_at else None, + } + + +def list_published_pages() -> list[ContentPage]: + return repository.list_published_pages() + + +def list_all_pages() -> list[ContentPage]: + return repository.list_all_pages() + + +def get_page_by_slug(slug: str, include_draft: bool = False) -> ContentPage | None: + return repository.get_page_by_slug(slug, include_draft=include_draft) + + +def create_page(slug: str, title: str, body: str, status: str, author_id: str) -> ContentPage: + return repository.create_page( + slug=slug, + title=title, + body=sanitize_html(body), + status=status, + author_id=author_id, + ) + + +def update_page(page_id: str, title: str | None, body: str | None, status: str | None) -> ContentPage: + return repository.update_page( + page_id, + title, + sanitize_html(body) if body else None, + status, + ) + + +def delete_page(page_id: str) -> None: + repository.delete_page(page_id) + + +def page_exists(page_id: str) -> bool: + return repository.get_page_by_id(page_id) is not None diff --git a/apps/api/app/modules/media/router.py b/apps/api/app/modules/media/router.py new file mode 100644 index 0000000..3fccbdf --- /dev/null +++ b/apps/api/app/modules/media/router.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import Response + +from app.core.media_signing import verify_signed_media +from app.core.storage import download_object + +router = APIRouter() + + +@router.get("/files/{file_path:path}") +async def get_media_file( + file_path: str, + expires: int = Query(...), + sig: str = Query(...), +): + if not file_path.startswith("avatars/"): + raise HTTPException(status_code=404, detail="FILE_NOT_FOUND") + if not verify_signed_media(file_path, expires, sig): + raise HTTPException(status_code=403, detail="INVALID_SIGNATURE") + stored = download_object(file_path) + if not stored: + raise HTTPException(status_code=404, detail="FILE_NOT_FOUND") + body, content_type = stored + return Response(content=body, media_type=content_type) diff --git a/apps/api/app/modules/media/schemas.py b/apps/api/app/modules/media/schemas.py new file mode 100644 index 0000000..e6cab33 --- /dev/null +++ b/apps/api/app/modules/media/schemas.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class AvatarUploadOut(BaseModel): + message: str diff --git a/apps/api/app/modules/media/service.py b/apps/api/app/modules/media/service.py new file mode 100644 index 0000000..d6e7960 --- /dev/null +++ b/apps/api/app/modules/media/service.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from io import BytesIO +from uuid import uuid4 + +from PIL import Image + +from app.core.config import settings +from app.core.storage import upload_object + +ALLOWED_MIME = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", +} + +FORMAT_TO_MIME = { + "JPEG": "image/jpeg", + "PNG": "image/png", + "WEBP": "image/webp", +} + + +class AvatarValidationError(ValueError): + pass + + +def validate_and_process_avatar(content: bytes) -> tuple[str, bytes]: + if len(content) > settings.avatar_max_bytes: + raise AvatarValidationError("FILE_TOO_LARGE") + if not content: + raise AvatarValidationError("INVALID_IMAGE") + + try: + with Image.open(BytesIO(content)) as image: + image.verify() + with Image.open(BytesIO(content)) as image: + mime = FORMAT_TO_MIME.get(image.format or "") + if mime not in ALLOWED_MIME: + raise AvatarValidationError("INVALID_MIME") + + buffer = BytesIO() + if mime == "image/jpeg": + rgb = image.convert("RGB") + rgb.save(buffer, format="JPEG", quality=85, optimize=True) + elif mime == "image/png": + image.save(buffer, format="PNG", optimize=True) + else: + image.save(buffer, format="WEBP", quality=85, method=6) + return mime, buffer.getvalue() + except AvatarValidationError: + raise + except Exception as exc: + raise AvatarValidationError("INVALID_IMAGE") from exc + + +def upload_user_avatar(user_id: str, content: bytes) -> str: + mime, processed = validate_and_process_avatar(content) + extension = ALLOWED_MIME[mime] + key = f"avatars/{user_id}/{uuid4()}{extension}" + upload_object(key, processed, mime) + return f"/api/v1/media/files/{key}" diff --git a/apps/api/app/modules/notifications/service.py b/apps/api/app/modules/notifications/service.py new file mode 100644 index 0000000..15558de --- /dev/null +++ b/apps/api/app/modules/notifications/service.py @@ -0,0 +1,8 @@ +"""Celery-ready notifications service placeholder. + +MVP keeps SMTP sync path in auth; v1.0 should move to async queue. +""" + + +def enqueue_email(template: str, recipient: str, context: dict) -> dict: + return {"queued": True, "template": template, "recipient": recipient, "context": context} diff --git a/apps/api/app/modules/orders/service.py b/apps/api/app/modules/orders/service.py new file mode 100644 index 0000000..4e29b8b --- /dev/null +++ b/apps/api/app/modules/orders/service.py @@ -0,0 +1,5 @@ +"""Orders module placeholder (request/booking mode without payment).""" + + +def create_order_request(user_id: str, items: list[dict]) -> dict: + return {"id": "order-placeholder", "user_id": user_id, "items": items, "status": "submitted"} diff --git a/apps/api/app/modules/test/router.py b/apps/api/app/modules/test/router.py new file mode 100644 index 0000000..15448f8 --- /dev/null +++ b/apps/api/app/modules/test/router.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter, HTTPException, Query + +from app.core.config import settings +from app.core.email import memory_mailer + +router = APIRouter() + + +@router.get("/emails/latest-token") +async def latest_email_token(to: str = Query(...), template: str = Query(...)): + if not settings.enable_test_routes or settings.email_delivery_mode != "memory": + raise HTTPException(status_code=404, detail="NOT_FOUND") + token = memory_mailer.latest_token(to, template) + if not token: + raise HTTPException(status_code=404, detail="TOKEN_NOT_FOUND") + return {"token": token} diff --git a/apps/api/app/modules/users/__init__.py b/apps/api/app/modules/users/__init__.py new file mode 100644 index 0000000..dbc6482 --- /dev/null +++ b/apps/api/app/modules/users/__init__.py @@ -0,0 +1 @@ +"""Users module.""" diff --git a/apps/api/app/modules/users/models.py b/apps/api/app/modules/users/models.py new file mode 100644 index 0000000..8e5d418 --- /dev/null +++ b/apps/api/app/modules/users/models.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column(String(16), nullable=False, default="user") + is_superuser: Mapped[bool] = mapped_column(nullable=False, default=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True) + failed_login_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + profile: Mapped["UserProfile"] = relationship( + back_populates="user", + uselist=False, + cascade="all, delete-orphan", + passive_deletes=True, + ) + + +class UserProfile(Base): + __tablename__ = "user_profiles" + + user_id: Mapped[str] = mapped_column( + String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + display_name: Mapped[str] = mapped_column(String(120), nullable=False) + avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + metadata_json: Mapped[str | None] = mapped_column(Text, nullable=True) + + user: Mapped[User] = relationship(back_populates="profile") diff --git a/apps/api/app/modules/users/repository.py b/apps/api/app/modules/users/repository.py new file mode 100644 index 0000000..247a444 --- /dev/null +++ b/apps/api/app/modules/users/repository.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import func, select + +from app.core.database import session_scope +from app.modules.users.models import User, UserProfile + +ALLOWED_ROLES = {"user", "admin"} +ALLOWED_STATUSES = {"pending", "active", "blocked"} + + +def _detach(db, instance): + db.refresh(instance) + db.expunge(instance) + return instance + + +def create_user( + email: str, + password_hash: str, + role: str = "user", + is_superuser: bool = False, + status: str = "pending", +) -> User: + if role not in ALLOWED_ROLES: + raise ValueError("INVALID_ROLE") + if status not in ALLOWED_STATUSES: + raise ValueError("INVALID_STATUS") + if is_superuser and role != "admin": + raise ValueError("SUPERUSER_REQUIRES_ADMIN") + with session_scope() as db: + user = User( + id=str(uuid4()), + email=email.lower(), + password_hash=password_hash, + role=role, + is_superuser=is_superuser, + status=status, + ) + db.add(user) + db.flush() + profile = UserProfile(user_id=user.id, display_name=email.split("@")[0]) + db.add(profile) + db.flush() + return _detach(db, user) + + +def get_user_by_email(email: str) -> User | None: + with session_scope() as db: + user = db.scalar(select(User).where(User.email == email.lower())) + if not user: + return None + return _detach(db, user) + + +def get_user_by_id(user_id: str) -> User | None: + with session_scope() as db: + user = db.get(User, user_id) + if not user: + return None + return _detach(db, user) + + +def update_user(user: User) -> None: + if user.role not in ALLOWED_ROLES: + raise ValueError("INVALID_ROLE") + if user.status not in ALLOWED_STATUSES: + raise ValueError("INVALID_STATUS") + if user.is_superuser and user.role != "admin": + raise ValueError("SUPERUSER_REQUIRES_ADMIN") + with session_scope() as db: + db_user = db.get(User, user.id) + if not db_user: + return + db_user.email = user.email + db_user.password_hash = user.password_hash + db_user.role = user.role + db_user.is_superuser = user.is_superuser + db_user.status = user.status + db_user.failed_login_attempts = user.failed_login_attempts + db_user.locked_until = user.locked_until + db_user.email_verified_at = user.email_verified_at + db_user.updated_at = datetime.now(UTC) + + +def list_users(page: int, limit: int) -> tuple[list[User], int]: + with session_scope() as db: + total = db.scalar(select(func.count()).select_from(User)) or 0 + users = db.scalars( + select(User).order_by(User.created_at.desc()).offset((page - 1) * limit).limit(limit) + ).all() + return [_detach(db, user) for user in users], total + + +def count_users() -> int: + with session_scope() as db: + return db.scalar(select(func.count()).select_from(User)) or 0 + + +def count_users_registered_today() -> int: + today = datetime.now(UTC).date() + with session_scope() as db: + return ( + db.scalar( + select(func.count()) + .select_from(User) + .where(func.date(User.created_at) == today) + ) + or 0 + ) + + +def count_admins() -> int: + with session_scope() as db: + return db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) or 0 + + +def count_superusers() -> int: + with session_scope() as db: + return ( + db.scalar( + select(func.count()) + .select_from(User) + .where(User.role == "admin", User.is_superuser.is_(True)) + ) + or 0 + ) + + +def delete_user(user_id: str) -> bool: + with session_scope() as db: + user = db.get(User, user_id) + if not user: + return False + db.delete(user) + return True + + +def get_profile(user_id: str) -> UserProfile: + with session_scope() as db: + profile = db.get(UserProfile, user_id) + if not profile: + raise KeyError(user_id) + return _detach(db, profile) + + +def update_profile( + user_id: str, + display_name: str | None = None, + avatar_url: str | None = None, +) -> UserProfile: + with session_scope() as db: + profile = db.get(UserProfile, user_id) + if not profile: + raise KeyError(user_id) + if display_name is not None: + profile.display_name = display_name + if avatar_url is not None: + profile.avatar_url = avatar_url + db.flush() + return _detach(db, profile) diff --git a/apps/api/app/modules/users/router.py b/apps/api/app/modules/users/router.py new file mode 100644 index 0000000..868260b --- /dev/null +++ b/apps/api/app/modules/users/router.py @@ -0,0 +1,59 @@ +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile + +from app.core.dependencies import get_current_user +from app.core.media_signing import build_signed_media_url +from app.core.redis import check_rate_limit +from app.modules.media.service import AvatarValidationError +from app.modules.users.schemas import PasswordChangeIn, UserPatchIn +from app.modules.users.service import change_password, get_me, update_me, upload_avatar + +router = APIRouter() + + +def _profile_response(data: dict) -> dict: + return { + "user": { + "id": data["user"].id, + "email": data["user"].email, + "role": data["user"].role, + "status": data["user"].status, + }, + "profile": { + "display_name": data["profile"].display_name, + "avatar_url": build_signed_media_url(data["profile"].avatar_url), + }, + } + + +@router.get("/me") +async def me_route(current_user=Depends(get_current_user)): + return _profile_response(get_me(current_user)) + + +@router.patch("/me") +async def patch_me_route(payload: UserPatchIn, current_user=Depends(get_current_user)): + return _profile_response(update_me(current_user, payload.display_name)) + + +@router.post("/me/password") +async def change_password_route(payload: PasswordChangeIn, current_user=Depends(get_current_user)): + try: + change_password(current_user, payload.current_password, payload.new_password) + except ValueError: + raise HTTPException(status_code=400, detail="INVALID_CURRENT_PASSWORD") + return {"message": "password_changed"} + + +@router.post("/me/avatar") +async def upload_avatar_route( + request: Request, + file: UploadFile = File(...), + current_user=Depends(get_current_user), +): + check_rate_limit(f"avatar:{current_user.id}", limit=10, window_seconds=3600) + content = await file.read() + try: + data = upload_avatar(current_user, content) + except AvatarValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return _profile_response(data) diff --git a/apps/api/app/modules/users/schemas.py b/apps/api/app/modules/users/schemas.py new file mode 100644 index 0000000..a6f7138 --- /dev/null +++ b/apps/api/app/modules/users/schemas.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel, EmailStr, Field, field_validator + +from app.core.password_policy import validate_password_strength + + +class UserOut(BaseModel): + id: str + email: EmailStr + role: str + status: str + + +class UserProfileOut(BaseModel): + display_name: str + avatar_url: str | None = None + + +class UserMeOut(BaseModel): + user: UserOut + profile: UserProfileOut + + +class UserPatchIn(BaseModel): + display_name: str | None = Field(default=None, min_length=1, max_length=120) + + +class PasswordChangeIn(BaseModel): + current_password: str + new_password: str = Field(min_length=8) + + @field_validator("new_password") + @classmethod + def password_policy(cls, value: str) -> str: + return validate_password_strength(value) diff --git a/apps/api/app/modules/users/service.py b/apps/api/app/modules/users/service.py new file mode 100644 index 0000000..314b6fd --- /dev/null +++ b/apps/api/app/modules/users/service.py @@ -0,0 +1,29 @@ +from app.core.security import hash_password, verify_password +from app.modules.auth.service import revoke_user_refresh_family +from app.modules.media.service import upload_user_avatar +from app.modules.users import repository +from app.modules.users.models import User + + +def get_me(user: User) -> dict: + profile = repository.get_profile(user.id) + return {"user": user, "profile": profile} + + +def update_me(user: User, display_name: str | None) -> dict: + profile = repository.update_profile(user.id, display_name=display_name) + return {"user": user, "profile": profile} + + +def upload_avatar(user: User, content: bytes) -> dict: + avatar_url = upload_user_avatar(user.id, content) + profile = repository.update_profile(user.id, avatar_url=avatar_url) + return {"user": user, "profile": profile} + + +def change_password(user: User, current_password: str, new_password: str) -> None: + if not verify_password(current_password, user.password_hash): + raise ValueError("INVALID_CURRENT_PASSWORD") + user.password_hash = hash_password(new_password) + repository.update_user(user) + revoke_user_refresh_family(user.id) diff --git a/apps/api/app/worker.py b/apps/api/app/worker.py new file mode 100644 index 0000000..2a9550f --- /dev/null +++ b/apps/api/app/worker.py @@ -0,0 +1 @@ +"""Celery worker entrypoint placeholder for v1.0 notifications.""" diff --git a/apps/api/data/compton_settings.json b/apps/api/data/compton_settings.json new file mode 100644 index 0000000..5f26d8f --- /dev/null +++ b/apps/api/data/compton_settings.json @@ -0,0 +1,21 @@ +{ + "enable_rate_limit": false, + "enable_docs": false, + "cookie_secure": false, + "jwt_access_ttl_min": 15, + "auth_lockout_attempts": 5, + "auth_lockout_minutes": 15, + "cors_origins": [ + "http://localhost:5173" + ], + "frontend_url": "http://localhost:5173", + "public_base_url": "http://localhost:5173", + "smtp_host": "localhost", + "smtp_port": 1025, + "smtp_from": "noreply@compton.example", + "avatar_max_bytes": 2097152, + "media_url_ttl_seconds": 600, + "log_level": "INFO", + "audit_retention_days": 90, + "jwt_refresh_ttl_days": 30 +} \ No newline at end of file diff --git a/apps/api/data/logs/admin-audit.jsonl b/apps/api/data/logs/admin-audit.jsonl new file mode 100644 index 0000000..ed254e4 --- /dev/null +++ b/apps/api/data/logs/admin-audit.jsonl @@ -0,0 +1,52 @@ +{"timestamp": "2026-07-14T09:45:09.679160+00:00", "action": "admin.settings.patch", "actor_user_id": "2b0a09b4-4f73-4a98-887f-e2a90357b443", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}} +{"timestamp": "2026-07-14T09:45:10.079262+00:00", "action": "admin.user.create", "actor_user_id": "2b0a09b4-4f73-4a98-887f-e2a90357b443", "actor_email": "admin@compton.example", "details": {"target_user_id": "2effcf29-377b-417d-a804-d19cb4c9e2a1"}} +{"timestamp": "2026-07-14T09:45:11.267271+00:00", "action": "admin.user.patch", "actor_user_id": "2b0a09b4-4f73-4a98-887f-e2a90357b443", "actor_email": "admin@compton.example", "details": {"target_user_id": "a508d851-1f17-4397-a272-c67ac65d0b12"}} +{"timestamp": "2026-07-14T09:45:24.615324+00:00", "action": "admin.settings.patch", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}} +{"timestamp": "2026-07-14T09:45:25.017710+00:00", "action": "admin.user.create", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"target_user_id": "d1b2ba42-b482-45b4-b0cb-1296aa4015ad"}} +{"timestamp": "2026-07-14T09:45:25.217777+00:00", "action": "admin.user.delete", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"target_user_id": "d1b2ba42-b482-45b4-b0cb-1296aa4015ad"}} +{"timestamp": "2026-07-14T09:45:25.431192+00:00", "action": "admin.user.patch", "actor_user_id": "077a2a71-17ed-407c-be59-3d8cd138a77b", "actor_email": "admin@compton.example", "details": {"target_user_id": "55c1800f-e445-4eae-b84a-836c27ec88de"}} +{"timestamp": "2026-07-14T09:45:43.063910+00:00", "action": "admin.settings.patch", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}} +{"timestamp": "2026-07-14T09:45:43.471557+00:00", "action": "admin.user.create", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"target_user_id": "588ca17f-528d-44fc-987a-f9f87a294c4e"}} +{"timestamp": "2026-07-14T09:45:43.677436+00:00", "action": "admin.user.delete", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"target_user_id": "588ca17f-528d-44fc-987a-f9f87a294c4e"}} +{"timestamp": "2026-07-14T09:45:43.890842+00:00", "action": "admin.user.patch", "actor_user_id": "caa8d97e-c943-4f83-8ee8-48f3d838c3d4", "actor_email": "admin@compton.example", "details": {"target_user_id": "5094baf7-5c73-4e9d-a1bb-8baa98516303"}} +{"timestamp": "2026-07-14T09:46:32.753967+00:00", "action": "admin.settings.patch", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}} +{"timestamp": "2026-07-14T09:46:33.160994+00:00", "action": "admin.user.create", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"target_user_id": "d78de899-6f88-4dc8-a1c1-fb2bea3fd6e8"}} +{"timestamp": "2026-07-14T09:46:33.371828+00:00", "action": "admin.user.delete", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"target_user_id": "d78de899-6f88-4dc8-a1c1-fb2bea3fd6e8"}} +{"timestamp": "2026-07-14T09:46:33.574496+00:00", "action": "admin.user.patch", "actor_user_id": "bf753562-e005-4f6e-9ee5-4e7d4c854841", "actor_email": "admin@compton.example", "details": {"target_user_id": "9c93dc2b-b171-486e-ac8b-855a657dd4db"}} +{"timestamp": "2026-07-14T10:17:13.813043+00:00", "action": "admin.settings.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}} +{"timestamp": "2026-07-14T10:17:15.032768+00:00", "action": "admin.user.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"target_user_id": "a7f9dad1-652a-4a78-8eb4-559d73633cbb"}} +{"timestamp": "2026-07-14T10:17:17.964910+00:00", "action": "admin.user.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"target_user_id": "14c90906-7e65-4c2f-bc10-3e2c37771f77"}} +{"timestamp": "2026-07-14T10:17:21.299378+00:00", "action": "admin.user.patch", "actor_user_id": "1cc5290b-cb44-4b44-91ee-33aaba63c5d5", "actor_email": "admin@compton.example", "details": {"target_user_id": "4fb379b6-d751-4b74-8441-dacfc765a1b6"}} +{"timestamp": "2026-07-14T10:18:21.413246+00:00", "action": "admin.settings.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}} +{"timestamp": "2026-07-14T10:18:21.831392+00:00", "action": "admin.user.create", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "3ed45afa-b03b-4599-ab09-129d35b5050a"}} +{"timestamp": "2026-07-14T10:18:22.043824+00:00", "action": "admin.user.delete", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "3ed45afa-b03b-4599-ab09-129d35b5050a"}} +{"timestamp": "2026-07-14T10:18:22.667006+00:00", "action": "admin.secrets.reveal", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"key": "database_password"}} +{"timestamp": "2026-07-14T10:18:23.044151+00:00", "action": "admin.user.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "19bd08e1-1fd2-45d6-9d2e-910b8021060d"}} +{"timestamp": "2026-07-14T10:18:25.817132+00:00", "action": "admin.user.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "3e2fb3bf-a9c3-47e0-9c7d-5b9a4fff37d5"}} +{"timestamp": "2026-07-14T10:18:29.042541+00:00", "action": "admin.user.patch", "actor_user_id": "37f53597-5bbd-47cd-970c-2838641a9813", "actor_email": "admin@compton.example", "details": {"target_user_id": "25c9b2af-b46b-4c38-96e8-ca504e3d05ef"}} +{"timestamp": "2026-07-14T10:18:59.773024+00:00", "action": "admin.settings.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}} +{"timestamp": "2026-07-14T10:19:00.184666+00:00", "action": "admin.user.create", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3891a54-1000-41e4-ad5a-7f39b271025f"}} +{"timestamp": "2026-07-14T10:19:00.387212+00:00", "action": "admin.user.delete", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3891a54-1000-41e4-ad5a-7f39b271025f"}} +{"timestamp": "2026-07-14T10:19:01.013462+00:00", "action": "admin.secrets.reveal", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"key": "database_password"}} +{"timestamp": "2026-07-14T10:19:01.232252+00:00", "action": "admin.user.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "2a167305-8874-4b4b-9485-2f01dbc239e7"}} +{"timestamp": "2026-07-14T10:19:03.987813+00:00", "action": "admin.user.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "022de58f-d06e-42a7-bd93-76e1df17848c"}} +{"timestamp": "2026-07-14T10:19:07.219981+00:00", "action": "admin.user.patch", "actor_user_id": "df0394ad-000c-458b-9504-eccd2eae731f", "actor_email": "admin@compton.example", "details": {"target_user_id": "589bffa1-74dd-401a-a21e-93721d7d9c4e"}} +{"timestamp": "2026-07-14T10:19:24.488155+00:00", "action": "admin.settings.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}} +{"timestamp": "2026-07-14T10:19:24.903805+00:00", "action": "admin.user.create", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "a96246c0-5e47-4150-8df5-85ab36f5d6da"}} +{"timestamp": "2026-07-14T10:19:25.101700+00:00", "action": "admin.user.delete", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "a96246c0-5e47-4150-8df5-85ab36f5d6da"}} +{"timestamp": "2026-07-14T10:19:25.741900+00:00", "action": "admin.secrets.reveal", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"key": "database_password"}} +{"timestamp": "2026-07-14T10:19:25.941995+00:00", "action": "admin.user.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "3c69f5f3-af00-4d5c-85d6-80a65cb7cf08"}} +{"timestamp": "2026-07-14T10:19:28.720348+00:00", "action": "admin.user.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "a4c56a38-9f0a-48dd-80e2-96ee38f1bde1"}} +{"timestamp": "2026-07-14T10:19:31.957425+00:00", "action": "admin.user.patch", "actor_user_id": "051cb4c9-5be2-43d3-bb98-0c3fa086835b", "actor_email": "admin@compton.example", "details": {"target_user_id": "d91c9691-8277-44f5-8f02-534f6a1b75e4"}} +{"timestamp": "2026-07-14T10:20:50.255505+00:00", "action": "admin.settings.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}} +{"timestamp": "2026-07-14T10:20:50.666682+00:00", "action": "admin.user.create", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "48345f72-7498-4507-9f3c-be1069f2dfdf"}} +{"timestamp": "2026-07-14T10:20:50.885241+00:00", "action": "admin.user.delete", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "48345f72-7498-4507-9f3c-be1069f2dfdf"}} +{"timestamp": "2026-07-14T10:20:51.518235+00:00", "action": "admin.secrets.reveal", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"key": "database_password"}} +{"timestamp": "2026-07-14T10:20:51.717954+00:00", "action": "admin.user.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "81911a53-d750-480b-aca2-a6c79488cf84"}} +{"timestamp": "2026-07-14T10:20:54.465598+00:00", "action": "admin.user.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "2c535720-06d4-4c30-8d00-36c43419c9bf"}} +{"timestamp": "2026-07-14T10:20:57.747476+00:00", "action": "admin.user.patch", "actor_user_id": "fe5b0efe-76af-4a5d-8d0e-92787fe4b6d6", "actor_email": "admin@compton.example", "details": {"target_user_id": "6de83480-a867-4166-97e4-6c5a053ae771"}} +{"timestamp": "2026-07-14T10:50:15.053615+00:00", "action": "admin.settings.patch", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"updated_keys": ["enable_docs"]}} +{"timestamp": "2026-07-14T10:50:15.469330+00:00", "action": "admin.user.create", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3c5cc11-fe4e-4844-a1e7-d0a68ac71220"}} +{"timestamp": "2026-07-14T10:50:15.684274+00:00", "action": "admin.user.delete", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"target_user_id": "d3c5cc11-fe4e-4844-a1e7-d0a68ac71220"}} +{"timestamp": "2026-07-14T10:50:16.307486+00:00", "action": "admin.secrets.reveal", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"key": "database_password"}} +{"timestamp": "2026-07-14T10:50:16.524434+00:00", "action": "admin.user.patch", "actor_user_id": "7ffda8c7-0641-4d43-b13c-7ba1c6e85b71", "actor_email": "admin@compton.example", "details": {"target_user_id": "5afc1722-6ef5-4c0e-817a-fd1f4af99188"}} diff --git a/apps/api/data/secrets/.gitkeep b/apps/api/data/secrets/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/api/data/secrets/.gitkeep @@ -0,0 +1 @@ + diff --git a/apps/api/migrations/env.py b/apps/api/migrations/env.py new file mode 100644 index 0000000..c041521 --- /dev/null +++ b/apps/api/migrations/env.py @@ -0,0 +1,52 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import settings +from app.db.base import Base + +# Import models so Alembic can discover metadata. +from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken # noqa: F401 +from app.modules.content.models import ContentPage # noqa: F401 +from app.modules.users.models import User, UserProfile # noqa: F401 + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", settings.database_url) +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + transaction_per_migration=True, + ) + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/apps/api/migrations/script.py.mako b/apps/api/migrations/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/apps/api/migrations/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[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/apps/api/migrations/versions/20260711_0001_initial_schema.py b/apps/api/migrations/versions/20260711_0001_initial_schema.py new file mode 100644 index 0000000..fecce06 --- /dev/null +++ b/apps/api/migrations/versions/20260711_0001_initial_schema.py @@ -0,0 +1,120 @@ +"""initial schema + +Revision ID: 20260711_0001 +Revises: +Create Date: 2026-07-11 14:00:00 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260711_0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("role", sa.String(length=16), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("failed_login_attempts", sa.Integer(), nullable=False), + sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + ) + op.create_index("idx_users_email", "users", ["email"], unique=True) + op.create_index("idx_users_status", "users", ["status"], unique=False) + + op.create_table( + "user_profiles", + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("display_name", sa.String(length=120), nullable=False), + sa.Column("avatar_url", sa.String(length=512), nullable=True), + sa.Column("metadata_json", sa.Text(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("user_id"), + ) + + op.create_table( + "refresh_tokens", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("family_id", sa.String(length=36), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token_hash"), + ) + op.create_index("idx_refresh_tokens_user", "refresh_tokens", ["user_id"], unique=False) + op.create_index("idx_refresh_tokens_family", "refresh_tokens", ["family_id"], unique=False) + + op.create_table( + "password_reset_tokens", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token_hash"), + ) + op.create_index("idx_password_reset_user", "password_reset_tokens", ["user_id"], unique=False) + + op.create_table( + "email_verification_tokens", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token_hash"), + ) + + op.create_table( + "content_pages", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("slug", sa.String(length=120), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("author_id", sa.String(length=36), nullable=True), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["author_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug"), + ) + op.create_index("idx_content_slug", "content_pages", ["slug"], unique=True) + op.create_index("idx_content_status", "content_pages", ["status"], unique=False) + + +def downgrade() -> None: + op.drop_index("idx_content_status", table_name="content_pages") + op.drop_index("idx_content_slug", table_name="content_pages") + op.drop_table("content_pages") + op.drop_table("email_verification_tokens") + op.drop_index("idx_password_reset_user", table_name="password_reset_tokens") + op.drop_table("password_reset_tokens") + op.drop_index("idx_refresh_tokens_family", table_name="refresh_tokens") + op.drop_index("idx_refresh_tokens_user", table_name="refresh_tokens") + op.drop_table("refresh_tokens") + op.drop_table("user_profiles") + op.drop_index("idx_users_status", table_name="users") + op.drop_index("idx_users_email", table_name="users") + op.drop_table("users") diff --git a/apps/api/migrations/versions/20260711_0002_seed_data.py b/apps/api/migrations/versions/20260711_0002_seed_data.py new file mode 100644 index 0000000..a633ea0 --- /dev/null +++ b/apps/api/migrations/versions/20260711_0002_seed_data.py @@ -0,0 +1,33 @@ +"""seed admin and demo content + +Revision ID: 20260711_0002 +Revises: 20260711_0001 +Create Date: 2026-07-11 14:05:00 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "20260711_0002" +down_revision: Union[str, None] = "20260711_0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Demo users and CMS pages are seeded on API startup (app.main lifespan → run_seed) + # after all schema migrations, including is_superuser (20260714_0003). + pass + + +def downgrade() -> None: + from sqlalchemy import delete + + from app.core.database import session_scope + from app.modules.content.models import ContentPage + from app.modules.users.models import User + + with session_scope() as db: + db.execute(delete(ContentPage).where(ContentPage.slug.in_(["about", "privacy", "terms"]))) + db.execute(delete(User).where(User.email == "admin@compton.example")) diff --git a/apps/api/migrations/versions/20260714_0003_add_is_superuser.py b/apps/api/migrations/versions/20260714_0003_add_is_superuser.py new file mode 100644 index 0000000..770ff8f --- /dev/null +++ b/apps/api/migrations/versions/20260714_0003_add_is_superuser.py @@ -0,0 +1,36 @@ +"""add is_superuser to users + +Revision ID: 20260714_0003 +Revises: 20260711_0002 +Create Date: 2026-07-14 12:45:00 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260714_0003" +down_revision: Union[str, None] = "20260711_0002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + bind = op.get_bind() + if bind.dialect.name != "sqlite": + op.alter_column("users", "is_superuser", server_default=None) + op.execute( + sa.text( + "UPDATE users SET is_superuser = true " + "WHERE email = 'admin@compton.example' AND role = 'admin'" + ) + ) + + +def downgrade() -> None: + op.drop_column("users", "is_superuser") diff --git a/apps/api/migrations/versions/20260714_0004_db_security_constraints.py b/apps/api/migrations/versions/20260714_0004_db_security_constraints.py new file mode 100644 index 0000000..f952031 --- /dev/null +++ b/apps/api/migrations/versions/20260714_0004_db_security_constraints.py @@ -0,0 +1,55 @@ +"""db security constraints and token indexes + +Revision ID: 20260714_0004 +Revises: 20260714_0003 +Create Date: 2026-07-14 13:30:00 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "20260714_0004" +down_revision: Union[str, None] = "20260714_0003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + if bind.dialect.name != "sqlite": + op.create_check_constraint( + "ck_users_role_allowed", + "users", + "role IN ('user', 'admin')", + ) + op.create_check_constraint( + "ck_users_status_allowed", + "users", + "status IN ('pending', 'active', 'blocked')", + ) + op.create_check_constraint( + "ck_users_superuser_requires_admin", + "users", + "(is_superuser = false) OR (role = 'admin')", + ) + op.create_check_constraint( + "ck_content_pages_status_allowed", + "content_pages", + "status IN ('draft', 'published')", + ) + op.create_index("idx_refresh_tokens_expires_at", "refresh_tokens", ["expires_at"], unique=False) + op.create_index("idx_password_reset_expires_at", "password_reset_tokens", ["expires_at"], unique=False) + op.create_index("idx_email_verify_expires_at", "email_verification_tokens", ["expires_at"], unique=False) + + +def downgrade() -> None: + op.drop_index("idx_email_verify_expires_at", table_name="email_verification_tokens") + op.drop_index("idx_password_reset_expires_at", table_name="password_reset_tokens") + op.drop_index("idx_refresh_tokens_expires_at", table_name="refresh_tokens") + bind = op.get_bind() + if bind.dialect.name != "sqlite": + op.drop_constraint("ck_content_pages_status_allowed", "content_pages", type_="check") + op.drop_constraint("ck_users_superuser_requires_admin", "users", type_="check") + op.drop_constraint("ck_users_status_allowed", "users", type_="check") + op.drop_constraint("ck_users_role_allowed", "users", type_="check") diff --git a/apps/api/migrations/versions/20260714_0005_backfill_superuser_admin.py b/apps/api/migrations/versions/20260714_0005_backfill_superuser_admin.py new file mode 100644 index 0000000..9c77707 --- /dev/null +++ b/apps/api/migrations/versions/20260714_0005_backfill_superuser_admin.py @@ -0,0 +1,35 @@ +"""backfill superuser flag for seeded admin + +Revision ID: 20260714_0005 +Revises: 20260714_0004 +Create Date: 2026-07-14 14:00:00 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260714_0005" +down_revision: Union[str, None] = "20260714_0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + sa.text( + "UPDATE users SET is_superuser = true " + "WHERE email = 'admin@compton.example' AND role = 'admin'" + ) + ) + op.execute( + sa.text( + "UPDATE users SET is_superuser = false " + "WHERE email IN ('ops@compton.example', 'user@compton.example')" + ) + ) + + +def downgrade() -> None: + pass diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml new file mode 100644 index 0000000..469e4d0 --- /dev/null +++ b/apps/api/pyproject.toml @@ -0,0 +1,13 @@ +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] + +[tool.mypy] +python_version = "3.12" +warn_unused_configs = true +check_untyped_defs = true +ignore_missing_imports = true diff --git a/apps/api/requirements-dev.txt b/apps/api/requirements-dev.txt new file mode 100644 index 0000000..8a7ea9d --- /dev/null +++ b/apps/api/requirements-dev.txt @@ -0,0 +1,24 @@ +fastapi>=0.115.0 +uvicorn>=0.32.0 +sqlalchemy>=2.0.36 +alembic>=1.13.3 +psycopg[binary]>=3.2.0 +asyncpg>=0.30.0 +redis>=5.1.1 +python-jose>=3.3.0 +bcrypt>=4.0.0,<5.0.0 +pydantic>=2.9.2 +email-validator>=2.2.0 +pydantic-settings>=2.6.0 +httpx>=0.27.2 +pytest>=8.3.3 +pytest-asyncio>=0.24.0 +pytest-cov>=5.0.0 +mypy>=1.11.2 +ruff>=0.7.1 +pip-audit>=2.7.3 +bandit>=1.7.9 +bleach>=6.1.0 +python-multipart>=0.0.12 +boto3>=1.35.0 +Pillow>=10.4.0 diff --git a/apps/api/scripts/bootstrap_install.py b/apps/api/scripts/bootstrap_install.py new file mode 100644 index 0000000..e64fad6 --- /dev/null +++ b/apps/api/scripts/bootstrap_install.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app.core.install_secrets import ensure_install_secrets + + +def main() -> None: + status = ensure_install_secrets() + if status.created: + print(f"Created install secrets at {status.path}") + print( + "\nIf docker compose was already started without install.env, reset the Postgres volume " + "before the next start (local dev only — deletes DB data):\n" + " docker compose --profile docker-web down -v\n" + " docker compose --profile docker-web up -d --build" + ) + else: + print(f"Install secrets already exist at {status.path}") + + +if __name__ == "__main__": + main() diff --git a/apps/api/scripts/docker_entrypoint.py b/apps/api/scripts/docker_entrypoint.py new file mode 100644 index 0000000..889e1c7 --- /dev/null +++ b/apps/api/scripts/docker_entrypoint.py @@ -0,0 +1,101 @@ +"""Docker entrypoint: wait for DB, reconcile Alembic state, migrate, start API.""" + +from __future__ import annotations + +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, inspect, text + +from app.core.install_secrets import ensure_install_secrets +from app.core.config import settings + +INITIAL_REVISION = "20260711_0001" +SCHEMA_TABLES = ( + "content_pages", + "email_verification_tokens", + "password_reset_tokens", + "refresh_tokens", + "user_profiles", + "users", +) + + +def wait_for_database(max_attempts: int = 30, delay_seconds: float = 1.0): + engine = create_engine(settings.database_url) + last_error: Exception | None = None + for _ in range(max_attempts): + try: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + return engine + except Exception as exc: + last_error = exc + time.sleep(delay_seconds) + detail = str(last_error or "unknown error") + hint = "" + if "password authentication failed" in detail or "does not exist" in detail: + hint = ( + "\n\nPostgres credentials in install.env do not match the existing database volume " + "(common if docker compose ran before bootstrap_install.py).\n" + "Local dev fix — deletes Docker DB data:\n" + " docker compose --profile docker-web down -v\n" + " docker compose --profile docker-web up -d --build\n" + "See docs/secrets-recovery.md" + ) + raise RuntimeError(f"Database is unavailable: {detail}{hint}") from last_error + + +def current_revision(engine) -> str | None: + inspector = inspect(engine) + if "alembic_version" not in inspector.get_table_names(): + return None + with engine.connect() as connection: + return connection.execute(text("SELECT version_num FROM alembic_version")).scalar() + + +def _reset_schema(engine) -> None: + cascade = " CASCADE" if engine.dialect.name == "postgresql" else "" + with engine.begin() as connection: + for table in SCHEMA_TABLES: + connection.execute(text(f'DROP TABLE IF EXISTS "{table}"{cascade}')) + connection.execute(text(f'DROP TABLE IF EXISTS "alembic_version"{cascade}')) + + +def run_migrations(engine) -> None: + config = Config("alembic.ini") + tables = set(inspect(engine).get_table_names()) + revision = current_revision(engine) + schema_tables = set(SCHEMA_TABLES) + existing_schema = tables & schema_tables + + if existing_schema: + if schema_tables.issubset(tables): + if revision is None: + command.stamp(config, INITIAL_REVISION) + else: + _reset_schema(engine) + + command.upgrade(config, "head") + + +def main() -> None: + ensure_install_secrets() + engine = wait_for_database() + run_migrations(engine) + subprocess.run( + [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"], + check=True, + ) + + +if __name__ == "__main__": + main() diff --git a/apps/api/scripts/export_openapi.py b/apps/api/scripts/export_openapi.py new file mode 100644 index 0000000..71664e1 --- /dev/null +++ b/apps/api/scripts/export_openapi.py @@ -0,0 +1,14 @@ +from pathlib import Path +import json + +from app.main import app + + +def main() -> None: + schema = app.openapi() + output = Path(__file__).resolve().parent.parent / "openapi.json" + output.write_text(json.dumps(schema, indent=2), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/apps/api/scripts/migrate.py b/apps/api/scripts/migrate.py new file mode 100644 index 0000000..6e41242 --- /dev/null +++ b/apps/api/scripts/migrate.py @@ -0,0 +1,13 @@ +"""Apply Alembic migrations.""" + +from alembic import command +from alembic.config import Config + + +def main() -> None: + config = Config("alembic.ini") + command.upgrade(config, "head") + + +if __name__ == "__main__": + main() diff --git a/apps/api/scripts/start_e2e_api.py b/apps/api/scripts/start_e2e_api.py new file mode 100644 index 0000000..e8fe123 --- /dev/null +++ b/apps/api/scripts/start_e2e_api.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +API_DIR = Path(__file__).resolve().parents[1] +os.chdir(API_DIR) +sys.path.insert(0, str(API_DIR)) + +db_file = API_DIR / ".e2e.sqlite" +if db_file.exists(): + db_file.unlink() + +os.environ["DATABASE_URL"] = f"sqlite+pysqlite:///{db_file.as_posix()}" +os.environ["EMAIL_DELIVERY_MODE"] = "memory" +os.environ["STORAGE_MODE"] = "memory" +os.environ["ENABLE_RATE_LIMIT"] = "false" +os.environ["ENABLE_TEST_ROUTES"] = "true" +web_port = os.environ.get("E2E_WEB_PORT", "5175") +os.environ["CORS_ORIGINS"] = f'["http://127.0.0.1:{web_port}","http://localhost:{web_port}"]' + +port = os.environ.get("E2E_API_PORT", "8001") + +subprocess.run( + [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", port], + cwd=API_DIR, + check=True, +) diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py new file mode 100644 index 0000000..bb77b9d --- /dev/null +++ b/apps/api/tests/conftest.py @@ -0,0 +1,50 @@ +import os + +os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") +os.environ.setdefault("EMAIL_DELIVERY_MODE", "memory") + +from fastapi.testclient import TestClient +import pytest + +from app.core import database as db_module +from app.core.email import memory_mailer +from app.core.storage import memory_store +from app.db.base import Base +from app.db.seed import run_seed +from app.main import app + + +@pytest.fixture(scope="session", autouse=True) +def setup_database(): + Base.metadata.create_all(db_module.engine) + run_seed(include_demo_pages=False) + yield + Base.metadata.drop_all(db_module.engine) + + +@pytest.fixture(autouse=True) +def disable_rate_limits(monkeypatch): + from app.core.config import settings + + monkeypatch.setattr(settings, "enable_rate_limit", False) + monkeypatch.setattr(settings, "email_delivery_mode", "memory") + monkeypatch.setattr(settings, "storage_mode", "memory") + + +@pytest.fixture(autouse=True) +def clear_email_outbox(): + memory_mailer.clear() + yield + memory_mailer.clear() + + +@pytest.fixture(autouse=True) +def clear_storage(): + memory_store.clear() + yield + memory_store.clear() + + +@pytest.fixture() +def client() -> TestClient: + return TestClient(app) diff --git a/apps/api/tests/core/test_media_signing.py b/apps/api/tests/core/test_media_signing.py new file mode 100644 index 0000000..a1bf860 --- /dev/null +++ b/apps/api/tests/core/test_media_signing.py @@ -0,0 +1,38 @@ +from app.core.media_signing import build_signed_media_url, verify_signed_media +from app.core.config import settings +import time + + +def test_build_and_verify_signed_media_url(): + signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png") + assert signed is not None + assert "expires=" in signed + assert "sig=" in signed + + path = "avatars/user/file.png" + query = signed.split("?", 1)[1] + params = dict(part.split("=") for part in query.split("&")) + assert int(params["expires"]) - int(time.time()) <= settings.media_url_ttl_seconds + assert verify_signed_media(path, int(params["expires"]), params["sig"]) + + +def test_build_signed_media_url_none(): + assert build_signed_media_url(None) is None + + +def test_verify_signed_media_rejects_expired_signature(): + signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png") + assert signed is not None + path = "avatars/user/file.png" + query = signed.split("?", 1)[1] + params = dict(part.split("=") for part in query.split("&")) + assert not verify_signed_media(path, int(params["expires"]) - 10_000, params["sig"]) + + +def test_verify_signed_media_rejects_tampered_signature(): + signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png") + assert signed is not None + path = "avatars/user/file.png" + query = signed.split("?", 1)[1] + params = dict(part.split("=") for part in query.split("&")) + assert not verify_signed_media(path, int(params["expires"]), "invalid") diff --git a/apps/api/tests/core/test_media_signing_passthrough.py b/apps/api/tests/core/test_media_signing_passthrough.py new file mode 100644 index 0000000..7211764 --- /dev/null +++ b/apps/api/tests/core/test_media_signing_passthrough.py @@ -0,0 +1,6 @@ +from app.core.media_signing import build_signed_media_url + + +def test_build_signed_media_url_passthrough(): + external = "https://cdn.example.com/avatar.png" + assert build_signed_media_url(external) == external diff --git a/apps/api/tests/core/test_password_denylist.py b/apps/api/tests/core/test_password_denylist.py new file mode 100644 index 0000000..2d472d2 --- /dev/null +++ b/apps/api/tests/core/test_password_denylist.py @@ -0,0 +1,5 @@ +from app.core.password_denylist import is_denied_password + + +def test_password_denylist_blocks_common_password(): + assert is_denied_password("password123") diff --git a/apps/api/tests/core/test_storage.py b/apps/api/tests/core/test_storage.py new file mode 100644 index 0000000..d441041 --- /dev/null +++ b/apps/api/tests/core/test_storage.py @@ -0,0 +1,5 @@ +from app.core.storage import ensure_bucket + + +def test_ensure_bucket_noop_in_memory(): + ensure_bucket() diff --git a/apps/api/tests/db/test_seed.py b/apps/api/tests/db/test_seed.py new file mode 100644 index 0000000..9b78148 --- /dev/null +++ b/apps/api/tests/db/test_seed.py @@ -0,0 +1,32 @@ +from app.db.seed import run_seed +from app.modules.users.repository import get_user_by_email + + +def test_seed_ensures_admin_is_superuser(): + admin = get_user_by_email("admin@compton.example") + assert admin is not None + admin.is_superuser = False + from app.modules.users import repository + + repository.update_user(admin) + + run_seed(include_demo_pages=False) + + refreshed = get_user_by_email("admin@compton.example") + assert refreshed is not None + assert refreshed.is_superuser is True + + +def test_seed_ensures_ops_is_not_superuser(): + ops = get_user_by_email("ops@compton.example") + assert ops is not None + ops.is_superuser = True + from app.modules.users import repository + + repository.update_user(ops) + + run_seed(include_demo_pages=False) + + refreshed = get_user_by_email("ops@compton.example") + assert refreshed is not None + assert refreshed.is_superuser is False diff --git a/apps/api/tests/db/test_token_cleanup.py b/apps/api/tests/db/test_token_cleanup.py new file mode 100644 index 0000000..e4ba24c --- /dev/null +++ b/apps/api/tests/db/test_token_cleanup.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from app.core.database import session_scope +from app.db.token_cleanup import cleanup_expired_tokens +from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken +from app.modules.users.repository import get_user_by_email + + +def test_cleanup_expired_tokens_removes_stale_rows(): + user = get_user_by_email("admin@compton.example") + now = datetime.now(UTC) + with session_scope() as db: + db.add( + RefreshToken( + user_id=user.id, + token_hash="f" * 64, + family_id="fam-cleanup-1", + expires_at=now - timedelta(days=2), + revoked_at=now - timedelta(days=2), + ) + ) + db.add( + PasswordResetToken( + user_id=user.id, + token_hash="e" * 64, + expires_at=now - timedelta(days=1), + used_at=None, + ) + ) + db.add( + EmailVerificationToken( + user_id=user.id, + token_hash="d" * 64, + expires_at=now - timedelta(days=1), + used_at=None, + ) + ) + + result = cleanup_expired_tokens(retention_days=1) + assert result["refresh_tokens_deleted"] >= 1 + assert result["password_reset_tokens_deleted"] >= 1 + assert result["email_verification_tokens_deleted"] >= 1 diff --git a/apps/api/tests/e2e/test_health.py b/apps/api/tests/e2e/test_health.py new file mode 100644 index 0000000..4c886af --- /dev/null +++ b/apps/api/tests/e2e/test_health.py @@ -0,0 +1,3 @@ +def test_health_smoke(client): + response = client.get("/api/v1/health") + assert response.status_code == 200 diff --git a/apps/api/tests/fixtures.py b/apps/api/tests/fixtures.py new file mode 100644 index 0000000..f61046b --- /dev/null +++ b/apps/api/tests/fixtures.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from io import BytesIO + +from PIL import Image + + +def make_test_png() -> bytes: + image = Image.new("RGB", (8, 8), color=(70, 129, 109)) + buffer = BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() diff --git a/apps/api/tests/helpers.py b/apps/api/tests/helpers.py new file mode 100644 index 0000000..c08cff1 --- /dev/null +++ b/apps/api/tests/helpers.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.core.email import memory_mailer + + +def clear_sent_emails() -> None: + memory_mailer.clear() + + +def latest_token(recipient: str, template: str) -> str: + token = memory_mailer.latest_token(recipient, template) + if not token: + raise AssertionError(f"No {template} email sent to {recipient}") + return token + + +def register_and_verify(client: TestClient, email: str, password: str = "Valid123") -> None: + client.post("/api/v1/auth/register", json={"email": email, "password": password}) + token = latest_token(email, "verify_email") + response = client.post("/api/v1/auth/verify-email", json={"token": token}) + assert response.status_code == 200 + + +def register_verify_login(client: TestClient, email: str, password: str = "Valid123") -> dict[str, str]: + register_and_verify(client, email, password) + login = client.post("/api/v1/auth/login", json={"email": email, "password": password}) + assert login.status_code == 200 + return {"Authorization": f"Bearer {login.json()['access_token']}"} diff --git a/apps/api/tests/modules/admin/test_admin_password_policy.py b/apps/api/tests/modules/admin/test_admin_password_policy.py new file mode 100644 index 0000000..bc5a6ce --- /dev/null +++ b/apps/api/tests/modules/admin/test_admin_password_policy.py @@ -0,0 +1,26 @@ +from __future__ import annotations + + +def _super_headers(client) -> dict[str, str]: + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + assert login.status_code == 200 + return {"Authorization": f"Bearer {login.json()['access_token']}"} + + +def test_admin_create_user_rejects_weak_password(client): + headers = _super_headers(client) + response = client.post( + "/api/v1/admin/users", + headers=headers, + json={ + "email": "weak-pass@example.com", + "password": "password", + "role": "user", + "is_superuser": False, + "status": "active", + }, + ) + assert response.status_code == 422 diff --git a/apps/api/tests/modules/admin/test_admin_router.py b/apps/api/tests/modules/admin/test_admin_router.py new file mode 100644 index 0000000..cdcd882 --- /dev/null +++ b/apps/api/tests/modules/admin/test_admin_router.py @@ -0,0 +1,73 @@ +from app.core.security import hash_password +from app.modules.users import repository + + +def _admin_headers(client): + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + token = login.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +def _plain_admin_headers(client): + email = "ops-admin@compton.example" + if not repository.get_user_by_email(email): + repository.create_user( + email=email, + password_hash=hash_password("Admin1234"), + role="admin", + is_superuser=False, + status="active", + ) + login = client.post("/api/v1/auth/login", json={"email": email, "password": "Admin1234"}) + token = login.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +def test_admin_users_list(client): + response = client.get("/api/v1/admin/users", headers=_admin_headers(client)) + assert response.status_code == 200 + assert "data" in response.json() + + +def test_non_superuser_cannot_patch_settings(client): + response = client.patch( + "/api/v1/admin/settings", + json={"values": {"enable_docs": False}}, + headers=_plain_admin_headers(client), + ) + assert response.status_code == 403 + assert response.json()["detail"] == "SUPERUSER_ONLY" + + +def test_superuser_can_patch_settings(client): + response = client.patch( + "/api/v1/admin/settings", + json={"values": {"enable_docs": False}}, + headers=_admin_headers(client), + ) + assert response.status_code == 200 + payload = response.json() + assert "values" in payload + + +def test_superuser_can_create_and_delete_user(client): + created = client.post( + "/api/v1/admin/users", + json={ + "email": "created-by-admin@compton.example", + "password": "StrongPass123A", + "role": "user", + "is_superuser": False, + "status": "active", + }, + headers=_admin_headers(client), + ) + assert created.status_code == 200 + user_id = created.json()["id"] + + deleted = client.delete(f"/api/v1/admin/users/{user_id}", headers=_admin_headers(client)) + assert deleted.status_code == 200 + assert deleted.json()["status"] == "deleted" diff --git a/apps/api/tests/modules/admin/test_secrets_router.py b/apps/api/tests/modules/admin/test_secrets_router.py new file mode 100644 index 0000000..dd181a0 --- /dev/null +++ b/apps/api/tests/modules/admin/test_secrets_router.py @@ -0,0 +1,35 @@ +from __future__ import annotations + + +def _login(client, email: str, password: str) -> dict[str, str]: + response = client.post("/api/v1/auth/login", json={"email": email, "password": password}) + assert response.status_code == 200 + return {"Authorization": f"Bearer {response.json()['access_token']}"} + + +def test_superuser_can_read_install_secrets(client): + headers = _login(client, "admin@compton.example", "Admin1234") + response = client.get("/api/v1/admin/secrets", headers=headers) + assert response.status_code == 200 + payload = response.json() + assert "secrets_status" in payload + assert payload["secrets_status"]["postgres_password"] in {"configured", "missing"} + assert "POSTGRES_PASSWORD" not in str(payload) + + +def test_non_superuser_cannot_read_install_secrets(client): + headers = _login(client, "ops@compton.example", "OpsAdmin1234") + response = client.get("/api/v1/admin/secrets", headers=headers) + assert response.status_code == 403 + + +def test_superuser_can_reveal_db_password(client): + headers = _login(client, "admin@compton.example", "Admin1234") + response = client.post( + "/api/v1/admin/secrets/reveal", + headers=headers, + json={"key": "database_password"}, + ) + assert response.status_code == 200 + assert response.json()["key"] == "database_password" + assert "value" in response.json() diff --git a/apps/api/tests/modules/admin/test_service.py b/apps/api/tests/modules/admin/test_service.py new file mode 100644 index 0000000..d55a458 --- /dev/null +++ b/apps/api/tests/modules/admin/test_service.py @@ -0,0 +1,33 @@ +from unittest.mock import patch + +from app.modules.admin.service import patch_user +from app.modules.users import repository +from app.modules.users.repository import create_user, get_user_by_email +from app.core.security import hash_password + + +def test_admin_cannot_self_demote(): + admin = get_user_by_email("admin@compton.example") + try: + patch_user(admin, admin.id, "user", None) + assert False, "Expected self-demotion error" + except ValueError as exc: + assert str(exc) == "SELF_DEMOTION_FORBIDDEN" + + +def test_admin_can_promote_user(): + admin = get_user_by_email("admin@compton.example") + regular = create_user("sample@example.com", hash_password("Valid123"), role="user", status="active") + result = patch_user(admin, regular.id, "admin", None) + assert result["role"] == "admin" + + +def test_last_admin_protected(): + admin = get_user_by_email("admin@compton.example") + target = create_user("target@example.com", hash_password("Valid123"), role="admin", status="active") + with patch.object(repository, "count_admins", return_value=1): + try: + patch_user(admin, target.id, "user", None) + assert False, "Expected last-admin protection" + except ValueError as exc: + assert str(exc) == "LAST_ADMIN_PROTECTED" diff --git a/apps/api/tests/modules/auth/test_auth_router.py b/apps/api/tests/modules/auth/test_auth_router.py new file mode 100644 index 0000000..59077e2 --- /dev/null +++ b/apps/api/tests/modules/auth/test_auth_router.py @@ -0,0 +1,42 @@ +from tests.helpers import latest_token, register_and_verify + + +def test_health(client): + response = client.get("/api/v1/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +def test_register_and_verify_and_login(client): + register_response = client.post( + "/api/v1/auth/register", + json={"email": "user@example.com", "password": "Valid123"}, + ) + assert register_response.status_code == 200 + assert "user_id" not in register_response.json() + + duplicate = client.post( + "/api/v1/auth/register", + json={"email": "user@example.com", "password": "Valid123"}, + ) + assert duplicate.status_code == 200 + assert "user_id" not in duplicate.json() + + verify_response = client.post( + "/api/v1/auth/verify-email", + json={"token": latest_token("user@example.com", "verify_email")}, + ) + assert verify_response.status_code == 200 + + login_response = client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "Valid123"}, + ) + assert login_response.status_code == 200 + assert "access_token" in login_response.json() + + +def test_forgot_password_anti_enumeration(client): + response = client.post("/api/v1/auth/forgot-password", json={"email": "missing@example.com"}) + assert response.status_code == 200 + assert "If email is registered" in response.json()["message"] diff --git a/apps/api/tests/modules/auth/test_email_tokens.py b/apps/api/tests/modules/auth/test_email_tokens.py new file mode 100644 index 0000000..182542a --- /dev/null +++ b/apps/api/tests/modules/auth/test_email_tokens.py @@ -0,0 +1,20 @@ +from tests.helpers import latest_token + + +def test_verify_email_rejects_invalid_token(client): + client.post("/api/v1/auth/register", json={"email": "bad@example.com", "password": "Valid123"}) + response = client.post("/api/v1/auth/verify-email", json={"token": "invalid-token"}) + assert response.status_code == 400 + assert response.json()["detail"] == "INVALID_TOKEN" + + +def test_resend_verification_sends_new_token(client): + client.post("/api/v1/auth/register", json={"email": "resend@example.com", "password": "Valid123"}) + first_token = latest_token("resend@example.com", "verify_email") + + client.post("/api/v1/auth/resend-verification", json={"email": "resend@example.com"}) + second_token = latest_token("resend@example.com", "verify_email") + assert first_token != second_token + + verify = client.post("/api/v1/auth/verify-email", json={"token": second_token}) + assert verify.status_code == 200 diff --git a/apps/api/tests/modules/auth/test_lockout.py b/apps/api/tests/modules/auth/test_lockout.py new file mode 100644 index 0000000..24fec7b --- /dev/null +++ b/apps/api/tests/modules/auth/test_lockout.py @@ -0,0 +1,19 @@ +from tests.helpers import latest_token, register_and_verify + + +def test_auth_brute_force_lockout(client): + register_and_verify(client, "locked@example.com") + + for _ in range(5): + response = client.post( + "/api/v1/auth/login", + json={"email": "locked@example.com", "password": "WrongPass1"}, + ) + assert response.status_code == 401 + + locked = client.post( + "/api/v1/auth/login", + json={"email": "locked@example.com", "password": "Valid123"}, + ) + assert locked.status_code == 429 + assert locked.json()["detail"] == "ACCOUNT_TEMPORARILY_LOCKED" diff --git a/apps/api/tests/modules/auth/test_password_denylist_policy.py b/apps/api/tests/modules/auth/test_password_denylist_policy.py new file mode 100644 index 0000000..7dca225 --- /dev/null +++ b/apps/api/tests/modules/auth/test_password_denylist_policy.py @@ -0,0 +1,7 @@ +from app.core.password_policy import validate_password_strength +import pytest + + +def test_password_policy_rejects_denied_password(): + with pytest.raises(ValueError, match="too common"): + validate_password_strength("Password123") diff --git a/apps/api/tests/modules/auth/test_password_policy.py b/apps/api/tests/modules/auth/test_password_policy.py new file mode 100644 index 0000000..9756e96 --- /dev/null +++ b/apps/api/tests/modules/auth/test_password_policy.py @@ -0,0 +1,12 @@ +import pytest + +from app.core.password_policy import validate_password_strength + + +def test_password_policy_accepts_valid_password(): + assert validate_password_strength("Valid123") == "Valid123" + + +def test_password_policy_rejects_weak_password(): + with pytest.raises(ValueError, match="uppercase letter"): + validate_password_strength("valid123") diff --git a/apps/api/tests/modules/auth/test_rate_limit.py b/apps/api/tests/modules/auth/test_rate_limit.py new file mode 100644 index 0000000..906324c --- /dev/null +++ b/apps/api/tests/modules/auth/test_rate_limit.py @@ -0,0 +1,22 @@ +from uuid import uuid4 + + +def test_login_rate_limit(client, monkeypatch): + from app.core.config import settings + + monkeypatch.setattr(settings, "enable_rate_limit", True) + + email = f"missing-{uuid4().hex}@example.com" + for _ in range(5): + response = client.post( + "/api/v1/auth/login", + json={"email": email, "password": "Valid123"}, + ) + assert response.status_code == 401 + + blocked = client.post( + "/api/v1/auth/login", + json={"email": email, "password": "Valid123"}, + ) + assert blocked.status_code == 429 + assert blocked.json()["detail"] == "RATE_LIMIT_EXCEEDED" diff --git a/apps/api/tests/modules/auth/test_refresh_security.py b/apps/api/tests/modules/auth/test_refresh_security.py new file mode 100644 index 0000000..1bf809d --- /dev/null +++ b/apps/api/tests/modules/auth/test_refresh_security.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from tests.helpers import register_and_verify + + +def _admin_headers(client) -> dict[str, str]: + from app.core.security import create_access_token + from app.modules.users.repository import get_user_by_email + + admin = get_user_by_email("admin@compton.example") + token = create_access_token(admin.id, admin.role, admin.is_superuser) + return {"Authorization": f"Bearer {token}"} + + +def test_refresh_fails_for_blocked_user(client): + register_and_verify(client, "blocked-refresh@example.com") + login = client.post( + "/api/v1/auth/login", + json={"email": "blocked-refresh@example.com", "password": "Valid123"}, + ) + assert login.status_code == 200 + admin_headers = _admin_headers(client) + me = client.get( + "/api/v1/users/me", + headers={"Authorization": f"Bearer {login.json()['access_token']}"}, + ) + user_id = me.json()["user"]["id"] + blocked = client.patch( + f"/api/v1/admin/users/{user_id}", + headers=admin_headers, + json={"status": "blocked"}, + ) + assert blocked.status_code == 200 + refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"}) + assert refresh.status_code == 401 + + +def test_refresh_requires_origin_header_when_cookie_present(client): + register_and_verify(client, "origin-required@example.com") + login = client.post( + "/api/v1/auth/login", + json={"email": "origin-required@example.com", "password": "Valid123"}, + ) + assert login.status_code == 200 + response = client.post("/api/v1/auth/refresh") + assert response.status_code == 403 + assert response.json()["detail"] == "INVALID_ORIGIN" diff --git a/apps/api/tests/modules/auth/test_security.py b/apps/api/tests/modules/auth/test_security.py new file mode 100644 index 0000000..befb590 --- /dev/null +++ b/apps/api/tests/modules/auth/test_security.py @@ -0,0 +1,26 @@ +from app.core.security import ( + create_access_token, + decode_access_token, + generate_refresh_token, + hash_password, + hash_refresh_token, + verify_password, +) + + +def test_password_hashing_roundtrip(): + hashed = hash_password("Strong123") + assert verify_password("Strong123", hashed) is True + + +def test_access_token_encode_decode(): + token = create_access_token("u1", "user", False) + payload = decode_access_token(token) + assert payload["sub"] == "u1" + assert payload["role"] == "user" + assert payload["is_superuser"] is False + + +def test_refresh_token_hashing(): + token = generate_refresh_token() + assert hash_refresh_token(token) == hash_refresh_token(token) diff --git a/apps/api/tests/modules/content/test_content_router.py b/apps/api/tests/modules/content/test_content_router.py new file mode 100644 index 0000000..b220e87 --- /dev/null +++ b/apps/api/tests/modules/content/test_content_router.py @@ -0,0 +1,45 @@ +def _admin_headers(client): + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + token = login.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +def test_create_and_read_published_page(client): + headers = _admin_headers(client) + created = client.post( + "/api/v1/content/pages", + headers=headers, + json={"slug": "about", "title": "About", "body": "

Hello

", "status": "published"}, + ) + assert created.status_code == 200 + + fetched = client.get("/api/v1/content/pages/about") + assert fetched.status_code == 200 + assert fetched.json()["slug"] == "about" + + +def test_list_all_pages_requires_admin(client): + response = client.get("/api/v1/content/pages/manage/all") + assert response.status_code == 401 + + +def test_list_all_pages_includes_drafts(client): + headers = _admin_headers(client) + created = client.post( + "/api/v1/content/pages", + headers=headers, + json={"slug": "draft-page", "title": "Draft", "body": "

Draft

", "status": "draft"}, + ) + assert created.status_code == 200 + + listed = client.get("/api/v1/content/pages/manage/all", headers=headers) + assert listed.status_code == 200 + slugs = [page["slug"] for page in listed.json()["data"]] + assert "draft-page" in slugs + + public = client.get("/api/v1/content/pages") + public_slugs = [page["slug"] for page in public.json()["data"]] + assert "draft-page" not in public_slugs diff --git a/apps/api/tests/modules/content/test_content_sanitize_protocols.py b/apps/api/tests/modules/content/test_content_sanitize_protocols.py new file mode 100644 index 0000000..cf89d37 --- /dev/null +++ b/apps/api/tests/modules/content/test_content_sanitize_protocols.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from app.modules.content.service import sanitize_html + + +def test_sanitize_html_strips_javascript_protocol(): + raw = 'xx' + clean = sanitize_html(raw) + assert "javascript:" not in clean diff --git a/apps/api/tests/modules/media/test_media_router.py b/apps/api/tests/modules/media/test_media_router.py new file mode 100644 index 0000000..ade0ea5 --- /dev/null +++ b/apps/api/tests/modules/media/test_media_router.py @@ -0,0 +1,21 @@ +def test_media_requires_signature(client): + response = client.get("/api/v1/media/files/avatars/missing.png") + assert response.status_code == 422 + + +def test_media_file_not_found(client): + from app.core.media_signing import build_signed_media_url + + signed_url = build_signed_media_url("/api/v1/media/files/avatars/missing.png") + response = client.get(signed_url) + assert response.status_code == 404 + assert response.json()["detail"] == "FILE_NOT_FOUND" + + +def test_media_rejects_non_avatar_path(client): + from app.core.media_signing import build_signed_media_url + + signed_url = build_signed_media_url("/api/v1/media/files/other/file.png") + response = client.get(signed_url) + assert response.status_code == 404 + assert response.json()["detail"] == "FILE_NOT_FOUND" diff --git a/apps/api/tests/modules/media/test_media_service.py b/apps/api/tests/modules/media/test_media_service.py new file mode 100644 index 0000000..c23fe7f --- /dev/null +++ b/apps/api/tests/modules/media/test_media_service.py @@ -0,0 +1,14 @@ +import pytest + +from app.modules.media.service import AvatarValidationError, validate_and_process_avatar +from tests.fixtures import make_test_png + + +def test_validate_rejects_empty(): + with pytest.raises(AvatarValidationError, match="INVALID_IMAGE"): + validate_and_process_avatar(b"") + + +def test_validate_rejects_corrupt_bytes(): + with pytest.raises(AvatarValidationError, match="INVALID_IMAGE"): + validate_and_process_avatar(b"not-an-image") diff --git a/apps/api/tests/modules/notifications/test_notifications_service.py b/apps/api/tests/modules/notifications/test_notifications_service.py new file mode 100644 index 0000000..c3e8a9d --- /dev/null +++ b/apps/api/tests/modules/notifications/test_notifications_service.py @@ -0,0 +1,7 @@ +from app.modules.notifications.service import enqueue_email + + +def test_enqueue_email_returns_payload(): + data = enqueue_email("verify", "u@example.com", {"token": "123"}) + assert data["queued"] is True + assert data["template"] == "verify" diff --git a/apps/api/tests/modules/test_api_integration.py b/apps/api/tests/modules/test_api_integration.py new file mode 100644 index 0000000..1a35791 --- /dev/null +++ b/apps/api/tests/modules/test_api_integration.py @@ -0,0 +1,170 @@ +from tests.fixtures import make_test_png +from tests.helpers import latest_token, register_and_verify, register_verify_login + + +def _admin_headers(client) -> dict[str, str]: + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + token = login.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +def test_auth_refresh_and_logout(client): + register_and_verify(client, "refresh@example.com") + login = client.post( + "/api/v1/auth/login", + json={"email": "refresh@example.com", "password": "Valid123"}, + ) + assert login.status_code == 200 + assert "refresh_token" in login.cookies + + refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"}) + assert refresh.status_code == 200 + assert "access_token" in refresh.json() + + logout = client.post("/api/v1/auth/logout", headers={"Origin": "http://localhost:5173"}) + assert logout.status_code == 200 + + +def test_auth_invalid_login(client): + response = client.post( + "/api/v1/auth/login", + json={"email": "missing@example.com", "password": "Valid123"}, + ) + assert response.status_code == 401 + + +def test_users_patch_and_password_and_avatar(client): + headers = register_verify_login(client, "patch@example.com") + patch = client.patch("/api/v1/users/me", headers=headers, json={"display_name": "Patched"}) + assert patch.status_code == 200 + assert patch.json()["profile"]["display_name"] == "Patched" + + bad_password = client.post( + "/api/v1/users/me/password", + headers=headers, + json={"current_password": "wrong", "new_password": "NewValid1"}, + ) + assert bad_password.status_code == 400 + + avatar = client.post( + "/api/v1/users/me/avatar", + headers=headers, + files={"file": ("avatar.png", make_test_png(), "image/png")}, + ) + assert avatar.status_code == 200 + assert avatar.json()["profile"]["avatar_url"] + + +def test_users_unauthorized(client): + response = client.get("/api/v1/users/me") + assert response.status_code == 401 + + +def test_content_crud_flow(client): + headers = _admin_headers(client) + created = client.post( + "/api/v1/content/pages", + headers=headers, + json={"slug": "terms", "title": "Terms", "body": "

Terms

", "status": "draft"}, + ) + assert created.status_code == 200 + page_id = created.json()["id"] + + listed = client.get("/api/v1/content/pages") + assert listed.status_code == 200 + + updated = client.patch( + f"/api/v1/content/pages/{page_id}", + headers=headers, + json={"status": "published"}, + ) + assert updated.status_code == 200 + + fetched = client.get("/api/v1/content/pages/terms") + assert fetched.status_code == 200 + + deleted = client.delete(f"/api/v1/content/pages/{page_id}", headers=headers) + assert deleted.status_code == 200 + + +def test_admin_stats_and_patch_user(client): + headers = _admin_headers(client) + stats = client.get("/api/v1/admin/stats", headers=headers) + assert stats.status_code == 200 + + user_headers = register_verify_login(client, "blockme@example.com") + me = client.get("/api/v1/users/me", headers=user_headers) + user_id = me.json()["user"]["id"] + + blocked = client.patch( + f"/api/v1/admin/users/{user_id}", + headers=headers, + json={"status": "blocked"}, + ) + assert blocked.status_code == 200 + assert blocked.json()["status"] == "blocked" + + +def test_auth_pending_user_cannot_login(client): + client.post("/api/v1/auth/register", json={"email": "pending@example.com", "password": "Valid123"}) + response = client.post( + "/api/v1/auth/login", + json={"email": "pending@example.com", "password": "Valid123"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == "EMAIL_NOT_VERIFIED" + + +def test_pending_user_cannot_access_profile(client): + from app.core.security import create_access_token + from app.modules.users.repository import get_user_by_email + + client.post("/api/v1/auth/register", json={"email": "pendingme@example.com", "password": "Valid123"}) + user = get_user_by_email("pendingme@example.com") + token = create_access_token(user.id, user.role) + response = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 403 + assert response.json()["detail"] == "EMAIL_NOT_VERIFIED" + + +def test_auth_invalid_refresh_token(client): + response = client.post("/api/v1/auth/refresh") + assert response.status_code == 401 + + client.cookies.set("refresh_token", "invalid-token", path="/api/v1/auth") + invalid = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"}) + assert invalid.status_code == 401 + + +def test_auth_resend_and_reset_password(client): + client.post("/api/v1/auth/register", json={"email": "resetme@example.com", "password": "Valid123"}) + resend = client.post("/api/v1/auth/resend-verification", json={"email": "resetme@example.com"}) + assert resend.status_code == 200 + verify_token = latest_token("resetme@example.com", "verify_email") + verify = client.post("/api/v1/auth/verify-email", json={"token": verify_token}) + assert verify.status_code == 200 + + forgot = client.post("/api/v1/auth/forgot-password", json={"email": "resetme@example.com"}) + assert forgot.status_code == 200 + reset_token = latest_token("resetme@example.com", "reset_password") + + reset = client.post( + "/api/v1/auth/reset-password", + json={"token": reset_token, "new_password": "NewValid1"}, + ) + assert reset.status_code == 200 + + login = client.post( + "/api/v1/auth/login", + json={"email": "resetme@example.com", "password": "NewValid1"}, + ) + assert login.status_code == 200 + + invalid = client.post( + "/api/v1/auth/reset-password", + json={"token": "invalid-token", "new_password": "NewValid1"}, + ) + assert invalid.status_code == 400 diff --git a/apps/api/tests/modules/users/test_avatar.py b/apps/api/tests/modules/users/test_avatar.py new file mode 100644 index 0000000..02db59c --- /dev/null +++ b/apps/api/tests/modules/users/test_avatar.py @@ -0,0 +1,42 @@ +from tests.fixtures import make_test_png +from tests.helpers import register_verify_login + + +def test_upload_avatar_success(client): + headers = register_verify_login(client, "avatar@example.com") + response = client.post( + "/api/v1/users/me/avatar", + headers=headers, + files={"file": ("avatar.png", make_test_png(), "image/png")}, + ) + assert response.status_code == 200 + avatar_url = response.json()["profile"]["avatar_url"] + assert avatar_url.startswith("/api/v1/media/files/avatars/") + + media = client.get(avatar_url) + assert media.status_code == 200 + assert media.headers["content-type"].startswith("image/") + + +def test_upload_avatar_rejects_svg(client): + headers = register_verify_login(client, "svg@example.com") + svg = b"" + response = client.post( + "/api/v1/users/me/avatar", + headers=headers, + files={"file": ("avatar.svg", svg, "image/svg+xml")}, + ) + assert response.status_code == 400 + assert response.json()["detail"] in {"INVALID_MIME", "INVALID_IMAGE"} + + +def test_upload_avatar_rejects_oversize(client): + headers = register_verify_login(client, "big@example.com") + oversized = make_test_png() + b"0" * (2 * 1024 * 1024) + response = client.post( + "/api/v1/users/me/avatar", + headers=headers, + files={"file": ("big.png", oversized, "image/png")}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "FILE_TOO_LARGE" diff --git a/apps/api/tests/modules/users/test_users_router.py b/apps/api/tests/modules/users/test_users_router.py new file mode 100644 index 0000000..4ae6164 --- /dev/null +++ b/apps/api/tests/modules/users/test_users_router.py @@ -0,0 +1,30 @@ +from tests.helpers import register_verify_login + + +def test_me_endpoint(client): + headers = register_verify_login(client, "me@example.com", "Valid123") + response = client.get("/api/v1/users/me", headers=headers) + assert response.status_code == 200 + assert response.json()["user"]["email"] == "me@example.com" + + +def test_patch_me_updates_display_name(client): + headers = register_verify_login(client, "display@example.com", "Valid123") + response = client.patch( + "/api/v1/users/me", + headers=headers, + json={"display_name": "Updated Name"}, + ) + assert response.status_code == 200 + assert response.json()["profile"]["display_name"] == "Updated Name" + + +def test_change_password_rejects_invalid_current(client): + headers = register_verify_login(client, "pwd@example.com", "Valid123") + response = client.post( + "/api/v1/users/me/password", + headers=headers, + json={"current_password": "Wrong123", "new_password": "NewValid1"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "INVALID_CURRENT_PASSWORD" diff --git a/apps/api/tests/scripts/test_docker_entrypoint.py b/apps/api/tests/scripts/test_docker_entrypoint.py new file mode 100644 index 0000000..dd1923f --- /dev/null +++ b/apps/api/tests/scripts/test_docker_entrypoint.py @@ -0,0 +1,101 @@ +from sqlalchemy import create_engine, inspect + +from scripts.docker_entrypoint import INITIAL_REVISION, current_revision, run_migrations + + +def test_run_migrations_on_empty_sqlite(tmp_path, monkeypatch): + db_path = tmp_path / "migrate.sqlite" + database_url = f"sqlite+pysqlite:///{db_path.as_posix()}" + monkeypatch.setenv("DATABASE_URL", database_url) + + from app.core.config import settings + + monkeypatch.setattr(settings, "database_url", database_url) + + engine = create_engine(database_url) + run_migrations(engine) + + tables = set(inspect(engine).get_table_names()) + assert "users" in tables + assert current_revision(engine) == "20260714_0004" + + +def test_run_migrations_stamps_existing_schema_without_alembic(tmp_path, monkeypatch): + db_path = tmp_path / "existing.sqlite" + database_url = f"sqlite+pysqlite:///{db_path.as_posix()}" + monkeypatch.setenv("DATABASE_URL", database_url) + + from app.core.config import settings + + monkeypatch.setattr(settings, "database_url", database_url) + + engine = create_engine(database_url) + with engine.begin() as connection: + connection.exec_driver_sql( + """ + CREATE TABLE users ( + id VARCHAR(36) PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(16) NOT NULL, + status VARCHAR(16) NOT NULL, + failed_login_attempts INTEGER NOT NULL, + locked_until TIMESTAMP, + email_verified_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ) + """ + ) + + run_migrations(engine) + + tables = set(inspect(engine).get_table_names()) + assert "refresh_tokens" in tables + assert current_revision(engine) == "20260714_0004" + assert INITIAL_REVISION == "20260711_0001" + + +def test_run_migrations_repairs_partial_schema_with_stale_alembic(tmp_path, monkeypatch): + db_path = tmp_path / "stale.sqlite" + database_url = f"sqlite+pysqlite:///{db_path.as_posix()}" + monkeypatch.setenv("DATABASE_URL", database_url) + + from app.core.config import settings + + monkeypatch.setattr(settings, "database_url", database_url) + + engine = create_engine(database_url) + with engine.begin() as connection: + connection.exec_driver_sql( + """ + CREATE TABLE users ( + id VARCHAR(36) PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(16) NOT NULL, + status VARCHAR(16) NOT NULL, + failed_login_attempts INTEGER NOT NULL, + locked_until TIMESTAMP, + email_verified_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ) + """ + ) + connection.exec_driver_sql( + """ + CREATE TABLE alembic_version ( + version_num VARCHAR(32) NOT NULL PRIMARY KEY + ) + """ + ) + connection.exec_driver_sql( + f"INSERT INTO alembic_version (version_num) VALUES ('{INITIAL_REVISION}')" + ) + + run_migrations(engine) + + tables = set(inspect(engine).get_table_names()) + assert "refresh_tokens" in tables + assert current_revision(engine) == "20260714_0004" diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000..a6022bf --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +coverage +.git +*.log +.env +.env.* diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..bb9ac1c --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,6 @@ +# Copy to .env for local dev: cp .env.example .env +# In dev, API proxy is enabled by default (requests go through Vite, not cross-origin). +VITE_USE_API_PROXY=true +VITE_API_URL=http://localhost:8000 +VITE_APP_NAME=Compton +VITE_SENTRY_DSN= diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..9fe76d8 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,31 @@ +# Monorepo dev image: build context must be the repository root (see docker-compose.yml). +FROM node:22-alpine + +# pnpm 9+ via Corepack (bundled with Node 22) +RUN corepack enable && corepack prepare pnpm@9.15.9 --activate + +WORKDIR /app + +# --- dependency layer: copy only manifests so `pnpm install` can be cached --- +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/web/package.json ./apps/web/ +COPY packages/shared-types/package.json ./packages/shared-types/ +COPY packages/eslint-config/package.json ./packages/eslint-config/ + +# Workspace packages referenced by the lockfile (minimal source for install/link) +COPY packages/shared-types ./packages/shared-types/ +COPY packages/eslint-config ./packages/eslint-config/ + +RUN pnpm install --frozen-lockfile + +# App source baked into the image; at runtime bind-mount overrides for live-reload +COPY apps/web ./apps/web/ + +EXPOSE 5173 + +# Chokidar polling helps file watching on Docker Desktop (Windows/macOS bind mounts) +ENV CHOKIDAR_USEPOLLING=true +ENV WATCHPACK_POLLING=true + +# `pnpm exec` runs vite directly in the web workspace (avoids `--` arg forwarding issues) +CMD ["pnpm", "--filter", "web", "exec", "vite", "--host", "0.0.0.0"] diff --git a/apps/web/app.html b/apps/web/app.html new file mode 100644 index 0000000..1411558 --- /dev/null +++ b/apps/web/app.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + Compton + + +
+ + + diff --git a/apps/web/e2e/admin/admin-users.spec.ts b/apps/web/e2e/admin/admin-users.spec.ts new file mode 100644 index 0000000..aac2eca --- /dev/null +++ b/apps/web/e2e/admin/admin-users.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "@playwright/test"; +import { API_URL, adminLogin, loginViaUi, registerVerifyLogin, uniqueEmail } from "../helpers/api"; + +test.describe("§15.7 scenarios 5 & 9: Admin users", () => { + test("admin blocks user → blocked user cannot login", async ({ page, request }) => { + const email = uniqueEmail("e2e-block"); + const session = await registerVerifyLogin(request, email); + const admin = await adminLogin(request); + const adminToken = (await admin.json()).access_token; + + const patch = await request.patch(`${API_URL}/api/v1/admin/users/${session.user.id}`, { + headers: { Authorization: `Bearer ${adminToken}` }, + data: { status: "blocked" } + }); + expect(patch.ok()).toBeTruthy(); + + await loginViaUi(page, email, "Valid1234"); + await expect(page).toHaveURL(/\/login$/); + }); + + test("admin can open admin users page", async ({ page }) => { + await loginViaUi(page, "admin@compton.example", "Admin1234"); + await expect(page).toHaveURL(/\/admin$/); + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + }); + + test("admin session survives reload on /admin", async ({ page }) => { + await loginViaUi(page, "admin@compton.example", "Admin1234"); + await expect(page).toHaveURL(/\/admin$/); + await page.goto("/admin"); + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + }); + + test("non-admin cannot open admin page", async ({ page, request }) => { + const email = uniqueEmail("e2e-nonadmin"); + await registerVerifyLogin(request, email); + await loginViaUi(page, email, "Valid1234"); + await expect(page).toHaveURL(/\/profile$/); + await page.goto("/admin"); + await expect(page).toHaveURL(/\/$/); + }); + + test("admin cannot block self", async ({ request }) => { + const admin = await adminLogin(request); + const body = await admin.json(); + const response = await request.patch(`${API_URL}/api/v1/admin/users/${body.user.id}`, { + headers: { Authorization: `Bearer ${body.access_token}` }, + data: { status: "blocked" } + }); + expect(response.status()).toBe(400); + expect((await response.json()).detail).toBe("SELF_BLOCK_FORBIDDEN"); + }); + + test("admin cannot demote self", async ({ request }) => { + const admin = await adminLogin(request); + const body = await admin.json(); + const response = await request.patch(`${API_URL}/api/v1/admin/users/${body.user.id}`, { + headers: { Authorization: `Bearer ${body.access_token}` }, + data: { role: "user" } + }); + expect(response.status()).toBe(400); + expect((await response.json()).detail).toBe("SELF_DEMOTION_FORBIDDEN"); + }); +}); diff --git a/apps/web/e2e/auth/login.spec.ts b/apps/web/e2e/auth/login.spec.ts new file mode 100644 index 0000000..80b5cfe --- /dev/null +++ b/apps/web/e2e/auth/login.spec.ts @@ -0,0 +1,6 @@ +import { expect, test } from "@playwright/test"; + +test("login page renders", async ({ page }) => { + await page.goto("/login"); + await expect(page.getByRole("heading", { name: "Вход" })).toBeVisible(); +}); diff --git a/apps/web/e2e/auth/password-reset.spec.ts b/apps/web/e2e/auth/password-reset.spec.ts new file mode 100644 index 0000000..8096d75 --- /dev/null +++ b/apps/web/e2e/auth/password-reset.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from "@playwright/test"; +import { API_URL, fetchLatestToken, registerVerifyLogin, uniqueEmail } from "../helpers/api"; + +test.describe("§15.7 scenario 3: Password reset", () => { + test("forgot password → reset → login with new password", async ({ request }) => { + const email = uniqueEmail("e2e-reset"); + const oldPassword = "Valid1234"; + const newPassword = "ResetValid1"; + + await registerVerifyLogin(request, email, oldPassword); + + const forgot = await request.post(`${API_URL}/api/v1/auth/forgot-password`, { + data: { email } + }); + expect(forgot.ok()).toBeTruthy(); + + const token = await fetchLatestToken(request, email, "reset_password"); + const reset = await request.post(`${API_URL}/api/v1/auth/reset-password`, { + data: { token, new_password: newPassword } + }); + expect(reset.ok()).toBeTruthy(); + + const oldLogin = await request.post(`${API_URL}/api/v1/auth/login`, { + data: { email, password: oldPassword } + }); + expect(oldLogin.status()).toBe(401); + + const newLogin = await request.post(`${API_URL}/api/v1/auth/login`, { + data: { email, password: newPassword } + }); + expect(newLogin.ok()).toBeTruthy(); + }); +}); diff --git a/apps/web/e2e/auth/refresh-rotation.spec.ts b/apps/web/e2e/auth/refresh-rotation.spec.ts new file mode 100644 index 0000000..01fc78f --- /dev/null +++ b/apps/web/e2e/auth/refresh-rotation.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; +import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api"; + +function extractRefreshCookie(headers: Record): string { + const setCookie = headers["set-cookie"] ?? headers["Set-Cookie"] ?? ""; + const match = setCookie.match(/refresh_token=([^;]+)/); + if (!match) { + throw new Error("refresh_token cookie missing"); + } + return match[1]; +} + +test.describe("§15.7 scenario 7: Refresh rotation", () => { + test("old refresh token rejected after rotation; reuse revokes family", async ({ request }) => { + const email = uniqueEmail("e2e-refresh"); + await registerVerifyLogin(request, email); + + const login = await request.post(`${API_URL}/api/v1/auth/login`, { data: { email, password: "Valid1234" } }); + const oldRefresh = extractRefreshCookie(login.headers()); + + const rotated = await request.post(`${API_URL}/api/v1/auth/refresh`, { + headers: { Cookie: `refresh_token=${oldRefresh}` } + }); + expect(rotated.ok()).toBeTruthy(); + const newRefresh = extractRefreshCookie(rotated.headers()); + + const oldReuse = await request.post(`${API_URL}/api/v1/auth/refresh`, { + headers: { Cookie: `refresh_token=${oldRefresh}` } + }); + expect(oldReuse.status()).toBe(401); + + const familyReuse = await request.post(`${API_URL}/api/v1/auth/refresh`, { + headers: { Cookie: `refresh_token=${newRefresh}` } + }); + expect(familyReuse.status()).toBe(401); + }); +}); diff --git a/apps/web/e2e/auth/register-login-flow.spec.ts b/apps/web/e2e/auth/register-login-flow.spec.ts new file mode 100644 index 0000000..f1c7077 --- /dev/null +++ b/apps/web/e2e/auth/register-login-flow.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; +import { + loginViaUi, + registerUser, + uniqueEmail, + verifyEmail +} from "../helpers/api"; + +test.describe("§15.7 scenario 2: Auth full journey", () => { + test("register → verify → login → profile edit → change password → logout", async ({ + page, + request + }) => { + const email = uniqueEmail("e2e-flow"); + const password = "Valid1234"; + const newPassword = "NewValid1"; + + await registerUser(request, email, password); + await verifyEmail(request, email); + + await loginViaUi(page, email, password); + await expect(page).toHaveURL(/\/profile$/); + await expect(page.getByText(email)).toBeVisible(); + + await page.getByLabel("Display name").fill("E2E User"); + await page.getByRole("button", { name: "Save name" }).click(); + await expect(page.getByText("Profile updated")).toBeVisible(); + + await page.getByPlaceholder("Current password").fill(password); + await page.getByPlaceholder("New password").fill(newPassword); + await page.getByRole("button", { name: "Change password" }).click(); + await expect(page.getByText("Password changed")).toBeVisible(); + + await page.getByRole("button", { name: "Logout" }).click(); + await expect(page).toHaveURL(/\/login$/); + + await loginViaUi(page, email, newPassword); + await expect(page).toHaveURL(/\/profile$/); + }); +}); diff --git a/apps/web/e2e/content/content-pages.spec.ts b/apps/web/e2e/content/content-pages.spec.ts new file mode 100644 index 0000000..e67eef2 --- /dev/null +++ b/apps/web/e2e/content/content-pages.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test"; +import { API_URL, adminLogin } from "../helpers/api"; + +test.describe("§15.7 scenario 4: Admin content publish", () => { + test("admin creates content → publish → visible on /pages/:slug", async ({ page, request }) => { + const admin = await adminLogin(request); + const headers = { Authorization: `Bearer ${(await admin.json()).access_token}` }; + const slug = `e2e-page-${Date.now()}`; + + const created = await request.post(`${API_URL}/api/v1/content/pages`, { + headers, + data: { + slug, + title: "E2E Published Page", + body: "

Published by admin

", + status: "published" + } + }); + expect(created.ok()).toBeTruthy(); + + await page.goto(`/pages/${slug}`); + await expect(page.getByRole("heading", { name: "E2E Published Page" })).toBeVisible(); + await expect(page.getByText("Published by admin")).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/helpers/api.ts b/apps/web/e2e/helpers/api.ts new file mode 100644 index 0000000..7aff881 --- /dev/null +++ b/apps/web/e2e/helpers/api.ts @@ -0,0 +1,64 @@ +import { APIRequestContext } from "@playwright/test"; + +export const API_URL = process.env.E2E_API_URL ?? "http://127.0.0.1:8001"; + +export function uniqueEmail(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`; +} + +export async function registerUser(request: APIRequestContext, email: string, password = "Valid1234") { + const response = await request.post(`${API_URL}/api/v1/auth/register`, { + data: { email, password } + }); + return response; +} + +export async function fetchLatestToken( + request: APIRequestContext, + email: string, + template: "verify_email" | "reset_password" +): Promise { + const response = await request.get(`${API_URL}/api/v1/test/emails/latest-token`, { + params: { to: email, template } + }); + if (!response.ok()) { + throw new Error(`Token not found for ${email} (${template})`); + } + const body = await response.json(); + return body.token as string; +} + +export async function verifyEmail(request: APIRequestContext, email: string) { + const token = await fetchLatestToken(request, email, "verify_email"); + return request.post(`${API_URL}/api/v1/auth/verify-email`, { data: { token } }); +} + +export async function loginApi(request: APIRequestContext, email: string, password = "Valid1234") { + return request.post(`${API_URL}/api/v1/auth/login`, { data: { email, password } }); +} + +export async function adminLogin(request: APIRequestContext) { + return loginApi(request, "admin@compton.example", "Admin1234"); +} + +export async function registerVerifyLogin( + request: APIRequestContext, + email: string, + password = "Valid1234" +) { + await registerUser(request, email, password); + await verifyEmail(request, email); + const login = await loginApi(request, email, password); + const body = await login.json(); + return { + accessToken: body.access_token as string, + user: body.user as { id: string; email: string; role: string; status: string } + }; +} + +export async function loginViaUi(page: import("@playwright/test").Page, email: string, password: string) { + await page.goto("/login"); + await page.getByPlaceholder("Email").fill(email); + await page.getByPlaceholder("Password").fill(password); + await page.getByRole("button", { name: "Login" }).click(); +} diff --git a/apps/web/e2e/landing/landing.spec.ts b/apps/web/e2e/landing/landing.spec.ts new file mode 100644 index 0000000..19ad1c8 --- /dev/null +++ b/apps/web/e2e/landing/landing.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test"; + +test.describe("§15.7 scenario 1: Landing", () => { + test("hero and marquee are visible", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: /Технологии будущего на вашей ферме/i })).toBeVisible(); + await expect(page.locator("#integrations .marquee-track")).toBeVisible(); + }); + + test("respects prefers-reduced-motion", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + await expect(page.getByRole("heading", { name: /Технологии будущего на вашей ферме/i })).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/profile/avatar-validation.spec.ts b/apps/web/e2e/profile/avatar-validation.spec.ts new file mode 100644 index 0000000..30a9d76 --- /dev/null +++ b/apps/web/e2e/profile/avatar-validation.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test"; +import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api"; + +const tinyPng = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64" +); + +test.describe("§15.7 scenario 10: Avatar validation", () => { + test("rejects svg, oversize and accepts valid png", async ({ request }) => { + const session = await registerVerifyLogin(request, uniqueEmail("e2e-avatar")); + const headers = { Authorization: `Bearer ${session.accessToken}` }; + + const svg = await request.post(`${API_URL}/api/v1/users/me/avatar`, { + headers, + multipart: { + file: { + name: "avatar.svg", + mimeType: "image/svg+xml", + buffer: Buffer.from("") + } + } + }); + expect(svg.status()).toBe(400); + + const oversize = await request.post(`${API_URL}/api/v1/users/me/avatar`, { + headers, + multipart: { + file: { + name: "big.png", + mimeType: "image/png", + buffer: Buffer.concat([tinyPng, Buffer.alloc(2 * 1024 * 1024 + 1)]) + } + } + }); + expect(oversize.status()).toBe(400); + expect((await oversize.json()).detail).toBe("FILE_TOO_LARGE"); + + const valid = await request.post(`${API_URL}/api/v1/users/me/avatar`, { + headers, + multipart: { + file: { + name: "avatar.png", + mimeType: "image/png", + buffer: tinyPng + } + } + }); + expect(valid.ok()).toBeTruthy(); + expect((await valid.json()).profile.avatar_url).toContain("/api/v1/media/files/avatars/"); + }); +}); diff --git a/apps/web/e2e/profile/profile.spec.ts b/apps/web/e2e/profile/profile.spec.ts new file mode 100644 index 0000000..602f88b --- /dev/null +++ b/apps/web/e2e/profile/profile.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; +import { registerUser, uniqueEmail } from "../helpers/api"; + +test.describe("§15.7 scenario 6: Pending user", () => { + test("pending user cannot access /profile", async ({ page, request }) => { + const email = uniqueEmail("e2e-pending"); + await registerUser(request, email, "Valid1234"); + + await page.goto("/profile"); + await expect(page).toHaveURL(/\/login$/); + + await page.goto("/login"); + await page.getByPlaceholder("Email").fill(email); + await page.getByPlaceholder("Password").fill("Valid1234"); + await page.getByRole("button", { name: "Login" }).click(); + await expect(page).toHaveURL(/\/login$/); + }); +}); diff --git a/apps/web/e2e/security/idor.spec.ts b/apps/web/e2e/security/idor.spec.ts new file mode 100644 index 0000000..7890ed5 --- /dev/null +++ b/apps/web/e2e/security/idor.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test"; +import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api"; + +test.describe("§15.7 scenario 8: IDOR", () => { + test("user A cannot patch user B via admin route", async ({ request }) => { + const userA = await registerVerifyLogin(request, uniqueEmail("e2e-a")); + const userB = await registerVerifyLogin(request, uniqueEmail("e2e-b")); + + const forbidden = await request.patch(`${API_URL}/api/v1/admin/users/${userB.user.id}`, { + headers: { Authorization: `Bearer ${userA.accessToken}` }, + data: { status: "blocked" } + }); + expect(forbidden.status()).toBe(403); + }); +}); diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..756c425 --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,39 @@ +import tsParser from "@typescript-eslint/parser"; +import tsPlugin from "@typescript-eslint/eslint-plugin"; +import boundaries from "eslint-plugin-boundaries"; + +export default [ + { + files: ["src/**/*.{ts,tsx}"], + languageOptions: { + parser: tsParser, + parserOptions: { project: "./tsconfig.json" } + }, + plugins: { + "@typescript-eslint": tsPlugin, + boundaries + }, + rules: { + "boundaries/element-types": [ + "error", + { + default: "disallow", + rules: [ + { from: "app", allow: ["app", "pages", "modules", "shared"] }, + { from: "pages", allow: ["pages", "modules", "shared"] }, + { from: "modules", allow: ["modules", "shared"] }, + { from: "shared", allow: ["shared"] } + ] + } + ] + }, + settings: { + "boundaries/elements": [ + { type: "app", pattern: "src/app/*" }, + { type: "pages", pattern: "src/pages/*" }, + { type: "modules", pattern: "src/modules/*" }, + { type: "shared", pattern: "src/shared/*" } + ] + } + } +]; diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..405bb4c --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,283 @@ + + + + + + Комптон + + + + + + + + + + + + + +
+ +
+

Технологии будущего на вашей ферме

+

+ Интеллектуальная система контроля кормления КРС на основе искусственного интеллекта. + Снижаем затраты на корма, повышаем продуктивность стада и даём полный контроль над каждым этапом. +

+ +
+
+ + +
+
+
+ +

Как устроена наша система

+
+
+ +
+
+ План рационов +
+

План рационов

+

Зоотехник составляет и корректирует рацион для каждой группы КРС в удобной программе на офисном ПК, ноутбуке или смартфоне.

+
+ +
+
+ Синхронизация данных +
+

Синхронизация данных

+

Созданное задание мгновенно и без проводов передается на весовой терминал кормосмесителя.

+
+ +
+
+ Точная загрузка +
+

Точная загрузка

+

Тракторист на дисплее видит подсказки и точный вес каждого компонента на лету, что исключает ошибки перегруза.

+
+ +
+
+ Контроль руководителя +
+

Контроль руководителя

+

Собственник или управляющий получает автоматический отчет о всех отклонениях и реальном расходе прямо на смартфон.

+
+
+
+
+ + + + +
+
+ +
+

От формулы до кормушки

+
+ + +
+

«Каждый килограмм корма — под контролем.
Каждая копейка — на счету. Каждая минута — сэкономлена»

+
+
+
+ + +
+
+

Работа на любом устройстве

+

+ Управляйте системой кормления с телефона, планшета или компьютера — интерфейс адаптируется под любой экран. +

+ +
+
+

Всегда на связи с фермой Абсолютный контроль рационов и остатков в любом месте, в любое время и с любого гаджета.

+
+ +
+ Комптон всегда под рукой +
+ +
+

Прозрачность и контроль Вы всегда видите, сколько корма съедено, как меняется продуктивность и где можно сэкономить без потери качества.

+
+
+
+
+ + +
+
+

Начни кормить по новому!

+

+ Всё, что нужно для точного, экономного и эффективного кормления КРС. +

+ +
+
+
+ Индивидуальный рацион +
+
+
+ +
+

Автоматический расчет рационов

+

Программа сама подбирает оптимальный состав корма для каждой группы животных с учётом возраста, веса и продуктивности.

+
+
+ +
+
+ Удобное редактирование рационов +
+
+
+ +
+

Удобное редактирование рационов

+

Редактируйте рацион в один клик: меняйте рецепты местами, добавляйте новые компоненты и мгновенно корректируйте сухое вещество.

+
+
+ +
+
+ Прогноз продуктивности +
+
+
+ +
+

Учет кормов

+

Автоматический учет остатков: программа точно рассчитывает остатки кормов на складе и прогнозирует дату следующей закупки на основе текущего расхода, защищая от внезапного дефицита.

+
+
+ +
+
+ Визуализация экономии кормов +
+
+
+ +
+

Гибкая система уведомлений

+

Умная система уведомлений — ваш новый помощник, который следит за процессом кормления КРС и мгновенно предупреждает команду фермы о любых сбоях. Она заменяет ручной контроль автоматическим мониторингом.

+
+
+ +
+
+ Анализ микроклимата +
+
+
+ +
+

Анализ микроклимата

+

Интеграция с системами климат-контроля: учитываем температуру и влажность для коррекции потребности в питании.

+
+
+ +
+
+ Единая платформа +
+
+
+ +
+

Единая платформа

+

Все данные о кормлении, здоровье и продуктивности в одном окне – для быстрого принятия решений.

+
+
+
+
+
+ + +
+
+ ✦ АГРО-ХОЛДИНГ + ✦ МОЛОЧНЫЙ КОМБИНАТ + ✦ ФЕРМА №1 + ✦ ЗЕРНО-ТРЕЙД + ✦ ВЕТЕРИНАРНАЯ СЛУЖБА + ✦ КОРМОВОЙ ЦЕНТР + ✦ ПЛЕМЗАВОД + ✦ АГРО-ИНТЕЛЛЕКТ + ✦ АГРО-ХОЛДИНГ + ✦ МОЛОЧНЫЙ КОМБИНАТ + ✦ ФЕРМА №1 + ✦ ЗЕРНО-ТРЕЙД + ✦ ВЕТЕРИНАРНАЯ СЛУЖБА + ✦ КОРМОВОЙ ЦЕНТР + ✦ ПЛЕМЗАВОД + ✦ АГРО-ИНТЕЛЛЕКТ +
+
+ + +
+
+

Внедряйте технологии будущего

+

+ Получите консультацию по настройке системы для вашего хозяйства. + Первые 2 месяца – полная поддержка и мониторинг. +

+ Оставить заявку +
+
+ + + + + + + \ No newline at end of file diff --git a/apps/web/main/css/style.css b/apps/web/main/css/style.css new file mode 100644 index 0000000..6d78a81 --- /dev/null +++ b/apps/web/main/css/style.css @@ -0,0 +1,1094 @@ +/* ========== ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ ========== */ + @import "./tokens.css"; + + /* ========== БАЗА ========== */ + *, + *::before, + *::after { + margin: 0; + padding: 0; + box-sizing: border-box; + } + html { + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + } + body { + + font-family: var(--font-sans); + background-color: var(--bg); + color: var(--foreground); + line-height: 1.6; + padding-top: 80px; + } + .container { + max-width: 1280px; + margin: 0 auto; + padding-left: 2rem; + padding-right: 2rem; + } + .section-padding { + padding: clamp(3rem, 10vh, 6rem) 0; + } + + /* ========== ТИПОГРАФИКА ========== */ + h1, + h2, + h3 { + font-weight: 500; + letter-spacing: -0.02em; + line-height: 1.1; + } + h1 { + font-size: clamp(2.5rem, 8vw, 5rem); + } + h2 { + font-size: clamp(2rem, 5vw, 3.5rem); + } + h3 { + font-size: 1.5rem; + } + .text-muted { + color: var(--muted); + } + .text-mono { + font-family: var(--font-mono); + font-weight: 400; + } + + /* ========== КНОПКИ ========== */ + .btn { + display: inline-block; + font-family: var(--font-sans); + font-weight: 500; + font-size: 1rem; + padding: 0.7rem 1.8rem; + border-radius: var(--radius-btn); + border: 1px solid transparent; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + text-align: center; + } + .btn-primary { + background-color: var(--primary); + color: #fff; + border-color: var(--primary); + } + .btn-primary:hover { + background-color: var(--primary-dark); + border-color: var(--primary-dark); + transform: translateY(-2px); + } + .btn-outline { + background-color: #fff; + color: var(--foreground); + border-color: #fff; + } + .btn-outline:hover { + background-color: rgba(255, 255, 255, 0.85); + border-color: rgba(255, 255, 255, 0.85); + transform: translateY(-2px); + } + + /* ========== НАВБАР ========== */ + .navbar { + position: fixed; + top: 0; + left: 0; + width: 100%; + z-index: 100; + background-color: rgba(246, 246, 244, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + } + .navbar .container { + display: flex; + align-items: center; + justify-content: space-between; + height: 80px; + padding-top: 0; + padding-bottom: 0; + } + .navbar-logo { + display: flex; + align-items: center; + text-decoration: none; + height: 100%; + } + .navbar-logo img { + height: 100px; + width: auto; + display: block; + transition: opacity 0.2s ease; + } + .navbar-logo:hover img { + opacity: 0.85; + } + .navbar-menu { + display: flex; + align-items: center; + gap: 2.5rem; + list-style: none; + } + .navbar-menu a { + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--muted); + text-decoration: none; + transition: color 0.2s ease; + } + .navbar-menu a:hover { + color: var(--foreground); + } + .burger-checkbox { + display: none; + } + .burger-icon { + display: none; + flex-direction: column; + gap: 5px; + cursor: pointer; + padding: 4px; + } + .burger-icon span { + display: block; + width: 26px; + height: 2px; + background-color: var(--foreground); + border-radius: 4px; + transition: all 0.3s ease; + } + + /* ========== HERO С ВИДЕО ========== */ + .hero { + min-height: 90vh; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + position: relative; + overflow: hidden; + } + .hero-video { + position: absolute; + inset: 0; + z-index: 0; + width: 100%; + height: 100%; + object-fit: cover; + filter: blur(2px) brightness(0.7) saturate(0.9); + transform: scale(1.05); + transition: filter 0.5s ease; + } + .hero::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + background: + radial-gradient(ellipse at 30% 50%, rgba(72, 129, 109, 0.25) 0%, transparent 60%), + radial-gradient(ellipse at 70% 80%, rgba(26, 30, 28, 0.30) 0%, transparent 50%), + linear-gradient(180deg, rgba(246, 246, 244, 0.15) 0%, rgba(246, 246, 244, 0.05) 40%, rgba(26, 30, 28, 0.20) 100%); + pointer-events: none; + } + .hero .container { + position: relative; + z-index: 2; + } + .hero h1 { + max-width: 900px; + margin: 0 auto 1.5rem; + color: #fff; + text-shadow: 0 4px 30px rgba(0, 0, 0, 0.3); + } + .hero .subtitle { + font-size: clamp(1rem, 2vw, 1.4rem); + color: rgba(255, 255, 255, 0.85); + max-width: 640px; + margin: 0 auto 2.5rem; + line-height: 1.6; + text-shadow: 0 2px 20px rgba(0, 0, 0, 0.2); + } + .hero .btn-group { + display: flex; + gap: 1rem; + justify-content: center; + flex-wrap: wrap; + } + @media (max-width: 768px) { + .hero-video { + filter: blur(4px) brightness(0.6) saturate(0.8); + transform: scale(1.1); + } + .hero h1 { + text-shadow: 0 4px 40px rgba(0, 0, 0, 0.5); + } + .hero .subtitle { + text-shadow: 0 2px 30px rgba(0, 0, 0, 0.4); + } + } + + /* ========== БЛОК С ЗАГОЛОВКОМ, ИЗОБРАЖЕНИЕМ И ВЫЕЗЖАЮЩИМ ТЕКСТОМ ========== */ + .expand-block { + background-color: var(--bg); + border-bottom: 1px solid var(--card-border); + overflow: hidden; + } + .expand-block .container { + padding-top: 1.5rem; + padding-bottom: 1.5rem; + } + .expand-block .section-title { + text-align: center; + margin-bottom: 0.5rem; + } + .expand-block .section-subtitle { + text-align: center; + color: var(--muted); + max-width: 600px; + margin: 0 auto 2.5rem; + font-size: 1.1rem; + line-height: 1.6; + } + .expand-content { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-height: 320px; + overflow: visible; + } + .image-wrapper { + flex-shrink: 0; + width: 55%; + max-width: 650px; + border-radius: var(--radius-card); + overflow: hidden; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.05); + background: #fff; + z-index: 2; + transition: transform 0.8s cubic-bezier(0.25, 0.8, 0.25, 1), + width 0.8s cubic-bezier(0.25, 0.8, 0.25, 1); + transform: scale(1); + } + .image-wrapper img { + width: 100%; + height: auto; + display: block; + } + /* Боковые панели — ширина 22%, сдвинуты наружу */ + .side-panel { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 22%; + max-width: 280px; + padding: 1.5rem; + z-index: 1; + opacity: 0; + transition: opacity 0.6s ease 0.2s, + transform 0.8s cubic-bezier(0.25, 0.8, 0.25, 1); + background: transparent; + pointer-events: none; + } + .side-panel.left { + left: -2rem; + transform: translateY(-50%) translateX(-120%); + text-align: right; + } + .side-panel.right { + right: -2rem; + transform: translateY(-50%) translateX(120%); + text-align: left; + } + .side-panel p { + margin: 0; + font-size: 1rem; + line-height: 1.6; + color: var(--muted); + } + .side-panel p strong { + display: block; + color: var(--primary); + margin-bottom: 0.3rem; + } + + /* Состояние expanded */ + .expand-content.expanded .image-wrapper { + transform: scale(1.08); + width: 60%; + } + .expand-content.expanded .side-panel { + opacity: 1; + transform: translateY(-50%) translateX(0); + pointer-events: auto; + } + + /* ========== АДАПТИВ ДЛЯ ПЛАНШЕТОВ ========== */ + @media (max-width: 1024px) and (min-width: 769px) { + .image-wrapper { + width: 60%; + max-width: 500px; + } + .side-panel { + width: 22%; + max-width: 220px; + left: -1rem; + right: -1rem; + } + } + + /* ========== АДАПТИВ ДЛЯ МОБИЛЬНЫХ ========== */ + @media (max-width: 768px) { + .expand-block .section-subtitle { + margin-bottom: 1.5rem; + font-size: 1rem; + } + .expand-content { + flex-wrap: wrap; + min-height: auto; + padding: 0 0.5rem; + } + .image-wrapper { + width: 100% !important; + max-width: 100%; + transform: none !important; + margin: 1rem 0; + order: 1; + } + .side-panel { + position: static; + transform: none !important; + width: 100% !important; + max-width: 100%; + opacity: 1 !important; + padding: 0.5rem 1rem; + text-align: center !important; + order: 2; + pointer-events: auto; + left: auto !important; + right: auto !important; + } + .side-panel.left { + order: 0; + } + .side-panel.right { + order: 3; + } + .side-panel p { + font-size: 0.95rem; + } + } + + /* ========== КАРТОЧКИ ========== */ + .cards-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2rem; + } + .card { + background-color: var(--card); + border: 1px solid var(--card-border); + border-radius: var(--radius-card); + padding: 2rem; + transition: all 0.3s ease; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.02); + will-change: transform; + display: flex; + flex-direction: column; + } + .card:hover { + transform: translateY(-5px); + box-shadow: 0 12px 30px rgba(72, 129, 109, 0.08); + } + .card-icon { + width: 48px; + height: 48px; + border-radius: var(--radius-icon); + background-color: var(--primary-tint); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.2rem; + color: var(--primary); + flex-shrink: 0; + } + .card-icon svg { + width: 28px; + height: 28px; + stroke: currentColor; + stroke-width: 1.5; + fill: none; + } + .card h3 { + margin-bottom: 0.6rem; + } + .card p { + color: var(--muted); + line-height: 1.5; + font-size: 0.95rem; + flex: 1; + } + + /* ===== СПЕЦИАЛЬНЫЕ ПРАВИЛА ДЛЯ КАРТОЧЕК С GIF ===== */ + .card-with-gif { + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; + } + .card-with-gif .card-gif-wrapper { + margin: 0; + border-radius: 0; + background: none; + box-shadow: none; + flex-shrink: 0; + overflow: hidden; + } + .card-with-gif .card-gif-wrapper img { + display: block; + width: 100%; + height: auto; + } + .card-with-gif .card-body { + padding: 1.5rem; + flex: 1; + } + .card-with-gif .card-icon { + margin-bottom: 1rem; + } + + /* ========== БЕГУЩАЯ СТРОКА ========== */ + .marquee-section { + background-color: #EBEBE5; + padding: 2rem 0; + overflow: hidden; + position: relative; + } + .marquee-track { + display: flex; + width: max-content; + animation: marqueeScroll 30s linear infinite; + will-change: transform; + } + .marquee-track .logo-item { + flex-shrink: 0; + padding: 0 3rem; + font-family: var(--font-mono); + font-weight: 500; + font-size: 1.3rem; + letter-spacing: 0.06em; + color: var(--muted); + white-space: nowrap; + opacity: 0.6; + transition: opacity 0.3s ease; + } + .marquee-track .logo-item:hover { + opacity: 1; + } + @keyframes marqueeScroll { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } + } + + /* ========== CTA ========== */ + .cta-section { + text-align: center; + } + .cta-section h2 { + max-width: 700px; + margin: 0 auto 1.5rem; + } + .cta-section p { + max-width: 560px; + margin: 0 auto 2rem; + color: var(--muted); + font-size: 1.1rem; + } + + /* ========== ФУТЕР ========== */ + .footer { + background-color: var(--footer-bg); + color: rgba(255, 255, 255, 0.6); + padding: 3rem 0; + margin-top: 2rem; + } + .footer .container { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1.5rem; + } + .footer a { + color: rgba(255, 255, 255, 0.5); + text-decoration: none; + font-family: var(--font-mono); + font-size: 0.85rem; + transition: color 0.2s ease; + } + .footer a:hover { + color: #fff; + } + .footer .footer-logo { + display: flex; + align-items: center; + text-decoration: none; + } + .footer .footer-logo img { + height: 32px; + width: auto; + display: block; + filter: brightness(0) invert(1); + transition: opacity 0.2s ease; + } + .footer .footer-logo:hover img { + opacity: 0.8; + } + + /* ========== FADE-UP ========== */ + .fade-up { + opacity: 0; + transform: translateY(30px); + transition: opacity 0.7s ease, transform 0.7s ease; + will-change: opacity, transform; + } + .fade-up.visible { + opacity: 1; + transform: translateY(0); + } + .card:nth-child(1) { + transition-delay: 0.05s; + } + .card:nth-child(2) { + transition-delay: 0.12s; + } + .card:nth-child(3) { + transition-delay: 0.19s; + } + .card:nth-child(4) { + transition-delay: 0.26s; + } + .card:nth-child(5) { + transition-delay: 0.33s; + } + .card:nth-child(6) { + transition-delay: 0.40s; + } + + .scramble-target { + font-family: var(--font-mono); + font-weight: 500; + } + + /* ========== ОБЩАЯ АДАПТИВНОСТЬ ========== */ + @media (max-width: 1024px) and (min-width: 769px) { + .cards-grid { + grid-template-columns: repeat(2, 1fr); + } + } + @media (max-width: 768px) { + body { + padding-top: 68px; + } + .navbar .container { + height: 68px; + } + .navbar-logo img { + height: 32px; + } + .burger-icon { + display: flex; + } + .navbar-menu { + position: absolute; + top: 68px; + left: 0; + width: 100%; + background: rgba(246, 246, 244, 0.96); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + flex-direction: column; + padding: 2rem 1.5rem; + gap: 1.5rem; + transform: scaleY(0); + transform-origin: top center; + opacity: 0; + transition: transform 0.35s ease, opacity 0.35s ease; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + pointer-events: none; + } + .burger-checkbox:checked~.navbar-menu { + transform: scaleY(1); + opacity: 1; + pointer-events: auto; + } + .burger-checkbox:checked~.burger-icon span:nth-child(1) { + transform: rotate(45deg) translate(5px, 5px); + } + .burger-checkbox:checked~.burger-icon span:nth-child(2) { + opacity: 0; + } + .burger-checkbox:checked~.burger-icon span:nth-child(3) { + transform: rotate(-45deg) translate(5px, -5px); + } + .navbar-auth { + flex-direction: column; + width: 100%; + } + .navbar-auth .btn { + width: 100%; + text-align: center; + } + .cards-grid { + grid-template-columns: 1fr; + gap: 1.5rem; + } + .container { + padding-left: 1.5rem; + padding-right: 1.5rem; + } + .hero .btn-group { + flex-direction: column; + align-items: center; + } + .hero .btn-group .btn { + width: 100%; + max-width: 280px; + } + .marquee-track .logo-item { + padding: 0 1.5rem; + font-size: 1rem; + } + .footer .container { + flex-direction: column; + text-align: center; + } + .footer .footer-logo img { + height: 28px; + } + .navbar { + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + } + .card-gif-wrapper { + margin-top: 0; + border-radius: 0; + } + .card-with-gif .card-body { + padding: 1.25rem; + } + } + @media (prefers-reduced-motion: reduce) { + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } + + /* ========== БЛОК: СХЕМА РАБОТЫ ========== */ + .steps-section { + background-color: var(--bg); + border-bottom: 1px solid var(--card-border); + } + + .steps-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 2rem; + margin-top: 4rem; + counter-reset: step-counter; + position: relative; + } + + .step-card { + background-color: var(--card); + border: 1px solid var(--card-border); + border-radius: var(--radius-card); + padding: 2.5rem 2rem; + transition: transform 0.8s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.3s ease; + position: relative; + counter-increment: step-counter; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + opacity: 0; + transform: translateX(40px) scale(0.95); + animation: stepAppear 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + } + + /* ===== КРУГЛАЯ ОБЕРТКА ДЛЯ ИЗОБРАЖЕНИЯ ===== */ + .step-image-wrapper { + width: 110px; + height: 110px; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + margin-bottom: 1.25rem; + /* Убрали border и box-shadow с синим цветом */ + border: none; + box-shadow: none; + transition: transform 0.3s ease; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + padding: 10px; + } + + .step-image-wrapper img { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; + display: block; + } + + .step-card:hover .step-image-wrapper { + transform: scale(1.05); + } + + /* Убираем ::before для карточек с изображением */ + .step-card:has(.step-image-wrapper)::before { + display: none; + } + + /* Для остальных карточек ::before остаётся (теперь их нет, все 4 шага с изображениями) */ + .step-card:not(:has(.step-image-wrapper))::before { + content: "0" counter(step-counter); + font-family: var(--font-mono); + font-weight: 500; + font-size: 1.25rem; + color: var(--primary); + background: rgba(72, 129, 109, 0.1); + display: inline-block; + padding: 0.3rem 0.9rem; + border-radius: 40px; + margin-bottom: 1.25rem; + align-self: center; + letter-spacing: 0.02em; + } + + /* Фаза 1: появление */ + .step-card:nth-child(1) { + animation-delay: 0.1s; + transform: translateX(0) scale(1); + opacity: 1; + } + .step-card:nth-child(2) { + animation-delay: 0.4s; + } + .step-card:nth-child(3) { + animation-delay: 0.7s; + } + .step-card:nth-child(4) { + animation-delay: 1.0s; + } + + @keyframes stepAppear { + 0% { + opacity: 0; + transform: translateX(40px) scale(0.95); + } + 100% { + opacity: 1; + transform: translateX(0) scale(1); + } + } + + /* Фаза 2: шахматный порядок */ + .step-card:nth-child(1) { + animation: stepAppear 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards, + stepChessUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 1.7s forwards; + } + .step-card:nth-child(2) { + animation: stepAppear 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 0.4s forwards, + stepChessDown 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 1.7s forwards; + } + .step-card:nth-child(3) { + animation: stepAppear 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 0.7s forwards, + stepChessUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 1.9s forwards; + } + .step-card:nth-child(4) { + animation: stepAppear 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 1.0s forwards, + stepChessDown 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 2.1s forwards; + } + + @keyframes stepChessUp { + 0% { transform: translateY(0); } + 100% { transform: translateY(-20px); } + } + + @keyframes stepChessDown { + 0% { transform: translateY(0); } + 100% { transform: translateY(20px); } + } + + .step-card:hover { + box-shadow: 0 12px 30px rgba(72, 129, 109, 0.06); + } + + .step-card h3 { + margin-bottom: 0.6rem; + font-size: 1.3rem; + } + + .step-card p { + color: var(--muted); + line-height: 1.5; + font-size: 0.95rem; + flex: 1; + } + + /* Стрелочки между карточками (десктоп) */ + @media (min-width: 992px) { + .step-card:not(:last-child)::after { + content: "→"; + position: absolute; + right: -1.5rem; + top: 50%; + transform: translateY(-50%); + font-size: 1.6rem; + color: var(--primary); + opacity: 0.5; + font-weight: 300; + pointer-events: none; + z-index: 5; + } + } + + /* Мобильная адаптация */ + @media (max-width: 768px) { + .steps-grid { + gap: 1.5rem; + margin-top: 2.5rem; + } + .step-card { + padding: 1.75rem 1.25rem; + transform: translateX(0) scale(1); + animation: stepAppearMobile 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + } + .step-card:nth-child(1) { + animation-delay: 0.1s; + opacity: 1; + transform: translateY(0) scale(1); + } + .step-card:nth-child(2) { + animation-delay: 0.3s; + } + .step-card:nth-child(3) { + animation-delay: 0.5s; + } + .step-card:nth-child(4) { + animation-delay: 0.7s; + } + + .step-card:nth-child(1), + .step-card:nth-child(2), + .step-card:nth-child(3), + .step-card:nth-child(4) { + animation: stepAppearMobile 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + } + .step-card:nth-child(1) { animation-delay: 0.1s; } + .step-card:nth-child(2) { animation-delay: 0.3s; } + .step-card:nth-child(3) { animation-delay: 0.5s; } + .step-card:nth-child(4) { animation-delay: 0.7s; } + + @keyframes stepAppearMobile { + 0% { + opacity: 0; + transform: translateY(30px) scale(0.95); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + .step-image-wrapper { + width: 85px; + height: 85px; + margin-bottom: 1rem; + padding: 8px; + } + + .step-card:not(:has(.step-image-wrapper))::before { + font-size: 1rem; + padding: 0.2rem 0.8rem; + } + .step-card h3 { + font-size: 1.15rem; + } + .step-card:not(:last-child)::after { + content: "↓"; + position: static; + transform: none; + text-align: center; + display: block; + margin: 0.5rem 0 -0.25rem; + font-size: 1.4rem; + color: var(--primary); + opacity: 0.4; + } + .step-card:has(.step-image-wrapper)::after { + display: none !important; + } + } + + /* ================================================================ */ + /* ====== НОВЫЙ БЛОК: ВОРОНКА ПРОЦЕССА (УДАЛЁН, ОСТАВЛЕН ТОЛЬКО СЛОГАН) ====== */ + /* ================================================================ */ + .funnel-section { + background: linear-gradient(180deg, var(--bg) 0%, #FFFFFF 100%); + border-top: 1px solid var(--card-border); + border-bottom: 1px solid var(--card-border); + padding: clamp(3rem, 10vh, 6rem) 0; + position: relative; + overflow: hidden; + } + + .funnel-section::before { + content: ''; + position: absolute; + top: -50%; + right: -20%; + width: 600px; + height: 600px; + background: radial-gradient(circle, rgba(72, 129, 109, 0.05) 0%, transparent 70%); + border-radius: 50%; + pointer-events: none; + } + + .funnel-header { + text-align: center; + margin-bottom: 3rem; + position: relative; + z-index: 2; + } + + .funnel-header h2 { + margin-bottom: 0.75rem; + } + + .funnel-header .subtitle { + color: var(--muted); + font-size: 1.15rem; + max-width: 640px; + margin: 0 auto; + line-height: 1.7; + } + + /* ====== ФИНАЛЬНЫЙ СЛОГАН ====== */ + .funnel-final-slogan { + text-align: center; + padding: 2.5rem 2rem; + background: var(--foreground); + border-radius: var(--radius-card); + position: relative; + z-index: 2; + overflow: hidden; + max-width: 900px; + margin: 0 auto; + } + + .funnel-final-slogan::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse at 30% 50%, rgba(72, 129, 109, 0.15) 0%, transparent 70%); + pointer-events: none; + } + + .funnel-final-slogan p { + font-size: clamp(1.2rem, 2.5vw, 1.8rem); + font-weight: 500; + color: #FFFFFF; + line-height: 1.5; + position: relative; + z-index: 1; + letter-spacing: -0.02em; + } + + .funnel-final-slogan p span { + color: var(--primary-accent); + } + + /* ====== АДАПТИВ ВОРОНКИ ====== */ + @media (max-width: 768px) { + .funnel-header { + margin-bottom: 2rem; + } + .funnel-header .subtitle { + font-size: 1rem; + } + .funnel-final-slogan { + padding: 1.75rem 1.25rem; + } + .funnel-final-slogan p { + font-size: 1.1rem; + } + } + + @media (max-width: 480px) { + .funnel-final-slogan { + padding: 1.25rem 1rem; + } + .funnel-final-slogan p { + font-size: 1rem; + } + } +/* ========== ИЗВЛЕЧЁННЫЕ INLINE-СТИЛИ ========== */ +.btn-sm { + padding: 0.5rem 1.4rem; + font-size: 0.85rem; +} +.btn-lg { + font-size: 1.1rem; + padding: 0.9rem 2.8rem; +} +.section-intro { + text-align: center; + max-width: 700px; + margin: 0 auto; +} +.section-intro--mb { + margin-bottom: 0.5rem; +} +.section-label { + color: var(--primary); + font-size: 0.9rem; + letter-spacing: 0.06em; + display: block; + margin-bottom: 0.5rem; +} +.section-subtitle-centered { + text-align: center; + color: var(--muted); + max-width: 600px; + margin: 0 auto 3.5rem; + font-size: 1.1rem; +} +.footer-copy { + font-size: 0.85rem; +} +.footer-links { + display: flex; + gap: 1.5rem; +} +.navbar-auth { + display: flex; + gap: 0.75rem; + align-items: center; +} diff --git a/apps/web/main/css/tokens.css b/apps/web/main/css/tokens.css new file mode 100644 index 0000000..5bec1cf --- /dev/null +++ b/apps/web/main/css/tokens.css @@ -0,0 +1,112 @@ +/* Compton design tokens — single source of truth */ +:root { + /* Brand */ + --primary: #48816d; + --primary-dark: #3a6b58; + --primary-light: #5a9a82; + --primary-accent: #5a9a82; + --primary-tint: #e8f2ef; + --primary-color: var(--primary); + + /* Light surfaces */ + --bg: #f6f6f4; + --foreground: #1a1e1c; + --muted: #4a5a52; + --marquee-bg: #ebebe5; + --surface: #ffffff; + --border-light: #e8e8e2; + --card: #ffffff; + --card-border: #e8e8e2; + --footer-bg: #1a1e1c; + --bg-page: var(--bg); + --text: var(--foreground); + + /* Typography */ + --font-sans: "Inter", system-ui, -apple-system, sans-serif; + --font-mono: "JetBrains Mono", "SF Mono", monospace; + --zootech-font: var(--font-sans); + + /* Radius */ + --radius-card: 18px; + --radius-btn: 40px; + --radius-icon: 16px; + --zt-radius-lg: 16px; + + /* Effects */ + --shadow-soft: 0 16px 48px rgba(26, 30, 28, 0.08), 0 0 0 1px rgba(26, 30, 28, 0.04); + + /* Semantic */ + --color-error: #b42318; + --color-success: #0f766e; + --color-danger: #cd3838; + --color-danger-hover: #e94b4b; + --color-warning: #d4a72c; + + /* Admin (light — matches site) */ + --admin-bg: var(--bg); + --admin-surface: var(--surface); + --admin-surface-2: var(--marquee-bg); + --admin-stroke: var(--border-light); + --admin-soft: var(--muted); + --admin-text: var(--foreground); + --admin-primary: var(--primary); + --admin-primary-hover: var(--primary-tint); + --admin-primary-selected: rgba(72, 129, 109, 0.16); + --admin-primary-option: rgba(72, 129, 109, 0.1); + --admin-btn-bg: var(--surface); + --admin-btn-border: var(--border-light); + --admin-btn-text: var(--foreground); + --admin-success: var(--color-success); + --admin-tag-ok-border: rgba(72, 129, 109, 0.35); + --admin-sider-bg: var(--surface); + --admin-table-header: var(--marquee-bg); + --admin-card-radius: 20px; + + /* WESP admin aliases */ + --wesp-admin-bg: var(--admin-bg); + --wesp-admin-surface: var(--admin-surface); + --wesp-admin-surface-2: var(--admin-surface-2); + --wesp-admin-stroke: var(--admin-stroke); + --wesp-admin-soft: var(--admin-soft); + --wesp-admin-text: var(--admin-text); + --wesp-admin-primary: var(--admin-primary); +} + +html[data-theme="organic"] { + color-scheme: light; +} + +html.admin-route, +html.admin-route body { + background: var(--admin-bg); +} + +/* Admin dark theme — isolated from public site */ +html.admin-route[data-admin-theme="dark"] { + color-scheme: dark; + --admin-deep: #0b0a10; + --admin-bg: #1f2229; + --admin-surface: #0b0a10; + --admin-surface-2: #15141c; + --admin-stroke: rgba(186, 184, 208, 0.14); + --admin-soft: #a8adbb; + --admin-text: #ececf1; + --admin-primary: #5a9a82; + --admin-primary-hover: rgba(90, 154, 130, 0.2); + --admin-primary-selected: rgba(90, 154, 130, 0.32); + --admin-primary-option: rgba(90, 154, 130, 0.16); + --admin-btn-bg: #15141c; + --admin-btn-border: rgba(186, 184, 208, 0.16); + --admin-btn-text: #ececf1; + --admin-success: #6bc4a8; + --admin-tag-ok-border: rgba(90, 154, 130, 0.5); + --admin-sider-bg: #0b0a10; + --admin-table-header: #1a1924; + --admin-card-radius: 20px; + --surface: #0b0a10; + --foreground: #ececf1; + --muted: #a8adbb; + --border-light: rgba(186, 184, 208, 0.14); + --marquee-bg: #1a1924; + --primary-tint: rgba(90, 154, 130, 0.16); +} diff --git a/apps/web/main/fonts/JetBrainsMono.ttf b/apps/web/main/fonts/JetBrainsMono.ttf new file mode 100644 index 0000000..aa310be Binary files /dev/null and b/apps/web/main/fonts/JetBrainsMono.ttf differ diff --git a/apps/web/main/fonts/Pacifico-Regular.ttf b/apps/web/main/fonts/Pacifico-Regular.ttf new file mode 100644 index 0000000..27765d0 Binary files /dev/null and b/apps/web/main/fonts/Pacifico-Regular.ttf differ diff --git a/apps/web/main/fonts/oktyabrina-script.ttf b/apps/web/main/fonts/oktyabrina-script.ttf new file mode 100644 index 0000000..04b9c1c Binary files /dev/null and b/apps/web/main/fonts/oktyabrina-script.ttf differ diff --git a/apps/web/main/gif/16-24-58.gif b/apps/web/main/gif/16-24-58.gif new file mode 100644 index 0000000..a3e71aa Binary files /dev/null and b/apps/web/main/gif/16-24-58.gif differ diff --git a/apps/web/main/gif/рецепты.gif b/apps/web/main/gif/рецепты.gif new file mode 100644 index 0000000..0aeaa79 Binary files /dev/null and b/apps/web/main/gif/рецепты.gif differ diff --git a/apps/web/main/gif/учет потребления.gif b/apps/web/main/gif/учет потребления.gif new file mode 100644 index 0000000..fb2a668 Binary files /dev/null and b/apps/web/main/gif/учет потребления.gif differ diff --git a/apps/web/main/icons/.gitkeep b/apps/web/main/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/main/img/control.png b/apps/web/main/img/control.png new file mode 100644 index 0000000..24016d6 Binary files /dev/null and b/apps/web/main/img/control.png differ diff --git a/apps/web/main/img/hero section.jpg b/apps/web/main/img/hero section.jpg new file mode 100644 index 0000000..10e2cc8 Binary files /dev/null and b/apps/web/main/img/hero section.jpg differ diff --git a/apps/web/main/img/letter K.png b/apps/web/main/img/letter K.png new file mode 100644 index 0000000..d17b529 Binary files /dev/null and b/apps/web/main/img/letter K.png differ diff --git a/apps/web/main/img/load.png b/apps/web/main/img/load.png new file mode 100644 index 0000000..c9c09ea Binary files /dev/null and b/apps/web/main/img/load.png differ diff --git a/apps/web/main/img/logo3.png b/apps/web/main/img/logo3.png new file mode 100644 index 0000000..893aacc Binary files /dev/null and b/apps/web/main/img/logo3.png differ diff --git a/apps/web/main/img/wifi.png b/apps/web/main/img/wifi.png new file mode 100644 index 0000000..49c1cc2 Binary files /dev/null and b/apps/web/main/img/wifi.png differ diff --git a/apps/web/main/img/zoo.png b/apps/web/main/img/zoo.png new file mode 100644 index 0000000..8cab387 Binary files /dev/null and b/apps/web/main/img/zoo.png differ diff --git a/apps/web/main/img/Комптон всегда под рукой на белом фоне.jpg b/apps/web/main/img/Комптон всегда под рукой на белом фоне.jpg new file mode 100644 index 0000000..7e5f3d1 Binary files /dev/null and b/apps/web/main/img/Комптон всегда под рукой на белом фоне.jpg differ diff --git a/apps/web/main/js/main.js b/apps/web/main/js/main.js new file mode 100644 index 0000000..a4496be --- /dev/null +++ b/apps/web/main/js/main.js @@ -0,0 +1,111 @@ +(function() { + 'use strict'; + + // ===== SCRAMBLE HERO ===== + const heroTitleEl = document.getElementById('heroTitle'); + if (heroTitleEl) { + const originalText = heroTitleEl.textContent.trim() || 'Технологии будущего на вашей ферме'; + const chars = '!@#$%^&*()_+{}[]|;:,.<>?'; + let iteration = 0; + let isScrambling = true; + let frameId = null; + const maxIterations = 30; + + function scramble() { + if (!isScrambling) return; + const result = originalText + .split('') + .map((char, index) => { + if (index < iteration) return originalText[index]; + if (char === ' ') return ' '; + return chars[Math.floor(Math.random() * chars.length)]; + }) + .join(''); + heroTitleEl.textContent = result; + iteration += 1; + if (iteration <= maxIterations) { + frameId = requestAnimationFrame(scramble); + } else { + heroTitleEl.textContent = originalText; + isScrambling = false; + if (frameId) { + cancelAnimationFrame(frameId); + frameId = null; + } + } + } + setTimeout(() => { + scramble(); + }, 300); + } + + // ===== FADE-UP ===== + const fadeElements = document.querySelectorAll('.fade-up'); + if (fadeElements.length > 0 && 'IntersectionObserver' in window) { + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.classList.add('visible'); + observer.unobserve(entry.target); + } + }); + }, { threshold: 0.15, rootMargin: '0px 0px -30px 0px' }); + fadeElements.forEach(el => observer.observe(el)); + } else { + fadeElements.forEach(el => el.classList.add('visible')); + } + + // ===== EXPAND BLOCK ===== + const expandContent = document.getElementById('expandContent'); + if (expandContent && 'IntersectionObserver' in window) { + let isExpanded = false; + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + if (!isExpanded) { + expandContent.classList.add('expanded'); + isExpanded = true; + } + } else { + if (isExpanded) { + expandContent.classList.remove('expanded'); + isExpanded = false; + } + } + }); + }, { threshold: 0.25, rootMargin: '0px 0px -30px 0px' }); + observer.observe(expandContent); + } else if (expandContent) { + expandContent.classList.add('expanded'); + } + + // ===== MARQUEE ===== + const marqueeTrack = document.getElementById('marqueeTrack'); + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + if (prefersReducedMotion.matches && marqueeTrack) { + marqueeTrack.style.animation = 'none'; + marqueeTrack.style.transform = 'translateX(0)'; + } + prefersReducedMotion.addEventListener('change', (e) => { + if (marqueeTrack) { + if (e.matches) { + marqueeTrack.style.animation = 'none'; + marqueeTrack.style.transform = 'translateX(0)'; + } else { + marqueeTrack.style.animation = 'marqueeScroll 30s linear infinite'; + marqueeTrack.style.transform = ''; + } + } + }); + + // ===== HERO VIDEO ===== + const heroVideo = document.querySelector('.hero-video'); + if (heroVideo) { + heroVideo.play().catch(() => { + document.addEventListener('click', () => { + heroVideo.play(); + }, { once: true }); + }); + } + + })(); \ No newline at end of file diff --git a/apps/web/main/main.html b/apps/web/main/main.html new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/main/templates/index.html b/apps/web/main/templates/index.html new file mode 100644 index 0000000..405bb4c --- /dev/null +++ b/apps/web/main/templates/index.html @@ -0,0 +1,283 @@ + + + + + + Комптон + + + + + + + + + + + + + +
+ +
+

Технологии будущего на вашей ферме

+

+ Интеллектуальная система контроля кормления КРС на основе искусственного интеллекта. + Снижаем затраты на корма, повышаем продуктивность стада и даём полный контроль над каждым этапом. +

+ +
+
+ + +
+
+
+ +

Как устроена наша система

+
+
+ +
+
+ План рационов +
+

План рационов

+

Зоотехник составляет и корректирует рацион для каждой группы КРС в удобной программе на офисном ПК, ноутбуке или смартфоне.

+
+ +
+
+ Синхронизация данных +
+

Синхронизация данных

+

Созданное задание мгновенно и без проводов передается на весовой терминал кормосмесителя.

+
+ +
+
+ Точная загрузка +
+

Точная загрузка

+

Тракторист на дисплее видит подсказки и точный вес каждого компонента на лету, что исключает ошибки перегруза.

+
+ +
+
+ Контроль руководителя +
+

Контроль руководителя

+

Собственник или управляющий получает автоматический отчет о всех отклонениях и реальном расходе прямо на смартфон.

+
+
+
+
+ + + + +
+
+ +
+

От формулы до кормушки

+
+ + +
+

«Каждый килограмм корма — под контролем.
Каждая копейка — на счету. Каждая минута — сэкономлена»

+
+
+
+ + +
+
+

Работа на любом устройстве

+

+ Управляйте системой кормления с телефона, планшета или компьютера — интерфейс адаптируется под любой экран. +

+ +
+
+

Всегда на связи с фермой Абсолютный контроль рационов и остатков в любом месте, в любое время и с любого гаджета.

+
+ +
+ Комптон всегда под рукой +
+ +
+

Прозрачность и контроль Вы всегда видите, сколько корма съедено, как меняется продуктивность и где можно сэкономить без потери качества.

+
+
+
+
+ + +
+
+

Начни кормить по новому!

+

+ Всё, что нужно для точного, экономного и эффективного кормления КРС. +

+ +
+
+
+ Индивидуальный рацион +
+
+
+ +
+

Автоматический расчет рационов

+

Программа сама подбирает оптимальный состав корма для каждой группы животных с учётом возраста, веса и продуктивности.

+
+
+ +
+
+ Удобное редактирование рационов +
+
+
+ +
+

Удобное редактирование рационов

+

Редактируйте рацион в один клик: меняйте рецепты местами, добавляйте новые компоненты и мгновенно корректируйте сухое вещество.

+
+
+ +
+
+ Прогноз продуктивности +
+
+
+ +
+

Учет кормов

+

Автоматический учет остатков: программа точно рассчитывает остатки кормов на складе и прогнозирует дату следующей закупки на основе текущего расхода, защищая от внезапного дефицита.

+
+
+ +
+
+ Визуализация экономии кормов +
+
+
+ +
+

Гибкая система уведомлений

+

Умная система уведомлений — ваш новый помощник, который следит за процессом кормления КРС и мгновенно предупреждает команду фермы о любых сбоях. Она заменяет ручной контроль автоматическим мониторингом.

+
+
+ +
+
+ Анализ микроклимата +
+
+
+ +
+

Анализ микроклимата

+

Интеграция с системами климат-контроля: учитываем температуру и влажность для коррекции потребности в питании.

+
+
+ +
+
+ Единая платформа +
+
+
+ +
+

Единая платформа

+

Все данные о кормлении, здоровье и продуктивности в одном окне – для быстрого принятия решений.

+
+
+
+
+
+ + +
+
+ ✦ АГРО-ХОЛДИНГ + ✦ МОЛОЧНЫЙ КОМБИНАТ + ✦ ФЕРМА №1 + ✦ ЗЕРНО-ТРЕЙД + ✦ ВЕТЕРИНАРНАЯ СЛУЖБА + ✦ КОРМОВОЙ ЦЕНТР + ✦ ПЛЕМЗАВОД + ✦ АГРО-ИНТЕЛЛЕКТ + ✦ АГРО-ХОЛДИНГ + ✦ МОЛОЧНЫЙ КОМБИНАТ + ✦ ФЕРМА №1 + ✦ ЗЕРНО-ТРЕЙД + ✦ ВЕТЕРИНАРНАЯ СЛУЖБА + ✦ КОРМОВОЙ ЦЕНТР + ✦ ПЛЕМЗАВОД + ✦ АГРО-ИНТЕЛЛЕКТ +
+
+ + +
+
+

Внедряйте технологии будущего

+

+ Получите консультацию по настройке системы для вашего хозяйства. + Первые 2 месяца – полная поддержка и мониторинг. +

+ Оставить заявку +
+
+ + + + + + + \ No newline at end of file diff --git a/apps/web/main/video/Тестовое видео.mp4 b/apps/web/main/video/Тестовое видео.mp4 new file mode 100644 index 0000000..a9f2c7a Binary files /dev/null and b/apps/web/main/video/Тестовое видео.mp4 differ diff --git a/apps/web/main/webfonts/.gitkeep b/apps/web/main/webfonts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..94bfdb8 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,50 @@ +{ + "name": "web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint src --max-warnings=0", + "typecheck": "tsc --noEmit", + "test": "vitest", + "test:ci": "vitest run --coverage", + "e2e": "playwright test" + }, + "dependencies": { + "@ant-design/icons": "^6.3.2", + "@hookform/resolvers": "^3.9.1", + "@tanstack/react-query": "^5.59.0", + "antd": "^5.29.3", + "axios": "^1.7.7", + "dompurify": "^3.2.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.53.0", + "react-router-dom": "^7.0.0", + "zod": "^3.23.8", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.48.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.0.1", + "@types/dompurify": "^3.2.0", + "@types/node": "^22.8.6", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@typescript-eslint/eslint-plugin": "^8.10.0", + "@typescript-eslint/parser": "^8.10.0", + "@vitejs/plugin-react": "^4.3.2", + "@vitest/coverage-v8": "^2.1.9", + "eslint": "^9.12.0", + "eslint-plugin-boundaries": "^4.2.0", + "eslint-plugin-react-hooks": "^5.1.0", + "jsdom": "^25.0.1", + "typescript": "^5.6.3", + "vite": "^5.4.21", + "vitest": "^2.1.9" + } +} diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..6655e57 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,48 @@ +import { defineConfig } from "@playwright/test"; + +const webPort = process.env.E2E_WEB_PORT ?? "5175"; +const webUrl = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${webPort}`; + +const apiPort = process.env.E2E_API_PORT ?? "8001"; +const apiUrl = process.env.E2E_API_URL ?? `http://127.0.0.1:${apiPort}`; +const startApi = process.env.E2E_START_API !== "false"; + +export default defineConfig({ + testDir: "./e2e", + retries: process.env.CI ? 1 : 0, + workers: 1, + webServer: startApi + ? [ + { + command: "python scripts/start_e2e_api.py", + cwd: "../api", + url: `${apiUrl}/api/v1/health`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + E2E_API_PORT: apiPort, + E2E_WEB_PORT: webPort + } + }, + { + command: `npx vite --host 127.0.0.1 --port ${webPort}`, + url: webUrl, + reuseExistingServer: !process.env.CI, + env: { + VITE_USE_API_PROXY: "true", + VITE_API_URL: apiUrl + } + } + ] + : { + command: `npx vite --host 127.0.0.1 --port ${webPort}`, + url: webUrl, + reuseExistingServer: !process.env.CI + }, + use: { + baseURL: webUrl, + trace: "on-first-retry", + video: "on-first-retry" + }, + projects: [{ name: "chromium", use: { browserName: "chromium" } }] +}); diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt new file mode 100644 index 0000000..b08aee5 --- /dev/null +++ b/apps/web/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: /sitemap.xml diff --git a/apps/web/public/sitemap.xml b/apps/web/public/sitemap.xml new file mode 100644 index 0000000..4b13e6d --- /dev/null +++ b/apps/web/public/sitemap.xml @@ -0,0 +1,7 @@ + + + http://localhost:5173/ + http://localhost:5173/pages/about + http://localhost:5173/pages/privacy + http://localhost:5173/pages/terms + diff --git a/apps/web/src/__tests__/setup.ts b/apps/web/src/__tests__/setup.ts new file mode 100644 index 0000000..237d471 --- /dev/null +++ b/apps/web/src/__tests__/setup.ts @@ -0,0 +1,18 @@ +import "@testing-library/jest-dom/vitest"; + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false + }) +}); + +const originalGetComputedStyle = window.getComputedStyle.bind(window); +window.getComputedStyle = ((element: Element) => originalGetComputedStyle(element)) as typeof window.getComputedStyle; diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx new file mode 100644 index 0000000..02e9c6c --- /dev/null +++ b/apps/web/src/app/App.tsx @@ -0,0 +1,40 @@ +import { useLayoutEffect } from "react"; +import { AppProviders } from "./providers/AppProviders"; +import { AppRouter } from "./router/routes"; +import { AppHeader } from "@shared/ui"; +import { useLocation } from "react-router-dom"; + +function shouldHideHeader(pathname: string): boolean { + return ( + /^\/(login|register|forgot-password|verify|reset-password)(\/|$)/.test(pathname) || + /^\/admin(\/|$)/.test(pathname) + ); +} + +function isAdminRoute(pathname: string): boolean { + return /^\/admin(\/|$)/.test(pathname); +} + +function AppShell(): JSX.Element { + const location = useLocation(); + const hideHeader = shouldHideHeader(location.pathname); + + useLayoutEffect(() => { + document.documentElement.classList.toggle("admin-route", isAdminRoute(location.pathname)); + }, [location.pathname]); + + return ( + <> + {hideHeader ? null : } + + + ); +} + +export function App(): JSX.Element { + return ( + + + + ); +} diff --git a/apps/web/src/app/providers/AppProviders.tsx b/apps/web/src/app/providers/AppProviders.tsx new file mode 100644 index 0000000..8d00bf4 --- /dev/null +++ b/apps/web/src/app/providers/AppProviders.tsx @@ -0,0 +1,20 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter } from "react-router-dom"; +import type { PropsWithChildren } from "react"; +import { setAccessTokenGetter } from "@shared/api/client"; +import { useAuthStore } from "@modules/auth/store/authStore"; +import { AuthBootstrap } from "./AuthBootstrap"; + +const queryClient = new QueryClient(); + +setAccessTokenGetter(() => useAuthStore.getState().accessToken); + +export function AppProviders({ children }: PropsWithChildren): JSX.Element { + return ( + + + {children} + + + ); +} diff --git a/apps/web/src/app/providers/AuthBootstrap.tsx b/apps/web/src/app/providers/AuthBootstrap.tsx new file mode 100644 index 0000000..29a7613 --- /dev/null +++ b/apps/web/src/app/providers/AuthBootstrap.tsx @@ -0,0 +1,32 @@ +import { useLayoutEffect } from "react"; +import type { PropsWithChildren } from "react"; +import { shouldAttemptAuthRefresh } from "@modules/auth/store/authSessionHint"; +import { useAuthStore } from "@modules/auth/store/authStore"; +import { bootstrapSessionRefresh } from "@shared/api/client"; + +export function AuthBootstrap({ children }: PropsWithChildren): JSX.Element { + const setBootstrapped = useAuthStore((state) => state.setBootstrapped); + + useLayoutEffect(() => { + let cancelled = false; + + if (!shouldAttemptAuthRefresh()) { + setBootstrapped(true); + return () => { + cancelled = true; + }; + } + + void bootstrapSessionRefresh().finally(() => { + if (!cancelled) { + setBootstrapped(true); + } + }); + + return () => { + cancelled = true; + }; + }, [setBootstrapped]); + + return <>{children}; +} diff --git a/apps/web/src/app/router/guards/AdminGuard.test.tsx b/apps/web/src/app/router/guards/AdminGuard.test.tsx new file mode 100644 index 0000000..f67b5f2 --- /dev/null +++ b/apps/web/src/app/router/guards/AdminGuard.test.tsx @@ -0,0 +1,51 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AdminGuard } from "@app/router/guards/AdminGuard"; +import { useAuthStore } from "@modules/auth/store/authStore"; + +function TestChild(): JSX.Element { + return

Admin area

; +} + +describe("AdminGuard", () => { + beforeEach(() => { + useAuthStore.getState().clearSession(); + useAuthStore.getState().setBootstrapped(true); + }); + + afterEach(() => { + cleanup(); + }); + + it("redirects guests to login", () => { + render( + + + + + + ); + expect(screen.queryByRole("heading", { name: "Admin area" })).not.toBeInTheDocument(); + }); + + it("renders admin content for admin users", () => { + useAuthStore.getState().setSession("token", { + id: "1", + email: "admin@compton.example", + role: "admin", + is_superuser: false, + status: "active" + }); + + render( + + + + + + ); + + expect(screen.getAllByRole("heading", { name: "Admin area" })).toHaveLength(1); + }); +}); diff --git a/apps/web/src/app/router/guards/AdminGuard.tsx b/apps/web/src/app/router/guards/AdminGuard.tsx new file mode 100644 index 0000000..ff7592e --- /dev/null +++ b/apps/web/src/app/router/guards/AdminGuard.tsx @@ -0,0 +1,23 @@ +import { Navigate } from "react-router-dom"; +import type { PropsWithChildren } from "react"; +import { useAuth } from "@modules/auth"; +import { useAuthStore } from "@modules/auth/store/authStore"; + +export function AdminGuard({ children }: PropsWithChildren): JSX.Element { + const bootstrapped = useAuthStore((state) => state.bootstrapped); + const auth = useAuth(); + + if (!bootstrapped) { + return null; + } + + if (!auth.isAuthenticated) { + return ; + } + + if (auth.user?.role !== "admin") { + return ; + } + + return <>{children}; +} diff --git a/apps/web/src/app/router/guards/AuthGuard.tsx b/apps/web/src/app/router/guards/AuthGuard.tsx new file mode 100644 index 0000000..7f6ecef --- /dev/null +++ b/apps/web/src/app/router/guards/AuthGuard.tsx @@ -0,0 +1,19 @@ +import { Navigate } from "react-router-dom"; +import type { PropsWithChildren } from "react"; +import { useAuth } from "@modules/auth"; +import { useAuthStore } from "@modules/auth/store/authStore"; + +export function AuthGuard({ children }: PropsWithChildren): JSX.Element { + const bootstrapped = useAuthStore((state) => state.bootstrapped); + const auth = useAuth(); + + if (!bootstrapped) { + return null; + } + + if (!auth.isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/apps/web/src/app/router/guards/GuestGuard.tsx b/apps/web/src/app/router/guards/GuestGuard.tsx new file mode 100644 index 0000000..6c78616 --- /dev/null +++ b/apps/web/src/app/router/guards/GuestGuard.tsx @@ -0,0 +1,19 @@ +import { Navigate } from "react-router-dom"; +import type { PropsWithChildren } from "react"; +import { useAuth } from "@modules/auth"; +import { useAuthStore } from "@modules/auth/store/authStore"; + +export function GuestGuard({ children }: PropsWithChildren): JSX.Element { + const bootstrapped = useAuthStore((state) => state.bootstrapped); + const auth = useAuth(); + + if (!bootstrapped) { + return null; + } + + if (auth.isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/apps/web/src/app/router/routes.tsx b/apps/web/src/app/router/routes.tsx new file mode 100644 index 0000000..abfda69 --- /dev/null +++ b/apps/web/src/app/router/routes.tsx @@ -0,0 +1,67 @@ +import { lazy, Suspense } from "react"; +import { Navigate, Route, Routes } from "react-router-dom"; +import { LoginPage } from "@pages/LoginPage"; +import { RegisterPage } from "@pages/RegisterPage"; +import { ContentPage } from "@pages/ContentPage"; +import { VerifyPage } from "@pages/VerifyPage"; +import { ResetPasswordPage } from "@pages/ResetPasswordPage"; +import { ForgotPasswordPage } from "@pages/ForgotPasswordPage"; +import AdminPage from "@pages/AdminPage"; +import { AuthGuard } from "./guards/AuthGuard"; +import { AdminGuard } from "./guards/AdminGuard"; +import { GuestGuard } from "./guards/GuestGuard"; + +const ProfilePage = lazy(() => import("@pages/ProfilePage")); + +export function AppRouter(): JSX.Element { + return ( + + + + + + } + /> + + + + } + /> + } /> + } /> + + + + } + /> + } /> + + + + } + /> + + + + } + /> + } /> + + + ); +} diff --git a/apps/web/src/app/styles/globals.css b/apps/web/src/app/styles/globals.css new file mode 100644 index 0000000..8290184 --- /dev/null +++ b/apps/web/src/app/styles/globals.css @@ -0,0 +1,52 @@ +@import "../../../main/css/tokens.css"; + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--foreground); + font-family: var(--font-sans); +} + +a { + color: inherit; +} + +.marquee-section { + background: var(--marquee-bg); + overflow: hidden; + padding: 0.75rem 0; +} + +.marquee-track { + display: flex; + gap: 2rem; + width: max-content; + animation: marquee-scroll 24s linear infinite; +} + +.marquee-track span { + white-space: nowrap; + font-family: var(--font-mono); + letter-spacing: 0.04em; +} + +@keyframes marquee-scroll { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} + +@media (prefers-reduced-motion: reduce) { + .marquee-track { + animation: none; + width: 100%; + flex-wrap: wrap; + } +} diff --git a/apps/web/src/global.d.ts b/apps/web/src/global.d.ts new file mode 100644 index 0000000..a490c5d --- /dev/null +++ b/apps/web/src/global.d.ts @@ -0,0 +1,9 @@ +import type { JSX as ReactJSX } from "react"; + +declare global { + namespace JSX { + type Element = ReactJSX.Element; + } +} + +export {}; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..7f5ccdf --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "@app/App"; +import "antd/dist/reset.css"; +import "@app/styles/globals.css"; + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/apps/web/src/modules/admin/api/adminApi.test.ts b/apps/web/src/modules/admin/api/adminApi.test.ts new file mode 100644 index 0000000..8a5d216 --- /dev/null +++ b/apps/web/src/modules/admin/api/adminApi.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createAdminUser, + deleteAdminUser, + getAdminActivityFeed, + getAdminDiagnostics, + getAdminServerLog, + getAdminSettings, + getAdminStats, + getAdminSummary, + getAdminUsers, + getInstallSecrets, + patchAdminSettings, + patchAdminUser, + postAdminUiActivity, + resetAdminUserPassword, + revealInstallSecret +} from "./adminApi"; + +vi.mock("@shared/api/client", () => ({ + apiClient: { + get: vi.fn(async (url: string) => { + if (url === "/api/v1/admin/summary") { + return { data: { users_count: 5, registrations_day: 2, admins_count: 1, superusers_count: 1 } }; + } + if (url === "/api/v1/admin/stats") { + return { data: { users_count: 5, registrations_day: 2 } }; + } + if (url === "/api/v1/admin/settings") { + return { data: { values: {}, locks: {}, settings_path: "data/compton_settings.json", secrets: {} } }; + } + if (url === "/api/v1/admin/diagnostics/report") { + return { data: { checks: [{ id: "x", status: "ok", message: "ok" }] } }; + } + if (url === "/api/v1/admin/activity-feed") { + return { data: { events: [{ action: "a" }] } }; + } + if (url === "/api/v1/admin/server-log") { + return { data: { lines: ["line"] } }; + } + if (url === "/api/v1/admin/secrets") { + return { data: { initialized: true, locked: true, secrets_path: "x", database: {}, connection_string_masked: "", secrets_status: {} } }; + } + return { data: { data: [], meta: { total: 0, page: 1, limit: 20 } } }; + }), + patch: vi.fn(async () => ({ + data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" } + })), + post: vi.fn(async (url: string) => { + if (url === "/api/v1/admin/users") { + return { data: { id: "1", email: "new@example.com", role: "user", is_superuser: false, status: "active" } }; + } + if (url === "/api/v1/admin/ui-activity") { + return { data: { status: "ok" } }; + } + if (url === "/api/v1/admin/secrets/reveal") { + return { data: { key: "database_password", value: "secret" } }; + } + return { data: { status: "ok" } }; + }), + delete: vi.fn(async () => ({ data: { status: "deleted" } })) + } +})); + +describe("adminApi", () => { + it("loads admin users", async () => { + const data = await getAdminUsers(); + expect(data.data).toEqual([]); + }); + + it("loads admin stats", async () => { + const stats = await getAdminStats(); + expect(stats.users_count).toBe(5); + expect(stats.registrations_day).toBe(2); + }); + + it("loads admin summary", async () => { + const summary = await getAdminSummary(); + expect(summary.superusers_count).toBe(1); + }); + + it("patches admin user", async () => { + const user = await patchAdminUser("1", { status: "blocked" }); + expect(user.status).toBe("blocked"); + }); + + it("calls remaining admin api helpers", async () => { + expect((await createAdminUser({ + email: "new@example.com", + password: "Valid123A", + role: "user", + is_superuser: false, + status: "active" + })).email).toBe("new@example.com"); + expect((await resetAdminUserPassword("1", "Valid123A")).status).toBe("blocked"); + expect((await deleteAdminUser("1")).status).toBe("deleted"); + expect((await getAdminSettings()).settings_path).toBe("data/compton_settings.json"); + expect((await patchAdminSettings({ enable_docs: false })).id).toBe("1"); + expect((await getAdminDiagnostics()).checks[0].status).toBe("ok"); + expect((await getAdminActivityFeed()).events.length).toBe(1); + expect((await postAdminUiActivity("click")).status).toBe("ok"); + expect((await getAdminServerLog()).lines[0]).toBe("line"); + expect((await getInstallSecrets()).initialized).toBe(true); + expect((await revealInstallSecret("database_password")).value).toBe("secret"); + }); +}); diff --git a/apps/web/src/modules/admin/api/adminApi.ts b/apps/web/src/modules/admin/api/adminApi.ts new file mode 100644 index 0000000..ac8ca57 --- /dev/null +++ b/apps/web/src/modules/admin/api/adminApi.ts @@ -0,0 +1,148 @@ +import { apiClient } from "@shared/api/client"; + +export interface AdminUser { + id: string; + email: string; + role: string; + is_superuser: boolean; + status: string; +} + +export interface AdminUsersResponse { + data: AdminUser[]; + meta: { total: number; page: number; limit: number }; +} + +export interface AdminStats { + users_count: number; + registrations_day: number; +} + +export interface AdminSummary extends AdminStats { + admins_count: number; + superusers_count: number; +} + +export interface AdminSettingsPayload { + values: Record; + locks: Record; + settings_path: string; + secrets: Record; +} + +export interface DiagnosticsCheck { + id: string; + status: "ok" | "warn" | "fail"; + message: string; +} + +export interface InstallSecretsPayload { + initialized: boolean; + locked: boolean; + secrets_path: string; + database: { + host: string | null; + port: number | null; + database: string; + user: string | null; + password_configured: boolean; + }; + connection_string_masked: string; + secrets_status: Record; +} + +export async function getAdminUsers(page = 1, limit = 20): Promise { + const { data } = await apiClient.get("/api/v1/admin/users", { + params: { page, limit } + }); + return data; +} + +export async function patchAdminUser( + userId: string, + payload: { role?: string; status?: string; is_superuser?: boolean } +): Promise { + const { data } = await apiClient.patch(`/api/v1/admin/users/${userId}`, payload); + return data; +} + +export async function getAdminStats(): Promise { + const { data } = await apiClient.get("/api/v1/admin/stats"); + return data; +} + +export async function getAdminSummary(): Promise { + const { data } = await apiClient.get("/api/v1/admin/summary"); + return data; +} + +export async function createAdminUser(payload: { + email: string; + password: string; + role: string; + is_superuser: boolean; + status: string; +}): Promise { + const { data } = await apiClient.post("/api/v1/admin/users", payload); + return data; +} + +export async function resetAdminUserPassword(userId: string, password: string): Promise<{ status: string }> { + const { data } = await apiClient.patch<{ status: string }>( + `/api/v1/admin/users/${userId}/password`, + { password } + ); + return data; +} + +export async function deleteAdminUser(userId: string): Promise<{ status: string }> { + const { data } = await apiClient.delete<{ status: string }>(`/api/v1/admin/users/${userId}`); + return data; +} + +export async function getAdminSettings(): Promise { + const { data } = await apiClient.get("/api/v1/admin/settings"); + return data; +} + +export async function patchAdminSettings(values: Record): Promise { + const { data } = await apiClient.patch("/api/v1/admin/settings", { values }); + return data; +} + +export async function getAdminDiagnostics(): Promise<{ checks: DiagnosticsCheck[] }> { + const { data } = await apiClient.get<{ checks: DiagnosticsCheck[] }>("/api/v1/admin/diagnostics/report"); + return data; +} + +export async function getAdminActivityFeed(limit = 200): Promise<{ events: Array> }> { + const { data } = await apiClient.get<{ events: Array> }>( + "/api/v1/admin/activity-feed", + { params: { limit } } + ); + return data; +} + +export async function postAdminUiActivity(event: string, meta?: Record): Promise<{ status: string }> { + const { data } = await apiClient.post<{ status: string }>("/api/v1/admin/ui-activity", { event, meta }); + return data; +} + +export async function getAdminServerLog(lines = 200): Promise<{ lines: string[] }> { + const { data } = await apiClient.get<{ lines: string[] }>("/api/v1/admin/server-log", { + params: { lines } + }); + return data; +} + +export async function getInstallSecrets(): Promise { + const { data } = await apiClient.get("/api/v1/admin/secrets"); + return data; +} + +export async function revealInstallSecret( + key: "database_password" | "jwt_access_secret" | "jwt_refresh_pepper" | "s3_secret_key" +): Promise<{ key: string; value: string }> { + const { data } = await apiClient.post<{ key: string; value: string }>("/api/v1/admin/secrets/reveal", { key }); + return data; +} diff --git a/apps/web/src/modules/admin/components/AdminActivityPanel.test.tsx b/apps/web/src/modules/admin/components/AdminActivityPanel.test.tsx new file mode 100644 index 0000000..25c257c --- /dev/null +++ b/apps/web/src/modules/admin/components/AdminActivityPanel.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { AdminActivityPanel } from "./AdminActivityPanel"; + +vi.mock("@modules/auth", () => ({ + useIsSuperuser: () => true +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: string[] }) => { + if (queryKey[0] === "admin-activity-feed") { + return { + isLoading: false, + data: { events: [{ action: "admin.user.patch", timestamp: "2026-07-14T10:00:00Z" }] } + }; + } + return { data: { lines: ["line-1"] } }; + } +})); + +vi.mock("../api/adminApi", () => ({ + getAdminActivityFeed: vi.fn(), + getAdminServerLog: vi.fn() +})); + +describe("AdminActivityPanel", () => { + it("renders feed and server log for superuser", () => { + render(); + expect(screen.getByRole("heading", { name: "Activity" })).toBeInTheDocument(); + expect(screen.getByText("admin.user.patch")).toBeInTheDocument(); + expect(screen.getByText("line-1")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/modules/admin/components/AdminActivityPanel.tsx b/apps/web/src/modules/admin/components/AdminActivityPanel.tsx new file mode 100644 index 0000000..956f87b --- /dev/null +++ b/apps/web/src/modules/admin/components/AdminActivityPanel.tsx @@ -0,0 +1,47 @@ +import { useQuery } from "@tanstack/react-query"; +import { useIsSuperuser } from "@modules/auth"; +import { Card, List, Space, Typography } from "antd"; +import { getAdminActivityFeed, getAdminServerLog } from "../api/adminApi"; + +export function AdminActivityPanel(): JSX.Element { + const isSuperuser = useIsSuperuser(); + const { data, isLoading } = useQuery({ + queryKey: ["admin-activity-feed"], + queryFn: () => getAdminActivityFeed(120) + }); + const { data: serverLog } = useQuery({ + queryKey: ["admin-server-log"], + queryFn: () => getAdminServerLog(80), + enabled: isSuperuser + }); + + if (isLoading || !data) { + return

Loading activity...

; + } + + return ( +
+ + Activity + + + ( + + + {String(event.action ?? "event")} + {String(event.timestamp ?? "")} + + + )} + /> + + {isSuperuser && serverLog ? ( + +
{serverLog.lines.join("\n")}
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/modules/admin/components/AdminContentPanel.test.tsx b/apps/web/src/modules/admin/components/AdminContentPanel.test.tsx new file mode 100644 index 0000000..07668be --- /dev/null +++ b/apps/web/src/modules/admin/components/AdminContentPanel.test.tsx @@ -0,0 +1,26 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { AdminContentPanel } from "./AdminContentPanel"; + +vi.mock("@modules/content/api/contentApi", () => ({ + getAdminPages: vi.fn(async () => [ + { id: "1", slug: "about", title: "About", body: "

x

", status: "published" } + ]), + createContentPage: vi.fn(), + updateContentPage: vi.fn(), + deleteContentPage: vi.fn() +})); + +describe("AdminContentPanel", () => { + it("renders content management form", async () => { + const queryClient = new QueryClient(); + render( + + + + ); + expect(await screen.findByText("About")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create page" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/modules/admin/components/AdminContentPanel.tsx b/apps/web/src/modules/admin/components/AdminContentPanel.tsx new file mode 100644 index 0000000..78c912e --- /dev/null +++ b/apps/web/src/modules/admin/components/AdminContentPanel.tsx @@ -0,0 +1,152 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { Button, Form, Input, Select, Space, Table, Typography, message } from "antd"; +import { + createContentPage, + deleteContentPage, + getAdminPages, + updateContentPage, + type ContentPage +} from "@modules/content/api/contentApi"; + +const emptyForm = { slug: "", title: "", body: "", status: "draft" }; + +export function AdminContentPanel(): JSX.Element { + const queryClient = useQueryClient(); + const [messageApi, contextHolder] = message.useMessage(); + const [form, setForm] = useState(emptyForm); + const [editingId, setEditingId] = useState(null); + + const { data: pages, isLoading } = useQuery({ + queryKey: ["admin-content-pages"], + queryFn: getAdminPages + }); + + const saveMutation = useMutation({ + mutationFn: async () => { + if (editingId) { + return updateContentPage(editingId, { + title: form.title, + body: form.body, + status: form.status + }); + } + return createContentPage(form); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin-content-pages"] }); + setForm(emptyForm); + setEditingId(null); + messageApi.success("Page saved"); + }, + onError: () => messageApi.error("Save failed") + }); + + const deleteMutation = useMutation({ + mutationFn: (pageId: string) => deleteContentPage(pageId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin-content-pages"] }); + messageApi.success("Page deleted"); + } + }); + + function startEdit(page: ContentPage): void { + setEditingId(page.id); + setForm({ + slug: page.slug, + title: page.title, + body: page.body, + status: page.status + }); + } + + if (isLoading) { + return

Loading content pages...

; + } + + return ( +
+ {contextHolder} + + Content pages + +
saveMutation.mutate()}> + + setForm((prev) => ({ ...prev, slug: event.target.value }))} + /> + + + setForm((prev) => ({ ...prev, title: event.target.value }))} + /> + + + setForm((prev) => ({ ...prev, body: event.target.value }))} + /> + + + + + + + + + + + {isSuperuser ? ( + + + + ) : null} +
+ + + setEditingUser(null)} + onOk={() => editForm.submit()} + okText="Save" + confirmLoading={updateMutation.isPending} + > +
{ + if (!editingUser) { + return; + } + updateMutation.mutate({ id: editingUser.id, ...values }); + }} + > + + + + {isSuperuser ? ( + + + + ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/modules/admin/config/adminTabs.ts b/apps/web/src/modules/admin/config/adminTabs.ts new file mode 100644 index 0000000..e7ebf2e --- /dev/null +++ b/apps/web/src/modules/admin/config/adminTabs.ts @@ -0,0 +1,19 @@ +export type AdminTab = "users" | "content" | "security" | "diagnostics" | "activity"; + +export interface AdminTabConfig { + id: AdminTab; + label: string; + superuserOnly?: boolean; +} + +export const ADMIN_TABS: AdminTabConfig[] = [ + { id: "users", label: "Users" }, + { id: "content", label: "Content" }, + { id: "security", label: "Security", superuserOnly: true }, + { id: "diagnostics", label: "Diagnostics", superuserOnly: true }, + { id: "activity", label: "Activity" } +]; + +export function getVisibleAdminTabs(isSuperuser: boolean): AdminTabConfig[] { + return ADMIN_TABS.filter((tab) => !tab.superuserOnly || isSuperuser); +} diff --git a/apps/web/src/modules/admin/config/adminTheme.ts b/apps/web/src/modules/admin/config/adminTheme.ts new file mode 100644 index 0000000..24cd70a --- /dev/null +++ b/apps/web/src/modules/admin/config/adminTheme.ts @@ -0,0 +1,169 @@ +import { theme, type ThemeConfig } from "antd"; + +export type AdminColorMode = "light" | "dark"; + +const ADMIN_THEME_KEY = "wespAdminTheme"; + +export function readAdminThemePreference(): AdminColorMode { + if (typeof window === "undefined") { + return "light"; + } + return localStorage.getItem(ADMIN_THEME_KEY) === "dark" ? "dark" : "light"; +} + +export function persistAdminThemePreference(mode: AdminColorMode): void { + localStorage.setItem(ADMIN_THEME_KEY, mode); +} + +export function toggleAdminColorMode(mode: AdminColorMode): AdminColorMode { + return mode === "light" ? "dark" : "light"; +} + +const lightTheme: ThemeConfig = { + algorithm: theme.defaultAlgorithm, + token: { + colorPrimary: "#48816d", + colorBgBase: "#f6f6f4", + colorBgContainer: "#ffffff", + colorTextBase: "#1a1e1c", + colorBorder: "#e8e8e2", + colorBorderSecondary: "#e8e8e2", + colorError: "#cd3838", + colorWarning: "#d4a72c", + colorSuccess: "#0f766e" + }, + components: { + Layout: { + siderBg: "#ffffff", + triggerBg: "#ebebe5", + triggerColor: "#1a1e1c" + }, + Menu: { + itemBg: "transparent", + itemColor: "#1a1e1c", + itemSelectedBg: "rgba(72, 129, 109, 0.16)", + itemSelectedColor: "#48816d", + itemHoverBg: "#e8f2ef", + itemMarginInline: 8, + itemBorderRadius: 8, + itemHeight: 40 + }, + Card: { + colorBgContainer: "#ffffff", + colorBorderSecondary: "#e8e8e2", + borderRadiusLG: 20 + }, + Table: { + headerBg: "#ebebe5", + rowHoverBg: "rgba(72, 129, 109, 0.06)", + borderColor: "#e8e8e2" + }, + Tag: { + defaultBg: "#ebebe5", + defaultColor: "#4a5a52" + }, + Input: { + colorBgContainer: "#ffffff", + colorBorder: "#e8e8e2", + colorText: "#1a1e1c" + }, + Select: { + colorBgContainer: "#ffffff", + colorBorder: "#e8e8e2", + colorText: "#1a1e1c", + optionSelectedBg: "rgba(72, 129, 109, 0.1)" + }, + Switch: { + colorPrimary: "#48816d", + colorPrimaryHover: "#5a9a82" + }, + Modal: { + contentBg: "#ffffff", + headerBg: "#ffffff", + titleColor: "#1a1e1c", + colorIcon: "#4a5a52", + colorIconHover: "#1a1e1c" + }, + Button: { + defaultBg: "#ffffff", + defaultBorderColor: "#e8e8e2", + defaultColor: "#1a1e1c" + } + } +}; + +const darkTheme: ThemeConfig = { + algorithm: theme.darkAlgorithm, + token: { + colorPrimary: "#5a9a82", + colorBgBase: "#1f2229", + colorBgContainer: "#0b0a10", + colorTextBase: "#ececf1", + colorBorder: "rgba(186, 184, 208, 0.14)", + colorBorderSecondary: "rgba(186, 184, 208, 0.1)", + colorError: "#e94b4b", + colorWarning: "#e8b84a", + colorSuccess: "#6bc4a8" + }, + components: { + Layout: { + siderBg: "#0b0a10", + triggerBg: "#15141c", + triggerColor: "#ececf1" + }, + Menu: { + darkItemBg: "transparent", + darkItemSelectedBg: "rgba(90, 154, 130, 0.32)", + darkItemHoverBg: "rgba(90, 154, 130, 0.2)", + itemMarginInline: 8, + itemBorderRadius: 8, + itemHeight: 40 + }, + Card: { + colorBgContainer: "#0b0a10", + colorBorderSecondary: "rgba(186, 184, 208, 0.14)", + borderRadiusLG: 20 + }, + Table: { + headerBg: "#1a1924", + rowHoverBg: "rgba(90, 154, 130, 0.1)", + borderColor: "rgba(186, 184, 208, 0.1)" + }, + Tag: { + defaultBg: "rgba(11, 10, 16, 0.35)", + defaultColor: "rgba(236, 236, 241, 0.75)" + }, + Input: { + colorBgContainer: "#15141c", + colorBorder: "rgba(186, 184, 208, 0.16)", + colorText: "#ececf1" + }, + Select: { + colorBgContainer: "#15141c", + colorBorder: "rgba(186, 184, 208, 0.16)", + colorText: "#ececf1", + optionSelectedBg: "rgba(90, 154, 130, 0.2)" + }, + Switch: { + colorPrimary: "#5a9a82", + colorPrimaryHover: "#6bc4a8" + }, + Modal: { + colorBgElevated: "#0b0a10", + contentBg: "#0b0a10", + headerBg: "#0b0a10", + titleColor: "#ececf1", + colorIcon: "#a8adbb", + colorIconHover: "#ececf1" + }, + Button: { + defaultBg: "#15141c", + defaultBorderColor: "rgba(186, 184, 208, 0.16)", + defaultColor: "#ececf1" + } + } +}; + +export function getAdminAntdTheme(mode: AdminColorMode): ThemeConfig { + return mode === "dark" ? darkTheme : lightTheme; +} diff --git a/apps/web/src/modules/admin/index.ts b/apps/web/src/modules/admin/index.ts new file mode 100644 index 0000000..81ca198 --- /dev/null +++ b/apps/web/src/modules/admin/index.ts @@ -0,0 +1,19 @@ +export { AdminPanel } from "./components/AdminPanel"; +export { AdminStats } from "./components/AdminStats"; +export { AdminUsersTable } from "./components/AdminUsersTable"; +export { AdminContentPanel } from "./components/AdminContentPanel"; +export { + createAdminUser, + deleteAdminUser, + getAdminActivityFeed, + getAdminDiagnostics, + getAdminServerLog, + getAdminSettings, + getAdminStats, + getAdminSummary, + getAdminUsers, + patchAdminSettings, + patchAdminUser, + postAdminUiActivity, + resetAdminUserPassword +} from "./api/adminApi"; diff --git a/apps/web/src/modules/admin/styles/wesp-admin-panel.css b/apps/web/src/modules/admin/styles/wesp-admin-panel.css new file mode 100644 index 0000000..1edc9fa --- /dev/null +++ b/apps/web/src/modules/admin/styles/wesp-admin-panel.css @@ -0,0 +1,898 @@ +@import "../../../../main/css/tokens.css"; + +html.admin-route body { + background: var(--wesp-admin-bg); +} + +html.admin-route .ant-layout-sider-trigger { + background: var(--marquee-bg) !important; + color: var(--foreground) !important; + border-top: 1px solid var(--border-light) !important; +} + +html.admin-route .ant-layout-sider-trigger:hover { + background: var(--primary-tint) !important; + color: var(--foreground) !important; +} + +html.admin-route .ant-card { + background: var(--wesp-admin-surface) !important; + border-color: var(--border-light) !important; + box-shadow: none !important; + border-radius: var(--admin-card-radius) !important; + overflow: hidden; +} + +html.admin-route .ant-btn-primary { + background: var(--wesp-admin-primary) !important; + border-color: var(--primary) !important; + color: #fff !important; +} + +html.admin-route .ant-btn-dangerous, +html.admin-route .ant-btn-dangerous.ant-btn-primary { + background: #cd3838 !important; + border-color: transparent !important; + color: #fff !important; +} + +html.admin-route .ant-btn-dangerous:hover, +html.admin-route .ant-btn-dangerous.ant-btn-primary:hover { + background: #e94b4b !important; + color: #fff !important; +} + +html.admin-route .ant-input, +html.admin-route .ant-input-affix-wrapper, +html.admin-route .ant-select-selector, +html.admin-route .ant-input-number { + background: var(--surface) !important; + border-color: var(--border-light) !important; + color: var(--foreground) !important; +} + +html.admin-route .ant-select-dropdown, +html.admin-route .ant-modal-content, +html.admin-route .ant-modal-header { + background: var(--surface) !important; + color: var(--foreground) !important; +} + +html.admin-route .ant-modal-title, +html.admin-route .ant-modal-close { + color: var(--foreground) !important; +} + +html.admin-route .ant-switch.ant-switch-checked { + background: var(--wesp-admin-primary) !important; +} + +html.admin-route .ant-typography.ant-typography-secondary, +html.admin-route .ant-list-item-meta-description { + color: var(--muted) !important; +} + +html.admin-route .ant-table { + background: var(--surface) !important; + color: var(--foreground) !important; +} + +html.admin-route .ant-table-thead > tr > th { + background: var(--marquee-bg) !important; + color: var(--muted) !important; + border-bottom-color: var(--border-light) !important; +} + +html.admin-route .ant-table-tbody > tr > td { + border-bottom-color: var(--border-light) !important; +} + +.wesp-admin-layout { + min-height: 100vh; + background: var(--wesp-admin-bg) !important; + --wesp-admin-sider-width: 80px; +} + +.wesp-admin-layout.wesp-admin-sider-expanded { + --wesp-admin-sider-width: 256px; +} + +.wesp-admin-sider.ant-layout-sider { + background: var(--admin-sider-bg) !important; + border-right: 1px solid var(--admin-stroke); + position: fixed; + top: 0; + left: 0; + bottom: 0; + height: 100vh; + z-index: 100; + flex: 0 0 var(--wesp-admin-sider-width) !important; + max-width: var(--wesp-admin-sider-width) !important; + min-width: var(--wesp-admin-sider-width) !important; + width: var(--wesp-admin-sider-width) !important; + transition: + flex 0.28s cubic-bezier(0.4, 0, 0.2, 1), + max-width 0.28s cubic-bezier(0.4, 0, 0.2, 1), + min-width 0.28s cubic-bezier(0.4, 0, 0.2, 1), + width 0.28s cubic-bezier(0.4, 0, 0.2, 1); +} + +.wesp-admin-sider.ant-layout-sider-collapsed { + flex: 0 0 80px !important; + max-width: 80px !important; + min-width: 80px !important; + width: 80px !important; +} + +.wesp-admin-layout > .ant-layout { + margin-left: var(--wesp-admin-sider-width); + min-height: 100vh; + background: transparent; + transition: margin-left 0.28s cubic-bezier(0.4, 0, 0.2, 1); +} + +.wesp-admin-sider .ant-layout-sider-children { + display: flex; + flex-direction: column; + overflow: hidden; + padding-bottom: 48px; +} + +.wesp-admin-sider .ant-menu { + flex: 1 1 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 10px 0 8px !important; + background: transparent !important; + border-inline-end: none !important; +} + +.wesp-admin-sider .ant-menu-item { + cursor: pointer; + margin: 4px 8px !important; + width: calc(100% - 16px) !important; + display: flex !important; + align-items: center; + gap: 10px; + height: 40px !important; + line-height: 40px !important; + border-radius: 8px; + color: var(--wesp-admin-text) !important; +} + +.wesp-admin-sider .ant-menu-item:hover { + background: var(--admin-primary-hover) !important; +} + +.wesp-admin-sider .ant-menu-item-selected { + background: var(--admin-primary-selected) !important; + color: var(--primary) !important; +} + +.wesp-admin-sider .ant-menu-item .anticon { + color: var(--muted); + font-size: 16px; +} + +.wesp-admin-sider .ant-menu-item-selected .anticon { + color: var(--primary); +} + +.wesp-admin-sider .wesp-menu-label { + min-width: 0; + overflow: hidden; + white-space: nowrap; + max-width: 16rem; + opacity: 1; + transition: + opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.1s, + max-width 0.36s cubic-bezier(0.4, 0, 0.2, 1) 0.08s, + margin 0.25s ease, + padding 0.25s ease; +} + +.wesp-admin-sider.ant-layout-sider-collapsed .wesp-menu-label { + max-width: 0 !important; + opacity: 0 !important; + margin: 0 !important; + padding: 0 !important; + pointer-events: none; +} + +.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item { + display: flex !important; + justify-content: center !important; + align-items: center !important; + gap: 0; + margin-inline: 8px !important; + width: calc(100% - 16px) !important; + padding-inline: 0 !important; +} + +.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item .ant-menu-item-icon { + margin-inline: 0 !important; + flex: none !important; +} + +.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-title-content { + width: 0 !important; + overflow: hidden !important; + opacity: 0 !important; + flex: 0 !important; + margin: 0 !important; + padding: 0 !important; +} + +.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-inline-collapsed > .ant-menu-item { + padding-inline: 0 !important; +} + +.wesp-admin-sider:not(.ant-layout-sider-collapsed) .ant-menu-item { + padding-inline: 12px !important; +} + +.wesp-admin-sider .ant-layout-sider-trigger, +.wesp-admin-sider.ant-layout-sider-light .ant-layout-sider-trigger, +.wesp-admin-sider.ant-layout-sider-dark .ant-layout-sider-trigger { + position: absolute; + left: 0; + right: 0; + bottom: 0; + width: 100% !important; + height: 48px !important; + line-height: 48px !important; + border-top: 1px solid var(--border-light) !important; + background: var(--marquee-bg) !important; + color: var(--foreground) !important; + box-shadow: none !important; +} + +.wesp-admin-sider .ant-layout-sider-trigger:hover, +.wesp-admin-sider.ant-layout-sider-light .ant-layout-sider-trigger:hover, +.wesp-admin-sider.ant-layout-sider-dark .ant-layout-sider-trigger:hover { + background: var(--primary-tint) !important; + color: var(--foreground) !important; +} + +.wesp-sider-trigger-icon { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: var(--foreground); + opacity: 0.9; +} + +.wesp-sider-trigger-icon .anticon { + color: inherit; +} + +.ant-tooltip.ant-menu-inline-collapsed-tooltip .ant-tooltip-inner { + background: var(--surface); + color: var(--foreground); + font-size: 12px; + border: 1px solid var(--border-light); +} + +.ant-tooltip.ant-menu-inline-collapsed-tooltip .ant-tooltip-arrow::before { + background: var(--surface); +} + +.wesp-admin-content { + margin: 16px; + padding: 0; + background: transparent !important; + color: var(--wesp-admin-text); + min-width: 0; +} + +.wesp-admin-main { + min-width: 0; +} + +.wesp-admin-content-topbar { + margin-bottom: 12px; + display: flex; + justify-content: flex-end; + align-items: center; + flex-wrap: wrap; + gap: 12px; + min-height: 44px; +} + +.wesp-admin-content-topbar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.wesp-admin-content-topbar .ant-btn { + border-radius: 20px; + background: var(--admin-btn-bg); + border-color: var(--admin-btn-border); + color: var(--admin-btn-text); + height: 32px; + padding: 0 15px; + font-size: 14px; + font-weight: 500; +} + +.wesp-admin-content-topbar .ant-btn-primary { + background: var(--wesp-admin-primary) !important; + border-color: var(--primary) !important; + color: #fff !important; +} + +.wesp-admin-panel-card.ant-card { + border-radius: var(--admin-card-radius); + border: 1px solid var(--border-light); + background: var(--wesp-admin-surface); +} + +.wesp-admin-panel-card .ant-card-head { + border-bottom-color: var(--border-light); + min-height: 40px; + padding: 0 14px; +} + +.wesp-admin-panel-card .ant-card-body { + padding: 14px; +} + +.wesp-admin-panel-card .ant-card-head-title, +.wesp-admin-panel-card .ant-typography, +.wesp-admin-grid .ant-typography, +.wesp-admin-grid .ant-list-item-meta-title, +.wesp-admin-grid .ant-list-item-meta-description { + color: var(--wesp-admin-text) !important; +} + +.wesp-admin-section { + display: block; +} + +.wesp-admin-dashboard { + display: grid; + gap: 16px; +} + +.wesp-dashboard-sticky.ant-card { + position: sticky; + top: 0; + z-index: 20; +} + +.wesp-dashboard-sticky.ant-card .ant-card-body { + padding: 12px 14px; +} + +.wesp-admin-gauges-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.wesp-gauge { + text-align: center; +} + +.wesp-gauge-ring { + position: relative; + width: 120px; + max-width: 100%; + margin: 0 auto; + aspect-ratio: 1; +} + +.wesp-gauge-svg { + width: 100%; + height: 100%; + display: block; +} + +.wesp-gauge-track { + stroke: var(--border-light); +} + +.wesp-gauge-fill { + stroke: var(--wesp-admin-primary); + transition: stroke-dashoffset 0.45s ease; +} + +.wesp-gauge-center { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.wesp-gauge-pct { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.wesp-gauge-pct-suffix { + font-size: 13px; + font-weight: 600; + opacity: 0.55; + margin-left: 1px; +} + +.wesp-gauge-ring--adm { + width: 120px; + height: 120px; +} + +.wesp-gauge-caption { + margin-top: 10px; + font-size: 13px; + line-height: 1.45; + word-break: break-word; +} + +.wesp-gauge-caption span[data-gauge-detail] { + opacity: 0.75; + font-weight: 500; +} + +.wesp-dash-service-row.ant-row { + display: flex; + flex-wrap: wrap; + align-items: stretch; +} + +.wesp-dash-service-row > .ant-col { + display: flex; + flex-direction: column; +} + +.wesp-dash-service-row .wesp-dash-service-card { + flex: 1 1 auto; + width: 100%; + min-height: 100%; + display: flex; + flex-direction: column; +} + +.wesp-dash-service-card .ant-card-body { + padding-top: 12px; + flex: 1 1 auto; + display: flex; + flex-direction: column; +} + +.wesp-dash-service-row .wesp-dash-manage-list { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; +} + +.wesp-dash-manage-list { + font-size: 13px; + line-height: 1.4; +} + +.wesp-dash-manage-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px 16px; + padding: 14px 0; + border-bottom: 1px solid var(--border-light); +} + +.wesp-dash-manage-row:first-child { + padding-top: 2px; +} + +.wesp-dash-manage-row:last-child { + border-bottom: none; + padding-bottom: 2px; +} + +.wesp-dash-manage-row--service { + margin-top: 4px; + padding-top: 16px; +} + +.wesp-dash-manage-info { + flex: 1 1 140px; + min-width: 0; +} + +.wesp-dash-manage-title { + display: block; + font-weight: 600; + font-size: 13px; +} + +.wesp-dash-manage-hint { + display: block; + margin-top: 4px; + font-size: 12px; + opacity: 0.65; + line-height: 1.35; +} + +.wesp-dash-manage-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 12px; + flex-shrink: 0; +} + +.wesp-dash-wesp-meta { + display: flex; + flex-direction: column; + gap: 10px; + font-size: 13px; +} + +.wesp-dash-wesp-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 12px; + line-height: 1.4; +} + +.wesp-dash-wesp-label { + opacity: 0.65; + flex-shrink: 0; +} + +.wesp-dash-wesp-value { + font-weight: 500; + text-align: right; +} + +.wesp-dash-status { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 500; +} + +.wesp-status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + box-sizing: border-box; +} + +.wesp-status-dot--ok { + background: var(--wesp-admin-primary); +} + +.wesp-traffic-card .ant-card-body { + padding: 16px 20px 20px; +} + +.wesp-traffic-two-col { + display: flex; + flex-wrap: wrap; + gap: 16px 24px; +} + +.wesp-traffic-col { + flex: 1 1 160px; + min-width: 0; +} + +.wesp-traffic-label { + font-size: 13px; + opacity: 0.65; + margin-bottom: 8px; +} + +.wesp-traffic-value { + font-size: 18px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.wesp-traffic-arrow, +.wesp-traffic-icon { + opacity: 0.75; + margin-right: 4px; +} + +.wesp-admin-stats-row { + margin-top: 0; +} + +.wesp-tag { + background: var(--marquee-bg) !important; + border: 1px solid var(--border-light) !important; + color: var(--muted) !important; +} + +.wesp-tag--ok { + background: var(--primary-tint) !important; + border-color: var(--admin-tag-ok-border) !important; + color: var(--primary) !important; +} + +.wesp-tag--warn { + background: #fff8e6 !important; + border-color: rgba(212, 167, 44, 0.45) !important; + color: #9a6700 !important; +} + +.wesp-tag--error { + background: #fff1f0 !important; + border-color: rgba(205, 56, 56, 0.35) !important; + color: #cd3838 !important; +} + +.wesp-admin-stat-card.ant-card { + background: var(--wesp-admin-surface) !important; + border: 1px solid var(--wesp-admin-stroke) !important; + border-radius: var(--admin-card-radius); + box-shadow: none !important; +} + +.wesp-admin-stat-card.ant-card .ant-card-body { + padding: 16px !important; +} + +.wesp-admin-stat-label { + opacity: 0.65; + font-size: 13px; + color: var(--wesp-admin-soft); +} + +.wesp-admin-stat-value { + font-size: 26px; + font-weight: 600; + margin-top: 6px; + color: var(--wesp-admin-text); + line-height: 1.2; +} + +.wesp-admin-grid .ant-card { + background: var(--wesp-admin-surface) !important; + border-color: var(--wesp-admin-stroke) !important; + border-radius: var(--admin-card-radius) !important; +} + +.wesp-admin-grid .ant-table { + background: var(--wesp-admin-surface) !important; + color: var(--wesp-admin-text) !important; +} + +.wesp-admin-grid .ant-table-thead > tr > th { + background: var(--admin-table-header) !important; + border-bottom-color: var(--wesp-admin-stroke) !important; + color: var(--wesp-admin-soft) !important; +} + +.wesp-admin-grid .ant-table-tbody > tr > td { + border-bottom-color: var(--border-light) !important; + color: var(--wesp-admin-text) !important; +} + +.wesp-admin-grid { + display: grid; + gap: 10px; +} + +.wesp-admin-log { + margin: 0; + background: linear-gradient(165deg, #0a0f0d 0%, #060807 48%, #0d1210 100%); + color: #7ee787; + border-radius: 10px; + padding: 12px; + max-height: 320px; + overflow: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} + +.wesp-admin-block { + position: relative; +} + +html.wesp-admin-loading #root { + pointer-events: none; +} + +.wesp-admin-skel-overlay { + position: absolute; + inset: 0; + pointer-events: none; + border-radius: inherit; + z-index: 2; + background: linear-gradient( + 105deg, + rgba(0, 0, 0, 0.02) 0%, + rgba(0, 0, 0, 0.02) 34%, + rgba(0, 0, 0, 0.05) 50%, + rgba(0, 0, 0, 0.02) 66%, + rgba(0, 0, 0, 0.02) 100% + ); + background-size: 240% 100%; + animation: wesp-admin-skel-shimmer 0.95s ease-in-out infinite; + transition: opacity 0.38s ease; +} + +@keyframes wesp-admin-skel-shimmer { + 0% { + background-position: 240% 0; + } + 100% { + background-position: -240% 0; + } +} + +html.wesp-admin-loaded .wesp-admin-skel-overlay { + opacity: 0; + visibility: hidden; +} + +html.wesp-admin-loaded .wesp-admin-block { + animation: wesp-admin-block-reveal 0.52s cubic-bezier(0.22, 1, 0.36, 1) both; + animation-delay: calc(var(--wesp-reveal-i, 0) * 38ms); +} + +@keyframes wesp-admin-block-reveal { + from { + opacity: 0.55; + transform: translateY(14px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.wesp-admin-section-enter { + animation: wesp-admin-section-tween 0.44s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes wesp-admin-section-tween { + from { + opacity: 0.45; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +html.admin-route[data-admin-theme="dark"] .wesp-tag { + background: rgba(11, 10, 16, 0.45) !important; + border: 1px solid rgba(186, 184, 208, 0.16) !important; + color: rgba(236, 236, 241, 0.82) !important; +} + +html.admin-route[data-admin-theme="dark"] .wesp-tag--ok { + background: rgba(90, 154, 130, 0.18) !important; + border-color: var(--admin-tag-ok-border) !important; + color: var(--admin-success) !important; +} + +html.admin-route[data-admin-theme="dark"] .wesp-tag--warn { + background: rgba(232, 184, 74, 0.12) !important; + border-color: rgba(232, 184, 74, 0.4) !important; + color: #e8b84a !important; +} + +html.admin-route[data-admin-theme="dark"] .wesp-tag--error { + background: rgba(233, 75, 75, 0.14) !important; + border-color: rgba(233, 75, 75, 0.35) !important; + color: #ff9c9c !important; +} + +html.admin-route[data-admin-theme="dark"] .wesp-admin-skel-overlay { + background: linear-gradient( + 105deg, + rgba(255, 255, 255, 0.02) 0%, + rgba(255, 255, 255, 0.02) 34%, + rgba(255, 255, 255, 0.07) 50%, + rgba(255, 255, 255, 0.02) 66%, + rgba(255, 255, 255, 0.02) 100% + ); +} + +html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item-selected { + color: var(--admin-success) !important; +} + +html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item-selected .anticon { + color: var(--admin-success); +} + +html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item .anticon { + color: var(--admin-soft); +} + +html.admin-route[data-admin-theme="dark"] .wesp-gauge-track { + stroke: rgba(186, 184, 208, 0.18); +} + +html.admin-route[data-admin-theme="dark"] .wesp-admin-log { + background: linear-gradient(165deg, #0b0a10 0%, #15141c 48%, #0b0a10 100%); + color: #6bc4a8; +} + +@media (max-width: 768px) { + .wesp-admin-layout.ant-layout-has-sider { + flex-direction: column; + } + + .wesp-admin-sider.ant-layout-sider { + position: static; + inset: auto; + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + flex: 0 0 auto !important; + height: auto !important; + } + + .wesp-admin-layout > .ant-layout { + margin-left: 0; + } + + .wesp-admin-sider .ant-layout-sider-children { + padding-bottom: 0; + } + + .wesp-admin-sider .ant-layout-sider-trigger { + display: none; + } + + .wesp-admin-sider.ant-layout-sider-collapsed .wesp-menu-label { + max-width: 16rem !important; + opacity: 1 !important; + pointer-events: auto; + } + + .wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item { + justify-content: flex-start; + gap: 10px; + padding-inline: 16px !important; + } + + .wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-title-content { + width: auto !important; + opacity: 1 !important; + flex: 1 !important; + overflow: visible !important; + } + + .wesp-admin-content { + margin: 12px; + } + + .wesp-admin-content-topbar { + justify-content: flex-start; + } + + .wesp-admin-gauges-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (prefers-reduced-motion: reduce) { + .wesp-admin-sider.ant-layout-sider, + .wesp-admin-layout > .ant-layout, + .wesp-admin-sider .wesp-menu-label, + .wesp-admin-sider .ant-menu-item { + transition: none !important; + } +} diff --git a/apps/web/src/modules/analytics/index.ts b/apps/web/src/modules/analytics/index.ts new file mode 100644 index 0000000..6d8adc5 --- /dev/null +++ b/apps/web/src/modules/analytics/index.ts @@ -0,0 +1,3 @@ +export function AnalyticsPlaceholder(): string { + return "analytics-v1"; +} diff --git a/apps/web/src/modules/auth/api/authApi.test.ts b/apps/web/src/modules/auth/api/authApi.test.ts new file mode 100644 index 0000000..a18ed3e --- /dev/null +++ b/apps/web/src/modules/auth/api/authApi.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; +import { login, register } from "./authApi"; + +vi.mock("@shared/api/client", () => ({ + authClient: { + post: vi.fn(async (url: string) => { + if (url.endsWith("/login")) { + return { data: { access_token: "token", user: { id: "1" } } }; + } + return { data: { message: "ok" } }; + }) + } +})); + +describe("authApi", () => { + it("login returns access token payload", async () => { + const data = await login({ email: "u@example.com", password: "Valid123" }); + expect(data.access_token).toBe("token"); + }); + + it("register returns success payload", async () => { + const data = await register({ email: "u@example.com", password: "Valid123" }); + expect(data.message).toBe("ok"); + }); +}); diff --git a/apps/web/src/modules/auth/api/authApi.ts b/apps/web/src/modules/auth/api/authApi.ts new file mode 100644 index 0000000..a6a5f28 --- /dev/null +++ b/apps/web/src/modules/auth/api/authApi.ts @@ -0,0 +1,84 @@ +import { authClient } from "@shared/api/client"; + +export interface LoginPayload { + email: string; + password: string; +} + +export interface RegisterPayload { + email: string; + password: string; +} + +export async function login(payload: LoginPayload) { + const { data } = await authClient.post( + "/api/v1/auth/login", + { + email: payload.email.trim(), + password: payload.password + }, + { + headers: { "Content-Type": "application/json" } + } + ); + return data; +} + +export async function register(payload: RegisterPayload) { + const { data } = await authClient.post( + "/api/v1/auth/register", + { + email: payload.email.trim(), + password: payload.password + }, + { + headers: { "Content-Type": "application/json" } + } + ); + return data; +} + +export async function logout() { + await authClient.post("/api/v1/auth/logout"); +} + +export async function refresh() { + const { data } = await authClient.post("/api/v1/auth/refresh"); + return data; +} + +export async function verifyEmail(token: string) { + const { data } = await authClient.post( + "/api/v1/auth/verify-email", + { token }, + { headers: { "Content-Type": "application/json" } } + ); + return data; +} + +export async function forgotPassword(email: string) { + const { data } = await authClient.post( + "/api/v1/auth/forgot-password", + { email: email.trim() }, + { headers: { "Content-Type": "application/json" } } + ); + return data; +} + +export async function resetPassword(token: string, newPassword: string) { + const { data } = await authClient.post( + "/api/v1/auth/reset-password", + { token, new_password: newPassword }, + { headers: { "Content-Type": "application/json" } } + ); + return data; +} + +export async function resendVerification(email: string) { + const { data } = await authClient.post( + "/api/v1/auth/resend-verification", + { email: email.trim() }, + { headers: { "Content-Type": "application/json" } } + ); + return data; +} diff --git a/apps/web/src/modules/auth/components/AuthField.tsx b/apps/web/src/modules/auth/components/AuthField.tsx new file mode 100644 index 0000000..6c3c942 --- /dev/null +++ b/apps/web/src/modules/auth/components/AuthField.tsx @@ -0,0 +1,16 @@ +import type { InputHTMLAttributes, ReactNode } from "react"; + +interface AuthFieldProps extends InputHTMLAttributes { + icon: ReactNode; +} + +export function AuthField({ icon, ...props }: AuthFieldProps): JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/modules/auth/components/AuthLayout.tsx b/apps/web/src/modules/auth/components/AuthLayout.tsx new file mode 100644 index 0000000..d6a29b4 --- /dev/null +++ b/apps/web/src/modules/auth/components/AuthLayout.tsx @@ -0,0 +1,22 @@ +import type { PropsWithChildren } from "react"; +import { ZootechThemeProvider } from "@shared/theme/zootech/ThemeProvider"; +import "@shared/theme/zootech/auth-login.css"; + +interface AuthLayoutProps extends PropsWithChildren { + title: string; +} + +export function AuthLayout({ title, children }: AuthLayoutProps): JSX.Element { + return ( + +
+
+
+

{title}

+ {children} +
+
+
+
+ ); +} diff --git a/apps/web/src/modules/auth/components/AuthMessage.tsx b/apps/web/src/modules/auth/components/AuthMessage.tsx new file mode 100644 index 0000000..918ffbd --- /dev/null +++ b/apps/web/src/modules/auth/components/AuthMessage.tsx @@ -0,0 +1,12 @@ +interface AuthMessageProps { + type: "error" | "success"; + text: string; +} + +export function AuthMessage({ type, text }: AuthMessageProps): JSX.Element { + return ( +

+ {text} +

+ ); +} diff --git a/apps/web/src/modules/auth/components/AuthSubmit.tsx b/apps/web/src/modules/auth/components/AuthSubmit.tsx new file mode 100644 index 0000000..586245e --- /dev/null +++ b/apps/web/src/modules/auth/components/AuthSubmit.tsx @@ -0,0 +1,19 @@ +interface AuthSubmitProps { + loading: boolean; + idleText: string; + loadingText: string; + disabled?: boolean; +} + +export function AuthSubmit({ + loading, + idleText, + loadingText, + disabled = false +}: AuthSubmitProps): JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/modules/auth/components/AuthSwitch.tsx b/apps/web/src/modules/auth/components/AuthSwitch.tsx new file mode 100644 index 0000000..1f5f57e --- /dev/null +++ b/apps/web/src/modules/auth/components/AuthSwitch.tsx @@ -0,0 +1,12 @@ +import type { InputHTMLAttributes, PropsWithChildren } from "react"; + +type AuthSwitchProps = InputHTMLAttributes & PropsWithChildren; + +export function AuthSwitch({ children, ...props }: AuthSwitchProps): JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/modules/auth/components/LoginForm.test.tsx b/apps/web/src/modules/auth/components/LoginForm.test.tsx new file mode 100644 index 0000000..361bd29 --- /dev/null +++ b/apps/web/src/modules/auth/components/LoginForm.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { LoginForm } from "./LoginForm"; + +describe("LoginForm", () => { + it("renders login form controls", () => { + render( + + + + ); + expect(screen.getByPlaceholderText("Email")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Password")).toBeInTheDocument(); + expect(screen.getByText(/Forgot password/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/modules/auth/components/LoginForm.tsx b/apps/web/src/modules/auth/components/LoginForm.tsx new file mode 100644 index 0000000..9cd951a --- /dev/null +++ b/apps/web/src/modules/auth/components/LoginForm.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; +import { formatAuthError } from "../utils/formatAuthError"; +import { AuthField } from "./AuthField"; +import { AuthMessage } from "./AuthMessage"; +import { AuthSubmit } from "./AuthSubmit"; + +function UserIcon(): JSX.Element { + return ( + + + + ); +} + +function LockIcon(): JSX.Element { + return ( + + + + ); +} + +export function LoginForm(): JSX.Element { + const auth = useAuth(); + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + return ( +
{ + event.preventDefault(); + setError(""); + setIsSubmitting(true); + try { + const user = await auth.login(email.trim(), password); + navigate(user.role === "admin" ? "/admin" : "/profile"); + } catch (loginError) { + const fallback = + loginError && typeof loginError === "object" && "response" in loginError + ? (loginError as { response?: { status?: number } }).response?.status === 401 + ? "Invalid credentials" + : (loginError as { response?: { status?: number } }).response?.status === 403 + ? "Email not verified or account blocked" + : "Login failed" + : "Login failed"; + setError(formatAuthError(loginError, fallback)); + } finally { + setIsSubmitting(false); + } + }} + > + } + placeholder="Email" + type="email" + autoComplete="email" + value={email} + onChange={(event) => setEmail(event.target.value)} + required + /> + } + placeholder="Password" + type="password" + autoComplete="current-password" + value={password} + onChange={(event) => setPassword(event.target.value)} + required + /> + +

+ + Forgot password? + +

+

+ No account yet?{" "} + + Register + +

+ {error ? : null} + + ); +} diff --git a/apps/web/src/modules/auth/components/RegisterForm.test.tsx b/apps/web/src/modules/auth/components/RegisterForm.test.tsx new file mode 100644 index 0000000..0ea74df --- /dev/null +++ b/apps/web/src/modules/auth/components/RegisterForm.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { AxiosError, type AxiosResponse } from "axios"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RegisterForm } from "./RegisterForm"; + +const registerMock = vi.fn(); + +vi.mock("../api/authApi", () => ({ + register: (...args: unknown[]) => registerMock(...args) +})); + +afterEach(() => { + cleanup(); + registerMock.mockReset(); +}); + +describe("RegisterForm", () => { + it("renders register form controls", () => { + render( + + + + ); + expect(screen.getByPlaceholderText("Email")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Password")).toBeInTheDocument(); + expect(screen.getByText(/uppercase, lowercase, and a digit/i)).toBeInTheDocument(); + expect(screen.getByText(/privacy policy/i)).toBeInTheDocument(); + }); + + it("shows validation error from API", async () => { + registerMock.mockRejectedValueOnce( + new AxiosError( + "Validation failed", + "422", + undefined, + undefined, + { + status: 422, + data: { + detail: [ + { + loc: ["body", "password"], + msg: "Value error, Password must include at least one uppercase letter" + } + ] + } + } as AxiosResponse + ) + ); + + render( + + + + ); + fireEvent.change(screen.getByPlaceholderText("Email"), { + target: { value: "user@example.com" } + }); + fireEvent.change(screen.getByPlaceholderText("Password"), { + target: { value: "valid123" } + }); + fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button", { name: "Register" })); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Password must include at least one uppercase letter" + ); + }); + }); +}); diff --git a/apps/web/src/modules/auth/components/RegisterForm.tsx b/apps/web/src/modules/auth/components/RegisterForm.tsx new file mode 100644 index 0000000..bf63fd4 --- /dev/null +++ b/apps/web/src/modules/auth/components/RegisterForm.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { register } from "../api/authApi"; +import { formatAuthError } from "../utils/formatAuthError"; +import { AuthField } from "./AuthField"; +import { AuthMessage } from "./AuthMessage"; +import { AuthSubmit } from "./AuthSubmit"; +import { AuthSwitch } from "./AuthSwitch"; + +function UserIcon(): JSX.Element { + return ( + + + + ); +} + +function LockIcon(): JSX.Element { + return ( + + + + ); +} + +export function RegisterForm(): JSX.Element { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [consent, setConsent] = useState(false); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + return ( +
{ + event.preventDefault(); + if (!consent) { + setError("Please accept the privacy policy to register."); + return; + } + setMessage(""); + setError(""); + setIsSubmitting(true); + try { + await register({ email, password }); + setMessage("Registration submitted. Check your email."); + } catch (registerError) { + setError(formatAuthError(registerError, "Registration failed")); + } finally { + setIsSubmitting(false); + } + }} + > + } + placeholder="Email" + type="email" + autoComplete="email" + value={email} + onChange={(event) => setEmail(event.target.value)} + required + /> + } + placeholder="Password" + type="password" + autoComplete="new-password" + value={password} + onChange={(event) => setPassword(event.target.value)} + required + minLength={8} + /> +

+ Password: at least 8 characters with uppercase, lowercase, and a digit. +

+ setConsent(event.target.checked)}> + I agree to the{" "} + + privacy policy + + . + + + {error ? : null} + {message ? : null} +

+ Already registered?{" "} + + Login + +

+ + ); +} diff --git a/apps/web/src/modules/auth/hooks/useAuth.ts b/apps/web/src/modules/auth/hooks/useAuth.ts new file mode 100644 index 0000000..fe458dd --- /dev/null +++ b/apps/web/src/modules/auth/hooks/useAuth.ts @@ -0,0 +1,30 @@ +import { useAuthStore } from "../store/authStore"; +import type { AuthUser } from "../store/authStore"; +import { login as apiLogin, logout as apiLogout, refresh as apiRefresh } from "../api/authApi"; + +export function useAuth() { + const user = useAuthStore((state) => state.user); + const accessToken = useAuthStore((state) => state.accessToken); + const bootstrapped = useAuthStore((state) => state.bootstrapped); + const setSession = useAuthStore((state) => state.setSession); + const clearSession = useAuthStore((state) => state.clearSession); + + return { + user, + isAuthenticated: Boolean(user && accessToken), + bootstrapped, + async login(email: string, password: string) { + const data = await apiLogin({ email, password }); + setSession(data.access_token, data.user); + return data.user as AuthUser; + }, + async logout() { + await apiLogout(); + clearSession(); + }, + async refreshSession() { + const data = await apiRefresh(); + setSession(data.access_token, data.user); + } + }; +} diff --git a/apps/web/src/modules/auth/hooks/useIsSuperuser.ts b/apps/web/src/modules/auth/hooks/useIsSuperuser.ts new file mode 100644 index 0000000..ab05357 --- /dev/null +++ b/apps/web/src/modules/auth/hooks/useIsSuperuser.ts @@ -0,0 +1,6 @@ +import { useAuthStore } from "../store/authStore"; + +export function useIsSuperuser(): boolean { + const user = useAuthStore((state) => state.user); + return Boolean(user?.role === "admin" && user?.is_superuser); +} diff --git a/apps/web/src/modules/auth/index.ts b/apps/web/src/modules/auth/index.ts new file mode 100644 index 0000000..40600ad --- /dev/null +++ b/apps/web/src/modules/auth/index.ts @@ -0,0 +1,4 @@ +export { LoginForm } from "./components/LoginForm"; +export { RegisterForm } from "./components/RegisterForm"; +export { useAuth } from "./hooks/useAuth"; +export { useIsSuperuser } from "./hooks/useIsSuperuser"; diff --git a/apps/web/src/modules/auth/store/authSessionHint.test.ts b/apps/web/src/modules/auth/store/authSessionHint.test.ts new file mode 100644 index 0000000..86b4bf3 --- /dev/null +++ b/apps/web/src/modules/auth/store/authSessionHint.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + clearAuthSessionHint, + hasAuthSessionHint, + isProtectedAppPath, + markAuthSessionHint, + shouldAttemptAuthRefresh +} from "./authSessionHint"; + +describe("authSessionHint", () => { + afterEach(() => { + sessionStorage.clear(); + }); + + it("tracks session hint in sessionStorage", () => { + expect(hasAuthSessionHint()).toBe(false); + markAuthSessionHint(); + expect(hasAuthSessionHint()).toBe(true); + clearAuthSessionHint(); + expect(hasAuthSessionHint()).toBe(false); + }); + + it("detects protected app paths", () => { + expect(isProtectedAppPath("/admin")).toBe(true); + expect(isProtectedAppPath("/admin/users")).toBe(true); + expect(isProtectedAppPath("/profile")).toBe(true); + expect(isProtectedAppPath("/login")).toBe(false); + }); + + it("attempts refresh on protected paths even without hint", () => { + expect(shouldAttemptAuthRefresh("/admin")).toBe(true); + expect(shouldAttemptAuthRefresh("/login")).toBe(false); + }); + + it("attempts refresh when hint is present", () => { + markAuthSessionHint(); + expect(shouldAttemptAuthRefresh("/login")).toBe(true); + }); +}); diff --git a/apps/web/src/modules/auth/store/authSessionHint.ts b/apps/web/src/modules/auth/store/authSessionHint.ts new file mode 100644 index 0000000..9c6e6ac --- /dev/null +++ b/apps/web/src/modules/auth/store/authSessionHint.ts @@ -0,0 +1,24 @@ +const AUTH_HINT_KEY = "compton-auth-hint"; +const PROTECTED_PATH_PREFIXES = ["/admin", "/profile"] as const; + +export function markAuthSessionHint(): void { + sessionStorage.setItem(AUTH_HINT_KEY, "1"); +} + +export function clearAuthSessionHint(): void { + sessionStorage.removeItem(AUTH_HINT_KEY); +} + +export function hasAuthSessionHint(): boolean { + return sessionStorage.getItem(AUTH_HINT_KEY) === "1"; +} + +export function isProtectedAppPath(pathname: string): boolean { + return PROTECTED_PATH_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`) + ); +} + +export function shouldAttemptAuthRefresh(pathname = window.location.pathname): boolean { + return hasAuthSessionHint() || isProtectedAppPath(pathname); +} diff --git a/apps/web/src/modules/auth/store/authStore.test.ts b/apps/web/src/modules/auth/store/authStore.test.ts new file mode 100644 index 0000000..9547d92 --- /dev/null +++ b/apps/web/src/modules/auth/store/authStore.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { useAuthStore } from "./authStore"; + +describe("authStore", () => { + it("stores and clears session in memory", () => { + useAuthStore.getState().setBootstrapped(true); + useAuthStore.getState().setSession("token", { + id: "1", + email: "u@e.com", + role: "user", + is_superuser: false, + status: "active" + }); + expect(useAuthStore.getState().accessToken).toBe("token"); + useAuthStore.getState().clearSession(); + expect(useAuthStore.getState().accessToken).toBeNull(); + }); +}); diff --git a/apps/web/src/modules/auth/store/authStore.ts b/apps/web/src/modules/auth/store/authStore.ts new file mode 100644 index 0000000..e98ceaa --- /dev/null +++ b/apps/web/src/modules/auth/store/authStore.ts @@ -0,0 +1,37 @@ +import { create } from "zustand"; +import { clearAuthSessionHint, markAuthSessionHint } from "@modules/auth/store/authSessionHint"; + +export type UserRole = "user" | "admin"; +export type UserStatus = "active" | "pending" | "blocked"; + +export interface AuthUser { + id: string; + email: string; + role: UserRole; + is_superuser: boolean; + status: UserStatus; +} + +interface AuthState { + accessToken: string | null; + user: AuthUser | null; + bootstrapped: boolean; + setSession: (accessToken: string, user: AuthUser) => void; + clearSession: () => void; + setBootstrapped: (bootstrapped: boolean) => void; +} + +export const useAuthStore = create()((set) => ({ + accessToken: null, + user: null, + bootstrapped: false, + setSession: (accessToken, user) => { + markAuthSessionHint(); + set({ accessToken, user }); + }, + clearSession: () => { + clearAuthSessionHint(); + set({ accessToken: null, user: null }); + }, + setBootstrapped: (bootstrapped) => set({ bootstrapped }) +})); diff --git a/apps/web/src/modules/auth/utils/formatAuthError.test.ts b/apps/web/src/modules/auth/utils/formatAuthError.test.ts new file mode 100644 index 0000000..36a42f7 --- /dev/null +++ b/apps/web/src/modules/auth/utils/formatAuthError.test.ts @@ -0,0 +1,37 @@ +import { AxiosError, type AxiosResponse } from "axios"; +import { describe, expect, it } from "vitest"; +import { formatAuthError } from "./formatAuthError"; + +describe("formatAuthError", () => { + it("shows network message when API is unreachable", () => { + const message = formatAuthError(new AxiosError("Network Error", "ERR_NETWORK"), "Login failed"); + expect(message).toBe( + "Cannot reach the API. Make sure the backend is running and reload the page." + ); + }); + + it("extracts password validation message", () => { + const message = formatAuthError( + new AxiosError( + "Validation failed", + "422", + undefined, + undefined, + { + status: 422, + data: { + detail: [ + { + loc: ["body", "password"], + msg: "Value error, Password must include at least one digit" + } + ] + } + } as AxiosResponse + ), + "Registration failed" + ); + + expect(message).toBe("Password must include at least one digit"); + }); +}); diff --git a/apps/web/src/modules/auth/utils/formatAuthError.ts b/apps/web/src/modules/auth/utils/formatAuthError.ts new file mode 100644 index 0000000..49cabd3 --- /dev/null +++ b/apps/web/src/modules/auth/utils/formatAuthError.ts @@ -0,0 +1,35 @@ +import { isAxiosError } from "axios"; + +type ValidationDetail = { + loc?: (string | number)[]; + msg?: string; +}; + +function formatValidationDetail(detail: ValidationDetail[]): string { + const passwordError = detail.find((item) => item.loc?.includes("password")); + if (passwordError?.msg) { + return passwordError.msg.replace(/^Value error, /, ""); + } + const emailError = detail.find((item) => item.loc?.includes("email")); + if (emailError?.msg) { + return "Enter a valid email address"; + } + return "Invalid email or password format"; +} + +export function formatAuthError(error: unknown, fallback: string): string { + if (!isAxiosError(error)) { + return fallback; + } + if (!error.response) { + return "Cannot reach the API. Make sure the backend is running and reload the page."; + } + const detail = error.response?.data?.detail; + if (typeof detail === "string") { + return detail; + } + if (Array.isArray(detail) && detail.length > 0) { + return formatValidationDetail(detail as ValidationDetail[]); + } + return fallback; +} diff --git a/apps/web/src/modules/catalog/index.test.ts b/apps/web/src/modules/catalog/index.test.ts new file mode 100644 index 0000000..9b50c98 --- /dev/null +++ b/apps/web/src/modules/catalog/index.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { CatalogPlaceholder } from "./index"; + +describe("CatalogPlaceholder", () => { + it("returns stable marker", () => { + expect(CatalogPlaceholder()).toBe("catalog-v1"); + }); +}); diff --git a/apps/web/src/modules/catalog/index.ts b/apps/web/src/modules/catalog/index.ts new file mode 100644 index 0000000..8970360 --- /dev/null +++ b/apps/web/src/modules/catalog/index.ts @@ -0,0 +1,3 @@ +export function CatalogPlaceholder(): string { + return "catalog-v1"; +} diff --git a/apps/web/src/modules/content/api/contentApi.test.ts b/apps/web/src/modules/content/api/contentApi.test.ts new file mode 100644 index 0000000..9de8874 --- /dev/null +++ b/apps/web/src/modules/content/api/contentApi.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createContentPage, + deleteContentPage, + getAdminPages, + getPageBySlug, + updateContentPage +} from "./contentApi"; + +vi.mock("@shared/api/client", () => ({ + apiClient: { + get: vi.fn(async (url: string) => { + if (url === "/api/v1/content/pages/manage/all") { + return { data: { data: [{ id: "1", slug: "about", title: "About", body: "", status: "published" }] } }; + } + return { data: { slug: "about", title: "About" } }; + }), + post: vi.fn(async () => ({ data: { id: "2", slug: "new", title: "New", body: "", status: "draft" } })), + patch: vi.fn(async () => ({ data: { id: "1", slug: "about", title: "Updated", body: "", status: "published" } })), + delete: vi.fn(async () => ({ data: {} })) + } +})); + +describe("contentApi", () => { + it("loads page by slug", async () => { + const data = await getPageBySlug("about"); + expect(data.slug).toBe("about"); + }); + + it("loads admin pages", async () => { + const pages = await getAdminPages(); + expect(pages[0].slug).toBe("about"); + }); + + it("creates content page", async () => { + const page = await createContentPage({ + slug: "new", + title: "New", + body: "

x

", + status: "draft" + }); + expect(page.slug).toBe("new"); + }); + + it("updates content page", async () => { + const page = await updateContentPage("1", { title: "Updated" }); + expect(page.title).toBe("Updated"); + }); + + it("deletes content page", async () => { + await expect(deleteContentPage("1")).resolves.toBeUndefined(); + }); +}); diff --git a/apps/web/src/modules/content/api/contentApi.ts b/apps/web/src/modules/content/api/contentApi.ts new file mode 100644 index 0000000..73aa746 --- /dev/null +++ b/apps/web/src/modules/content/api/contentApi.ts @@ -0,0 +1,41 @@ +import { apiClient } from "@shared/api/client"; + +export interface ContentPage { + id: string; + slug: string; + title: string; + body: string; + status: string; +} + +export async function getPageBySlug(slug: string) { + const { data } = await apiClient.get(`/api/v1/content/pages/${slug}`); + return data; +} + +export async function getAdminPages(): Promise { + const { data } = await apiClient.get<{ data: ContentPage[] }>("/api/v1/content/pages/manage/all"); + return data.data; +} + +export async function createContentPage(payload: { + slug: string; + title: string; + body: string; + status: string; +}): Promise { + const { data } = await apiClient.post("/api/v1/content/pages", payload); + return data; +} + +export async function updateContentPage( + pageId: string, + payload: { title?: string; body?: string; status?: string } +): Promise { + const { data } = await apiClient.patch(`/api/v1/content/pages/${pageId}`, payload); + return data; +} + +export async function deleteContentPage(pageId: string): Promise { + await apiClient.delete(`/api/v1/content/pages/${pageId}`); +} diff --git a/apps/web/src/modules/content/index.ts b/apps/web/src/modules/content/index.ts new file mode 100644 index 0000000..3e22063 --- /dev/null +++ b/apps/web/src/modules/content/index.ts @@ -0,0 +1 @@ +export { getPageBySlug } from "./api/contentApi"; diff --git a/apps/web/src/modules/landing/components/BrandSection.tsx b/apps/web/src/modules/landing/components/BrandSection.tsx new file mode 100644 index 0000000..1d1dc3a --- /dev/null +++ b/apps/web/src/modules/landing/components/BrandSection.tsx @@ -0,0 +1,16 @@ +import { Link } from "react-router-dom"; + +export function BrandSection(): JSX.Element { + return ( +
+

About Compton

+

+ Compton combines organic aesthetics with modern technology for secure accounts, + profile management, and content publishing. +

+

+ Read more about the brand +

+
+ ); +} diff --git a/apps/web/src/modules/landing/components/ContactsSection.tsx b/apps/web/src/modules/landing/components/ContactsSection.tsx new file mode 100644 index 0000000..2ca99fb --- /dev/null +++ b/apps/web/src/modules/landing/components/ContactsSection.tsx @@ -0,0 +1,9 @@ +export function ContactsSection(): JSX.Element { + return ( +
+

Contacts

+

Email: hello@compton.example

+

Support hours: Mon–Fri, 10:00–18:00 (UTC+3)

+
+ ); +} diff --git a/apps/web/src/modules/landing/components/HeroSection.tsx b/apps/web/src/modules/landing/components/HeroSection.tsx new file mode 100644 index 0000000..dab470b --- /dev/null +++ b/apps/web/src/modules/landing/components/HeroSection.tsx @@ -0,0 +1,8 @@ +export function HeroSection(): JSX.Element { + return ( +
+

Organic Tech / Compton

+

Modern platform with secure accounts and modular architecture.

+
+ ); +} diff --git a/apps/web/src/modules/landing/components/MarqueeSection.tsx b/apps/web/src/modules/landing/components/MarqueeSection.tsx new file mode 100644 index 0000000..f2a829c --- /dev/null +++ b/apps/web/src/modules/landing/components/MarqueeSection.tsx @@ -0,0 +1,10 @@ +export function MarqueeSection(): JSX.Element { + return ( +
+
+ Organic Tech — Compton — Organic Tech — Compton — Organic Tech — Compton + +
+
+ ); +} diff --git a/apps/web/src/modules/landing/index.ts b/apps/web/src/modules/landing/index.ts new file mode 100644 index 0000000..c15b394 --- /dev/null +++ b/apps/web/src/modules/landing/index.ts @@ -0,0 +1,4 @@ +export { HeroSection } from "./components/HeroSection"; +export { MarqueeSection } from "./components/MarqueeSection"; +export { BrandSection } from "./components/BrandSection"; +export { ContactsSection } from "./components/ContactsSection"; diff --git a/apps/web/src/modules/orders/index.test.ts b/apps/web/src/modules/orders/index.test.ts new file mode 100644 index 0000000..362fc55 --- /dev/null +++ b/apps/web/src/modules/orders/index.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { OrdersPlaceholder } from "./index"; + +describe("OrdersPlaceholder", () => { + it("returns stable marker", () => { + expect(OrdersPlaceholder()).toBe("orders-v1"); + }); +}); diff --git a/apps/web/src/modules/orders/index.ts b/apps/web/src/modules/orders/index.ts new file mode 100644 index 0000000..2b4b979 --- /dev/null +++ b/apps/web/src/modules/orders/index.ts @@ -0,0 +1,3 @@ +export function OrdersPlaceholder(): string { + return "orders-v1"; +} diff --git a/apps/web/src/modules/profile/api/profileApi.test.ts b/apps/web/src/modules/profile/api/profileApi.test.ts new file mode 100644 index 0000000..ce04219 --- /dev/null +++ b/apps/web/src/modules/profile/api/profileApi.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import { changePassword, getProfile, updateProfile, uploadAvatar } from "./profileApi"; + +vi.mock("@shared/api/client", () => ({ + apiClient: { + get: vi.fn(async () => ({ + data: { + user: { email: "u@example.com" }, + profile: { display_name: "User", avatar_url: null } + } + })), + patch: vi.fn(async () => ({ data: { profile: { display_name: "User" } } })), + post: vi.fn(async () => ({ data: { profile: { avatar_url: "/api/v1/media/files/x" } } })) + } +})); + +describe("profileApi", () => { + it("gets profile", async () => { + const data = await getProfile(); + expect(data.user.email).toBe("u@example.com"); + }); + + it("updates profile", async () => { + const data = await updateProfile({ display_name: "User" }); + expect(data.profile.display_name).toBe("User"); + }); + + it("changes password", async () => { + await expect( + changePassword({ current_password: "Valid123", new_password: "NewValid1" }) + ).resolves.toBeUndefined(); + }); + + it("uploads avatar", async () => { + const file = new File(["avatar"], "avatar.png", { type: "image/png" }); + const data = await uploadAvatar(file); + expect(data.profile.avatar_url).toBe("/api/v1/media/files/x"); + }); +}); diff --git a/apps/web/src/modules/profile/api/profileApi.ts b/apps/web/src/modules/profile/api/profileApi.ts new file mode 100644 index 0000000..1484860 --- /dev/null +++ b/apps/web/src/modules/profile/api/profileApi.ts @@ -0,0 +1,40 @@ +import { apiClient } from "@shared/api/client"; + +export interface ProfileResponse { + user: { + id: string; + email: string; + role: string; + status: string; + }; + profile: { + display_name: string; + avatar_url: string | null; + }; +} + +export async function getProfile(): Promise { + const { data } = await apiClient.get("/api/v1/users/me"); + return data; +} + +export async function updateProfile(payload: { display_name?: string }): Promise { + const { data } = await apiClient.patch("/api/v1/users/me", payload); + return data; +} + +export async function changePassword(payload: { + current_password: string; + new_password: string; +}): Promise { + await apiClient.post("/api/v1/users/me/password", payload); +} + +export async function uploadAvatar(file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + const { data } = await apiClient.post("/api/v1/users/me/avatar", formData, { + headers: { "Content-Type": "multipart/form-data" } + }); + return data; +} diff --git a/apps/web/src/modules/profile/components/ProfileCard.test.tsx b/apps/web/src/modules/profile/components/ProfileCard.test.tsx new file mode 100644 index 0000000..9f1bc98 --- /dev/null +++ b/apps/web/src/modules/profile/components/ProfileCard.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ProfileCard } from "./ProfileCard"; + +describe("ProfileCard", () => { + it("renders profile info and avatar", () => { + render( + + ); + expect(screen.getByText("Compton User")).toBeInTheDocument(); + expect(screen.getByText("user@example.com")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Compton User avatar" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/modules/profile/components/ProfileCard.tsx b/apps/web/src/modules/profile/components/ProfileCard.tsx new file mode 100644 index 0000000..e0a77e1 --- /dev/null +++ b/apps/web/src/modules/profile/components/ProfileCard.tsx @@ -0,0 +1,29 @@ +import { getApiBaseUrl } from "@shared/api/config"; + +interface ProfileCardProps { + email: string; + displayName: string; + avatarUrl?: string | null; +} + +export function ProfileCard({ email, displayName, avatarUrl }: ProfileCardProps): JSX.Element { + const apiBase = getApiBaseUrl(); + const resolvedAvatar = avatarUrl?.startsWith("/") ? `${apiBase}${avatarUrl}` : avatarUrl; + + return ( +
+

Profile

+ {resolvedAvatar ? ( + {`${displayName} + ) : null} +

{displayName}

+

{email}

+
+ ); +} diff --git a/apps/web/src/modules/profile/components/ProfileEditor.test.tsx b/apps/web/src/modules/profile/components/ProfileEditor.test.tsx new file mode 100644 index 0000000..efa1fdf --- /dev/null +++ b/apps/web/src/modules/profile/components/ProfileEditor.test.tsx @@ -0,0 +1,30 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ProfileEditor } from "./ProfileEditor"; + +vi.mock("../api/profileApi", () => ({ + updateProfile: vi.fn(), + changePassword: vi.fn(), + uploadAvatar: vi.fn() +})); + +const profile = { + user: { id: "1", email: "user@example.com", role: "user", is_superuser: false, status: "active" }, + profile: { display_name: "Compton User", avatar_url: null } +}; + +describe("ProfileEditor", () => { + it("renders profile edit form", () => { + const queryClient = new QueryClient(); + render( + + + + ); + expect(screen.getByRole("heading", { name: "Edit profile" })).toBeInTheDocument(); + expect(screen.getByDisplayValue("Compton User")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save name" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Change password" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/modules/profile/components/ProfileEditor.tsx b/apps/web/src/modules/profile/components/ProfileEditor.tsx new file mode 100644 index 0000000..6bec72d --- /dev/null +++ b/apps/web/src/modules/profile/components/ProfileEditor.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button, Input } from "@shared/ui"; +import { changePassword, updateProfile, uploadAvatar, type ProfileResponse } from "../api/profileApi"; + +interface ProfileEditorProps { + profile: ProfileResponse; +} + +export function ProfileEditor({ profile }: ProfileEditorProps): JSX.Element { + const queryClient = useQueryClient(); + const [displayName, setDisplayName] = useState(profile.profile.display_name); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [message, setMessage] = useState(""); + + const updateMutation = useMutation({ + mutationFn: () => updateProfile({ display_name: displayName }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["profile-me"] }); + setMessage("Profile updated"); + } + }); + + const passwordMutation = useMutation({ + mutationFn: () => changePassword({ current_password: currentPassword, new_password: newPassword }), + onSuccess: () => { + setCurrentPassword(""); + setNewPassword(""); + setMessage("Password changed"); + }, + onError: () => setMessage("Invalid current password") + }); + + const avatarMutation = useMutation({ + mutationFn: (file: File) => uploadAvatar(file), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["profile-me"] }); + setMessage("Avatar uploaded"); + }, + onError: () => setMessage("Avatar upload failed") + }); + + return ( +
+

Edit profile

+ + + + + +
+

Change password

+ setCurrentPassword(event.target.value)} + /> + setNewPassword(event.target.value)} + /> + +
+ + {message ?

{message}

: null} +
+ ); +} diff --git a/apps/web/src/modules/profile/index.ts b/apps/web/src/modules/profile/index.ts new file mode 100644 index 0000000..f859038 --- /dev/null +++ b/apps/web/src/modules/profile/index.ts @@ -0,0 +1,3 @@ +export { ProfileCard } from "./components/ProfileCard"; +export { ProfileEditor } from "./components/ProfileEditor"; +export { getProfile, updateProfile, changePassword, uploadAvatar } from "./api/profileApi"; diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx new file mode 100644 index 0000000..0e4d506 --- /dev/null +++ b/apps/web/src/pages/AdminPage.tsx @@ -0,0 +1,5 @@ +import { AdminPanel } from "@modules/admin/components/AdminPanel"; + +export default function AdminPage(): JSX.Element { + return ; +} diff --git a/apps/web/src/pages/ContentPage.test.tsx b/apps/web/src/pages/ContentPage.test.tsx new file mode 100644 index 0000000..572a1c6 --- /dev/null +++ b/apps/web/src/pages/ContentPage.test.tsx @@ -0,0 +1,44 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ContentPage } from "./ContentPage"; +import * as contentApi from "@modules/content/api/contentApi"; + +function renderPage(slug = "about") { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + return render( + + + + } /> + + + + ); +} + +describe("ContentPage", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("renders fetched page title and body", async () => { + vi.spyOn(contentApi, "getPageBySlug").mockResolvedValue({ + id: "1", + slug: "about", + title: "About Us", + body: "

About content

", + status: "published" + }); + + renderPage("about"); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "About Us" })).toBeInTheDocument(); + }); + expect(screen.getByText("About content")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/pages/ContentPage.tsx b/apps/web/src/pages/ContentPage.tsx new file mode 100644 index 0000000..f3b781d --- /dev/null +++ b/apps/web/src/pages/ContentPage.tsx @@ -0,0 +1,40 @@ +import DOMPurify from "dompurify"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router-dom"; +import { getPageBySlug } from "@modules/content/api/contentApi"; + +const ALLOWED_TAGS = ["p", "h1", "h2", "h3", "h4", "ul", "ol", "li", "a", "strong", "em", "br", "img"]; + +export function ContentPage(): JSX.Element { + const { slug } = useParams(); + const { data, isLoading, isError } = useQuery({ + queryKey: ["content-page", slug], + queryFn: () => getPageBySlug(slug ?? ""), + enabled: Boolean(slug) + }); + + if (isLoading) { + return ( +
+

Loading...

+
+ ); + } + + if (isError || !data) { + return ( +
+

Page not found

+
+ ); + } + + const safeHtml = DOMPurify.sanitize(data.body, { ALLOWED_TAGS }); + + return ( +
+

{data.title}

+
+
+ ); +} diff --git a/apps/web/src/pages/ForgotPasswordPage.tsx b/apps/web/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 0000000..3980700 --- /dev/null +++ b/apps/web/src/pages/ForgotPasswordPage.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { forgotPassword } from "@modules/auth/api/authApi"; +import { formatAuthError } from "@modules/auth/utils/formatAuthError"; +import { AuthLayout } from "@modules/auth/components/AuthLayout"; +import { AuthField } from "@modules/auth/components/AuthField"; +import { AuthMessage } from "@modules/auth/components/AuthMessage"; +import { AuthSubmit } from "@modules/auth/components/AuthSubmit"; + +function UserIcon(): JSX.Element { + return ( + + + + ); +} + +export function ForgotPasswordPage(): JSX.Element { + const [email, setEmail] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + return ( + +
{ + event.preventDefault(); + setMessage(""); + setError(""); + setIsSubmitting(true); + try { + const data = await forgotPassword(email); + setMessage(data.message ?? "If email is registered, reset instructions have been sent."); + } catch (forgotError) { + setError(formatAuthError(forgotError, "Request failed")); + } finally { + setIsSubmitting(false); + } + }} + > + } + placeholder="Email" + type="email" + autoComplete="email" + value={email} + onChange={(event) => setEmail(event.target.value)} + required + /> + + {message ? : null} + {error ? : null} +

+ + Back to login + +

+ +
+ ); +} diff --git a/apps/web/src/pages/HomePage.test.tsx b/apps/web/src/pages/HomePage.test.tsx new file mode 100644 index 0000000..ebaf913 --- /dev/null +++ b/apps/web/src/pages/HomePage.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { HomePage } from "./HomePage"; + +describe("HomePage", () => { + it("renders landing sections", () => { + render( + + + + ); + expect(screen.getByRole("heading", { name: "Organic Tech / Compton" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "About Compton" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Contacts" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..5847c02 --- /dev/null +++ b/apps/web/src/pages/HomePage.tsx @@ -0,0 +1,12 @@ +import { HeroSection, MarqueeSection, BrandSection, ContactsSection } from "@modules/landing"; + +export function HomePage(): JSX.Element { + return ( +
+ + + + +
+ ); +} diff --git a/apps/web/src/pages/LoginPage.test.tsx b/apps/web/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..2e56ce0 --- /dev/null +++ b/apps/web/src/pages/LoginPage.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { LoginPage } from "./LoginPage"; + +describe("LoginPage", () => { + it("renders login heading", () => { + render( + + + + ); + expect(screen.getByRole("heading", { name: "Вход" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..e02a61b --- /dev/null +++ b/apps/web/src/pages/LoginPage.tsx @@ -0,0 +1,10 @@ +import { LoginForm } from "@modules/auth"; +import { AuthLayout } from "@modules/auth/components/AuthLayout"; + +export function LoginPage(): JSX.Element { + return ( + + + + ); +} diff --git a/apps/web/src/pages/ProfilePage.tsx b/apps/web/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..c100805 --- /dev/null +++ b/apps/web/src/pages/ProfilePage.tsx @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import { ProfileCard, ProfileEditor } from "@modules/profile"; +import { getProfile } from "@modules/profile/api/profileApi"; + +export default function ProfilePage(): JSX.Element { + const { data, isLoading } = useQuery({ + queryKey: ["profile-me"], + queryFn: getProfile + }); + + if (isLoading || !data) { + return ( +
+

Loading profile...

+
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/pages/RegisterPage.test.tsx b/apps/web/src/pages/RegisterPage.test.tsx new file mode 100644 index 0000000..ed26276 --- /dev/null +++ b/apps/web/src/pages/RegisterPage.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { RegisterPage } from "./RegisterPage"; + +describe("RegisterPage", () => { + it("renders register heading", () => { + render( + + + + ); + expect(screen.getByRole("heading", { name: "Регистрация" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/pages/RegisterPage.tsx b/apps/web/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..b66578f --- /dev/null +++ b/apps/web/src/pages/RegisterPage.tsx @@ -0,0 +1,10 @@ +import { RegisterForm } from "@modules/auth"; +import { AuthLayout } from "@modules/auth/components/AuthLayout"; + +export function RegisterPage(): JSX.Element { + return ( + + + + ); +} diff --git a/apps/web/src/pages/ResetPasswordPage.tsx b/apps/web/src/pages/ResetPasswordPage.tsx new file mode 100644 index 0000000..7002adf --- /dev/null +++ b/apps/web/src/pages/ResetPasswordPage.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { resetPassword } from "@modules/auth/api/authApi"; +import { formatAuthError } from "@modules/auth/utils/formatAuthError"; +import { AuthField } from "@modules/auth/components/AuthField"; +import { AuthLayout } from "@modules/auth/components/AuthLayout"; +import { AuthMessage } from "@modules/auth/components/AuthMessage"; +import { AuthSubmit } from "@modules/auth/components/AuthSubmit"; + +function LockIcon(): JSX.Element { + return ( + + + + ); +} + +export function ResetPasswordPage(): JSX.Element { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const token = searchParams.get("token") ?? ""; + + return ( + +
{ + event.preventDefault(); + setMessage(""); + setError(""); + setIsSubmitting(true); + try { + await resetPassword(token, password); + setMessage("Password updated. You can log in now."); + setTimeout(() => navigate("/login"), 1200); + } catch (resetError) { + setError(formatAuthError(resetError, "Password reset failed")); + } finally { + setIsSubmitting(false); + } + }} + > + {!token ? : null} + } + placeholder="New password" + type="password" + autoComplete="new-password" + value={password} + onChange={(event) => setPassword(event.target.value)} + required + minLength={8} + disabled={!token} + /> +

+ Password: at least 8 characters with uppercase, lowercase, and a digit. +

+ + {message ? : null} + {error ? : null} +

+ + Back to login + +

+ +
+ ); +} diff --git a/apps/web/src/pages/VerifyPage.tsx b/apps/web/src/pages/VerifyPage.tsx new file mode 100644 index 0000000..a0df6d3 --- /dev/null +++ b/apps/web/src/pages/VerifyPage.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { verifyEmail } from "@modules/auth/api/authApi"; +import { formatAuthError } from "@modules/auth/utils/formatAuthError"; +import { AuthLayout } from "@modules/auth/components/AuthLayout"; +import { AuthMessage } from "@modules/auth/components/AuthMessage"; + +export function VerifyPage(): JSX.Element { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [message, setMessage] = useState("Verifying your email..."); + const [error, setError] = useState(""); + + useEffect(() => { + const token = searchParams.get("token"); + if (!token) { + setError("Verification token is missing."); + setMessage(""); + return; + } + + void verifyEmail(token) + .then(() => { + setMessage("Email verified. You can log in now."); + setError(""); + }) + .catch((verifyError) => { + setMessage(""); + setError(formatAuthError(verifyError, "Verification failed")); + }); + }, [searchParams]); + + return ( + +
+ {message ? : null} + {error ? : null} + +

+ + Back home + +

+
+
+ ); +} diff --git a/apps/web/src/shared/api/client.test.ts b/apps/web/src/shared/api/client.test.ts new file mode 100644 index 0000000..4fa1d8f --- /dev/null +++ b/apps/web/src/shared/api/client.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { applyAuthHeader, setAccessTokenGetter } from "./client"; + +describe("apiClient auth header", () => { + it("attaches bearer token from getter", () => { + setAccessTokenGetter(() => "test-token"); + const headers = applyAuthHeader({}); + expect(headers.Authorization).toBe("Bearer test-token"); + }); + + it("leaves headers unchanged without token", () => { + setAccessTokenGetter(() => null); + const headers = applyAuthHeader({ "X-Test": "1" }); + expect(headers).toEqual({ "X-Test": "1" }); + }); +}); diff --git a/apps/web/src/shared/api/client.ts b/apps/web/src/shared/api/client.ts new file mode 100644 index 0000000..d237def --- /dev/null +++ b/apps/web/src/shared/api/client.ts @@ -0,0 +1,94 @@ +import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios"; +import { useAuthStore } from "@modules/auth/store/authStore"; +import { getApiBaseUrl } from "./config"; + +const apiBaseUrl = getApiBaseUrl(); + +export const apiClient = axios.create({ + baseURL: apiBaseUrl, + timeout: 10_000 +}); + +export const authClient = axios.create({ + baseURL: apiBaseUrl, + timeout: 10_000, + withCredentials: true +}); +let getAccessToken: () => string | null = () => null; +let refreshPromise: Promise | null = null; + +export function setAccessTokenGetter(getter: () => string | null): void { + getAccessToken = getter; +} + +export function applyAuthHeader(headers: Record): Record { + const token = getAccessToken(); + if (token) { + return { ...headers, Authorization: `Bearer ${token}` }; + } + return headers; +} + +export async function bootstrapSessionRefresh(): Promise { + return refreshAccessToken(); +} + +async function refreshAccessToken(): Promise { + if (!refreshPromise) { + refreshPromise = authClient + .post("/api/v1/auth/refresh") + .then((response) => { + const { access_token: accessToken, user } = response.data; + useAuthStore.getState().setSession(accessToken, user); + return accessToken as string; + }) + .catch(() => { + useAuthStore.getState().clearSession(); + return null; + }) + .finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; +} + +apiClient.interceptors.request.use((config) => { + config.headers = applyAuthHeader(config.headers as Record) as typeof config.headers; + return config; +}); + +apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const detail = + error.response && typeof error.response.data === "object" && error.response.data !== null + ? (error.response.data as { detail?: string }).detail + : undefined; + if ( + error.response?.status === 403 && + (detail === "ACCOUNT_BLOCKED" || detail === "EMAIL_NOT_VERIFIED") + ) { + useAuthStore.getState().clearSession(); + return Promise.reject(error); + } + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + if (error.response?.status !== 401 || !originalRequest || originalRequest._retry) { + return Promise.reject(error); + } + if (originalRequest.url?.includes("/api/v1/auth/")) { + return Promise.reject(error); + } + + originalRequest._retry = true; + const accessToken = await refreshAccessToken(); + if (!accessToken) { + return Promise.reject(error); + } + + originalRequest.headers = applyAuthHeader( + originalRequest.headers as Record + ) as typeof originalRequest.headers; + return apiClient.request(originalRequest); + } +); diff --git a/apps/web/src/shared/api/config.ts b/apps/web/src/shared/api/config.ts new file mode 100644 index 0000000..8fa9e7e --- /dev/null +++ b/apps/web/src/shared/api/config.ts @@ -0,0 +1,9 @@ +export function getApiBaseUrl(): string { + if (import.meta.env.VITE_USE_API_PROXY === "false") { + return import.meta.env.VITE_API_URL ?? "http://localhost:8000"; + } + if (import.meta.env.DEV || import.meta.env.VITE_USE_API_PROXY === "true") { + return ""; + } + return import.meta.env.VITE_API_URL ?? "http://localhost:8000"; +} diff --git a/apps/web/src/shared/theme/zootech/ThemeProvider.tsx b/apps/web/src/shared/theme/zootech/ThemeProvider.tsx new file mode 100644 index 0000000..1df4eb0 --- /dev/null +++ b/apps/web/src/shared/theme/zootech/ThemeProvider.tsx @@ -0,0 +1,19 @@ +import { useEffect, type PropsWithChildren } from "react"; + +export function ZootechThemeProvider({ children }: PropsWithChildren): JSX.Element { + useEffect(() => { + const root = document.documentElement; + const previousTheme = root.getAttribute("data-theme"); + root.setAttribute("data-theme", "organic"); + + return () => { + if (previousTheme) { + root.setAttribute("data-theme", previousTheme); + return; + } + root.removeAttribute("data-theme"); + }; + }, []); + + return <>{children}; +} diff --git a/apps/web/src/shared/theme/zootech/auth-login.css b/apps/web/src/shared/theme/zootech/auth-login.css new file mode 100644 index 0000000..a2cb6b2 --- /dev/null +++ b/apps/web/src/shared/theme/zootech/auth-login.css @@ -0,0 +1,113 @@ +@import "../../../../main/css/tokens.css"; + +.z-login-shell { + min-height: 100vh; + margin: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; + background: var(--bg-page); + color: var(--text); + font-family: var(--zootech-font); +} + +.z-login-page { + width: 100%; + max-width: 420px; +} + +.z-login-card { + background: var(--surface); + border: 1px solid var(--border-light); + border-radius: var(--zt-radius-lg); + box-shadow: var(--shadow-soft); + padding: 24px; +} + +.z-login-title { + margin: 0 0 16px; + font-size: 1.5rem; + font-weight: 600; +} + +.z-login-form { + display: grid; + gap: 12px; +} + +.z-login-field { + position: relative; +} + +.z-login-field-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--muted); + display: inline-flex; +} + +.z-login-input { + width: 100%; + border: 1px solid var(--border-light); + border-radius: 10px; + background: var(--primary-tint); + color: var(--text); + padding: 12px 12px 12px 40px; + font-size: 0.95rem; +} + +.z-login-submit { + border: 0; + border-radius: 999px; + background: var(--primary); + color: #fff; + font-size: 0.95rem; + font-weight: 600; + padding: 12px 14px; + cursor: pointer; +} + +.z-login-submit:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.z-login-meta, +.z-login-msg { + margin: 0; + font-size: 0.9rem; +} + +.z-login-meta { + color: var(--muted); +} + +.z-login-msg--error { + color: var(--color-error); +} + +.z-login-msg--success { + color: var(--color-success); +} + +.z-login-remember { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + color: var(--muted); +} + +.z-login-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.z-login-link { + color: inherit; +} diff --git a/apps/web/src/shared/ui/AppHeader/AppHeader.tsx b/apps/web/src/shared/ui/AppHeader/AppHeader.tsx new file mode 100644 index 0000000..39e425a --- /dev/null +++ b/apps/web/src/shared/ui/AppHeader/AppHeader.tsx @@ -0,0 +1,47 @@ +import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from "@modules/auth"; +import { Button } from "@shared/ui"; + +export function AppHeader(): JSX.Element { + const auth = useAuth(); + const navigate = useNavigate(); + + return ( +
+ + {auth.isAuthenticated ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/shared/ui/Button/Button.test.tsx b/apps/web/src/shared/ui/Button/Button.test.tsx new file mode 100644 index 0000000..42c5d95 --- /dev/null +++ b/apps/web/src/shared/ui/Button/Button.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Button } from "./Button"; + +describe("Button", () => { + it("renders button text", () => { + render(); + expect(screen.getByRole("button", { name: "Click" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/shared/ui/Button/Button.tsx b/apps/web/src/shared/ui/Button/Button.tsx new file mode 100644 index 0000000..341c94a --- /dev/null +++ b/apps/web/src/shared/ui/Button/Button.tsx @@ -0,0 +1,42 @@ +import type { ButtonHTMLAttributes, CSSProperties, PropsWithChildren } from "react"; + +type Variant = "primary" | "secondary" | "ghost"; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; +} + +const variantStyles: Record = { + primary: "background: var(--primary); color: white; border: 0;", + secondary: "background: var(--marquee-bg); color: var(--foreground); border: 0;", + ghost: "background: transparent; color: var(--foreground); border: 1px solid var(--muted);" +}; + +export function Button({ + children, + variant = "primary", + ...props +}: PropsWithChildren): JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/shared/ui/Input/Input.tsx b/apps/web/src/shared/ui/Input/Input.tsx new file mode 100644 index 0000000..b030ccb --- /dev/null +++ b/apps/web/src/shared/ui/Input/Input.tsx @@ -0,0 +1,15 @@ +import type { InputHTMLAttributes } from "react"; + +export function Input(props: InputHTMLAttributes): JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/shared/ui/index.ts b/apps/web/src/shared/ui/index.ts new file mode 100644 index 0000000..33d5391 --- /dev/null +++ b/apps/web/src/shared/ui/index.ts @@ -0,0 +1,3 @@ +export { Button } from "./Button/Button"; +export { Input } from "./Input/Input"; +export { AppHeader } from "./AppHeader/AppHeader"; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..9170d4f --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "paths": { + "@app/*": ["./src/app/*"], + "@pages/*": ["./src/pages/*"], + "@modules/*": ["./src/modules/*"], + "@shared/*": ["./src/shared/*"] + }, + "skipLibCheck": true + }, + "include": ["src", "e2e", "src/vite-env.d.ts", "src/global.d.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..db55907 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,68 @@ +/// +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; +import { mainStaticPlugin } from "./vite.main-static"; + +const apiProxyTarget = process.env.VITE_API_URL ?? "http://127.0.0.1:8000"; + +export default defineConfig({ + plugins: [react(), mainStaticPlugin(resolve(__dirname, "main"))], + build: { + rollupOptions: { + input: { + index: resolve(__dirname, "index.html"), + app: resolve(__dirname, "app.html") + } + } + }, + server: { + port: 5173, + strictPort: true, + proxy: { + "/api": { + target: apiProxyTarget, + changeOrigin: true, + secure: false + } + } + }, + resolve: { + alias: { + "@app": resolve(__dirname, "src/app"), + "@pages": resolve(__dirname, "src/pages"), + "@modules": resolve(__dirname, "src/modules"), + "@shared": resolve(__dirname, "src/shared") + } + }, + test: { + include: ["src/**/*.{test,spec}.{ts,tsx}"], + exclude: ["e2e/**", "node_modules/**"], + environment: "jsdom", + setupFiles: ["src/__tests__/setup.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.{ts,tsx}"], + exclude: [ + "src/**/*.d.ts", + "src/modules/**/index.ts", + "src/main.tsx", + "src/app/App.tsx", + "src/app/providers/**", + "src/app/router/guards/**", + "src/app/router/routes.tsx", + "src/pages/AdminPage.tsx", + "src/pages/ProfilePage.tsx", + "src/pages/VerifyPage.tsx", + "src/pages/ResetPasswordPage.tsx", + "src/pages/ForgotPasswordPage.tsx", + "src/shared/ui/AppHeader/**", + "src/modules/analytics/**" + ], + reporter: ["text", "html"], + thresholds: { + lines: 85 + } + } + } +}); diff --git a/apps/web/vite.main-static.ts b/apps/web/vite.main-static.ts new file mode 100644 index 0000000..ea4cd33 --- /dev/null +++ b/apps/web/vite.main-static.ts @@ -0,0 +1,86 @@ +import type { Connect, Plugin } from "vite"; +import { cpSync, createReadStream, existsSync, statSync } from "node:fs"; +import { extname, resolve } from "node:path"; + +const MIME_TYPES: Record = { + ".css": "text/css", + ".js": "application/javascript", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".mp4": "video/mp4", + ".ttf": "font/ttf", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".html": "text/html" +}; + +const APP_ROUTE_PREFIXES = [ + "/login", + "/register", + "/profile", + "/admin", + "/verify", + "/reset-password", + "/forgot-password", + "/pages" +]; + +function isAppRoute(url: string): boolean { + const path = url.split("?")[0]; + return APP_ROUTE_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)); +} + +function createMainStaticMiddleware(mainDir: string): Connect.NextHandleFunction { + const root = resolve(mainDir); + + return (req, res, next) => { + const urlPath = decodeURIComponent((req.url ?? "").split("?")[0]); + if (!urlPath || urlPath === "/") { + next(); + return; + } + + const relativePath = urlPath.replace(/^\/+/, ""); + const filePath = resolve(root, relativePath); + + if (!filePath.startsWith(root) || !existsSync(filePath) || !statSync(filePath).isFile()) { + next(); + return; + } + + const ext = extname(filePath).toLowerCase(); + res.setHeader("Content-Type", MIME_TYPES[ext] ?? "application/octet-stream"); + createReadStream(filePath).pipe(res); + }; +} + +function createAppFallbackMiddleware(): Connect.NextHandleFunction { + return (req, _res, next) => { + const url = req.url ?? ""; + if (isAppRoute(url)) { + req.url = "/app.html"; + } + next(); + }; +} + +export function mainStaticPlugin(mainDir: string): Plugin { + const resolvedMainDir = resolve(mainDir); + + return { + name: "kompton-main-static", + configureServer(server) { + server.middlewares.use("/main", createMainStaticMiddleware(resolvedMainDir)); + server.middlewares.use(createAppFallbackMiddleware()); + }, + configurePreviewServer(server) { + server.middlewares.use("/main", createMainStaticMiddleware(resolvedMainDir)); + server.middlewares.use(createAppFallbackMiddleware()); + }, + closeBundle() { + cpSync(resolvedMainDir, resolve(__dirname, "dist/main"), { recursive: true }); + } + }; +} diff --git a/data/secrets/install.env b/data/secrets/install.env new file mode 100644 index 0000000..bff00ce --- /dev/null +++ b/data/secrets/install.env @@ -0,0 +1,11 @@ +DATABASE_URL=postgresql+psycopg://compton_app:dhKPkf8LKMN8GPHjp5x3uHLQfytcM1stCg8M1SOdCko@postgres:5432/compton +JWT_ACCESS_SECRET=0134ee55c189ad13ceed51950754dce337ac6d4dd26d5119181d3dd58832380c +JWT_REFRESH_PEPPER=983b24c9566f06ee7a1975ee6dd843edf784d10f8bbfcc92e907b0cbf25c4811 +MINIO_ROOT_PASSWORD=8suhZMVmfRIHI_-TQ5NU3S4sXUiDIw7KG_8LvUR1SrU +MINIO_ROOT_USER=minio +POSTGRES_DB=compton +POSTGRES_PASSWORD=dhKPkf8LKMN8GPHjp5x3uHLQfytcM1stCg8M1SOdCko +POSTGRES_USER=compton_app +S3_ACCESS_KEY=minio +S3_SECRET_KEY=xcE4hgaqsdy2ErSse1-oTmgJHYvl6DgNfJlrMYWjy98 +SECRETS_LOCKED=true diff --git a/data/secrets/install.meta.json b/data/secrets/install.meta.json new file mode 100644 index 0000000..f2a9fdc --- /dev/null +++ b/data/secrets/install.meta.json @@ -0,0 +1,4 @@ +{ + "install_id": "10efbf3c-9185-4190-a75d-ea4e71e87575", + "locked_at": "2026-07-14T10:57:47.660813+00:00" +} diff --git a/data/security/password-denylist.txt b/data/security/password-denylist.txt new file mode 100644 index 0000000..2b16360 --- /dev/null +++ b/data/security/password-denylist.txt @@ -0,0 +1,30 @@ +password +password1 +password123 +123456 +12345678 +123456789 +qwerty +qwerty123 +admin +admin123 +admin1234 +welcome +welcome1 +letmein +letmein1 +iloveyou +iloveyou1 +sunshine +sunshine1 +football +football1 +baseball +baseball1 +dragon +dragon123 +monkey +monkey123 +passw0rd +passw0rd1 +trustno1 diff --git a/docker-compose.dev-ports.yml b/docker-compose.dev-ports.yml new file mode 100644 index 0000000..bd0f017 --- /dev/null +++ b/docker-compose.dev-ports.yml @@ -0,0 +1,13 @@ +services: + postgres: + ports: + - "5432:5432" + + redis: + ports: + - "6379:6379" + + minio: + ports: + - "9000:9000" + - "9001:9001" diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..19513b6 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,33 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: compton_test + ports: + - "5433:5432" + + redis: + image: redis:7-alpine + ports: + - "6380:6379" + + api: + build: ./apps/api + env_file: + - ./apps/api/.env.test + environment: + DATABASE_URL: postgresql+psycopg://test:test@postgres:5432/compton_test + depends_on: + - postgres + - redis + ports: + - "8001:8000" + + web: + build: ./apps/web + depends_on: + - api + ports: + - "5175:5173" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d14d500 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,63 @@ +services: + web: + profiles: ["docker-web"] + build: + context: . + dockerfile: apps/web/Dockerfile + ports: + - "5173:5173" + environment: + VITE_USE_API_PROXY: "true" + VITE_API_URL: http://api:8000 + # File-watcher polling for bind-mounted sources on Windows/macOS + CHOKIDAR_USEPOLLING: "true" + WATCHPACK_POLLING: "true" + volumes: + # Live-reload: mount the whole monorepo so workspace packages stay in sync + - .:/app + # Anonymous volumes: keep container-installed node_modules from being + # overwritten by the host (empty dir or different OS/arch binaries) + - /app/node_modules + - /app/apps/web/node_modules + depends_on: + - api + - postgres + - redis + - minio + + api: + build: ./apps/api + ports: + - "8000:8000" + environment: + S3_ENDPOINT: http://minio:9000 + STORAGE_MODE: s3 + REDIS_URL: redis://redis:6379/0 + CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]' + FRONTEND_URL: http://localhost:5173 + PUBLIC_BASE_URL: http://localhost:5173 + COOKIE_SECURE: "false" + EMAIL_DELIVERY_MODE: memory + ENABLE_RATE_LIMIT: "true" + env_file: + - ./apps/api/.env.example + - ./apps/api/data/secrets/install.env + volumes: + - ./apps/api/data/secrets:/app/data/secrets + depends_on: + - postgres + - redis + - minio + + postgres: + image: postgres:16 + env_file: + - ./apps/api/data/secrets/install.env + redis: + image: redis:7-alpine + + minio: + image: minio/minio + command: server /data --console-address ":9001" + env_file: + - ./apps/api/data/secrets/install.env diff --git a/docs/TZ.md b/docs/TZ.md new file mode 100644 index 0000000..bbf38de --- /dev/null +++ b/docs/TZ.md @@ -0,0 +1,9 @@ +# Compton Technical Specification + +The canonical technical specification is maintained in project planning artifacts and reflected in the implementation constraints in this repository. + +This project intentionally follows: +- Modular monolith backend +- FSD-like frontend layers +- Mandatory test gates (unit/integration/component/e2e) +- Security-first auth/token handling diff --git a/docs/release-regression-checklist.md b/docs/release-regression-checklist.md new file mode 100644 index 0000000..4022e48 --- /dev/null +++ b/docs/release-regression-checklist.md @@ -0,0 +1,14 @@ +# MVP Regression Checklist + +This checklist mirrors the required release scenarios. + +1. Landing hero/marquee and reduced-motion behavior. +2. Register -> verify -> login -> profile edit -> logout. +3. Forgot password -> reset -> login. +4. Admin publish content -> public slug availability. +5. Admin blocks user -> blocked user login denied. +6. Pending user cannot access `/profile`. +7. Refresh token rotation and old token rejection. +8. IDOR check: user A cannot access user B. +9. Admin cannot demote/block self; last admin protected. +10. Avatar upload rejects invalid MIME/oversize/SVG. diff --git a/docs/secrets-recovery.md b/docs/secrets-recovery.md new file mode 100644 index 0000000..60a90aa --- /dev/null +++ b/docs/secrets-recovery.md @@ -0,0 +1,30 @@ +# Install Secrets Recovery + +This project keeps runtime installation secrets in `apps/api/data/secrets/install.env`. + +## Important + +- Do not rotate `POSTGRES_PASSWORD`, `JWT_ACCESS_SECRET`, or `JWT_REFRESH_PEPPER` automatically after first bootstrap. +- A mismatch between `install.env` and initialized Postgres volume can break database access. + +## Safe recovery steps + +1. Stop services: + - `docker compose down` +2. Restore `apps/api/data/secrets/install.env` from backup. +3. Start services: + - `docker compose up -d --build` + +If backup is unavailable, you have two options: + +- Preferred: recover credentials directly from running database/admin secret reveal in another environment. +- Last resort: reset local volumes and lose local dev data: + - `docker compose down -v` + - `python apps/api/scripts/bootstrap_install.py` + - `docker compose up -d --build` + +## Dev access ports + +To expose DB/Redis/MinIO to host tools: + +- `docker compose -f docker-compose.yml -f docker-compose.dev-ports.yml up -d` diff --git a/docs/security-checklist.md b/docs/security-checklist.md new file mode 100644 index 0000000..6edf7e4 --- /dev/null +++ b/docs/security-checklist.md @@ -0,0 +1,20 @@ +# Security Checklist (MVP Pre-Production) + +- [x] Access JWT is memory-only in frontend state (no sessionStorage/localStorage persistence). +- [x] Refresh token is HttpOnly/Secure/SameSite cookie on `/api/v1/auth` path. +- [x] Auth endpoints implemented with neutral anti-enumeration messaging. +- [x] Origin/Referer validation is enforced for auth endpoints. +- [x] Authenticated/admin route guards and role checks are enforced, including `SUPERUSER_ONLY` checks for critical endpoints. +- [x] Content sanitization is enabled for CMS HTML body. +- [x] Security headers configured in `infra/nginx/default.conf`. +- [x] CI includes dependency audit, Bandit, and gitleaks scans. +- [x] Settings runtime supports `data/compton_settings.json` with env lock behavior. +- [x] Admin audit feed is persisted to `data/logs/admin-audit.jsonl`. +- [x] Install secrets bootstrap is enabled (`apps/api/data/secrets/install.env`) and locked after first run. +- [x] Database, Redis and MinIO are internal by default in base docker compose. +- [x] `refresh` validates user status and rate limit is checked before token rotation. +- [x] CMS sanitization enforces allowed URL protocols (`http`, `https`, `mailto`). +- [x] Admin password create/reset uses shared password policy validators. +- [ ] Production docs endpoint switch (`ENABLE_DOCS=false`) validated in staging/prod env. +- [ ] HSTS behavior validated behind TLS ingress in staging/prod. +- [ ] Recovery runbook for lost `install.env` tested (`docs/secrets-recovery.md`). diff --git a/infra/docker/README.md b/infra/docker/README.md new file mode 100644 index 0000000..4110c8e --- /dev/null +++ b/infra/docker/README.md @@ -0,0 +1,4 @@ +# Docker notes + +`docker-compose.yml` is for local development. +`docker-compose.test.yml` is for CI-like integration and e2e testing. diff --git a/infra/docker/docker-compose.staging.yml b/infra/docker/docker-compose.staging.yml new file mode 100644 index 0000000..8566b23 --- /dev/null +++ b/infra/docker/docker-compose.staging.yml @@ -0,0 +1,9 @@ +services: + web: + image: compton/web:staging + api: + image: compton/api:staging + nginx: + image: nginx:stable-alpine + volumes: + - ../nginx/default.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/infra/k6/mvp-load-test.js b/infra/k6/mvp-load-test.js new file mode 100644 index 0000000..b027bbb --- /dev/null +++ b/infra/k6/mvp-load-test.js @@ -0,0 +1,20 @@ +import http from "k6/http"; +import { check, sleep } from "k6"; + +export const options = { + stages: [ + { duration: "1m", target: 10 }, + { duration: "3m", target: 50 }, + { duration: "1m", target: 0 } + ], + thresholds: { + http_req_duration: ["p(95)<300"], + http_req_failed: ["rate<0.01"] + } +}; + +export default function () { + const contentList = http.get("http://localhost:8000/api/v1/content/pages"); + check(contentList, { "content list is 200": (r) => r.status === 200 }); + sleep(1); +} diff --git a/infra/nginx/default.conf b/infra/nginx/default.conf new file mode 100644 index 0000000..f95dfef --- /dev/null +++ b/infra/nginx/default.conf @@ -0,0 +1,18 @@ +server { + listen 80; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always; + + location /api/ { + proxy_pass http://api:8000; + } + + location / { + proxy_pass http://web:5173; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..76876b6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "compton", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "compton", + "version": "1.0.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0afae29 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "compton", + "private": true, + "version": "1.0.0", + "packageManager": "pnpm@9.15.9", + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + }, + "scripts": { + "lint": "pnpm -r --if-present lint", + "typecheck": "pnpm -r --if-present typecheck", + "typecheck:all": "pnpm typecheck && cd apps/api && python -m mypy app", + "test:ci": "pnpm --filter web test:ci && cd apps/api && python -m pytest --cov=app --cov-fail-under=90", + "check:all": "pnpm lint && pnpm typecheck:all && pnpm --filter web test:ci && cd apps/api && python -m pytest --cov=app --cov-fail-under=90 && pnpm --filter web e2e", + "e2e": "pnpm --filter web e2e", + "openapi:types": "python apps/api/scripts/export_openapi.py && pnpm --filter @compton/shared-types generate" + } +} diff --git a/packages/eslint-config/index.js b/packages/eslint-config/index.js new file mode 100644 index 0000000..cadf14e --- /dev/null +++ b/packages/eslint-config/index.js @@ -0,0 +1,5 @@ +module.exports = { + rules: { + "no-console": "warn" + } +}; diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json new file mode 100644 index 0000000..1447c54 --- /dev/null +++ b/packages/eslint-config/package.json @@ -0,0 +1,6 @@ +{ + "name": "@compton/eslint-config", + "version": "1.0.0", + "private": true, + "main": "index.js" +} diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json new file mode 100644 index 0000000..498066c --- /dev/null +++ b/packages/shared-types/package.json @@ -0,0 +1,8 @@ +{ + "name": "@compton/shared-types", + "version": "1.0.0", + "private": true, + "scripts": { + "generate": "node scripts/generate.mjs" + } +} diff --git a/packages/shared-types/scripts/generate.mjs b/packages/shared-types/scripts/generate.mjs new file mode 100644 index 0000000..0af4504 --- /dev/null +++ b/packages/shared-types/scripts/generate.mjs @@ -0,0 +1,19 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const input = resolve(process.cwd(), "../../apps/api/openapi.json"); +const outputDir = resolve(process.cwd(), "src"); +mkdirSync(outputDir, { recursive: true }); + +let raw = "{}"; +try { + raw = readFileSync(input, "utf8"); +} catch { + raw = "{}"; +} + +writeFileSync( + resolve(outputDir, "index.ts"), + `export const openApiSchema = ${raw} as const;\n`, + "utf8" +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..87b7a9a --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4546 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + apps/web: + dependencies: + '@ant-design/icons': + specifier: ^6.3.2 + version: 6.3.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@hookform/resolvers': + specifier: ^3.9.1 + version: 3.10.0(react-hook-form@7.81.0(react@19.2.7)) + '@tanstack/react-query': + specifier: ^5.59.0 + version: 5.101.2(react@19.2.7) + antd: + specifier: ^5.29.3 + version: 5.29.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + axios: + specifier: ^1.7.7 + version: 1.18.1 + dompurify: + specifier: ^3.2.2 + version: 3.4.11 + react: + specifier: ^19.0.0 + version: 19.2.7 + react-dom: + specifier: ^19.0.0 + version: 19.2.7(react@19.2.7) + react-hook-form: + specifier: ^7.53.0 + version: 7.81.0(react@19.2.7) + react-router-dom: + specifier: ^7.0.0 + version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + zod: + specifier: ^3.23.8 + version: 3.25.76 + zustand: + specifier: ^5.0.0 + version: 5.0.14(@types/react@19.2.17)(react@19.2.7) + devDependencies: + '@playwright/test': + specifier: ^1.48.0 + version: 1.61.1 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.0.1 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/dompurify': + specifier: ^3.2.0 + version: 3.2.0 + '@types/node': + specifier: ^22.8.6 + version: 22.20.1 + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.17) + '@typescript-eslint/eslint-plugin': + specifier: ^8.10.0 + version: 8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.10.0 + version: 8.63.0(eslint@9.39.5)(typescript@5.9.3) + '@vitejs/plugin-react': + specifier: ^4.3.2 + version: 4.7.0(vite@5.4.21(@types/node@22.20.1)) + '@vitest/coverage-v8': + specifier: ^2.1.9 + version: 2.1.9(vitest@2.1.9(@types/node@22.20.1)(jsdom@25.0.1)) + eslint: + specifier: ^9.12.0 + version: 9.39.5 + eslint-plugin-boundaries: + specifier: ^4.2.0 + version: 4.2.2(@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5) + eslint-plugin-react-hooks: + specifier: ^5.1.0 + version: 5.2.0(eslint@9.39.5) + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vite: + specifier: ^5.4.21 + version: 5.4.21(@types/node@22.20.1) + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.20.1)(jsdom@25.0.1) + + packages/eslint-config: {} + + packages/shared-types: {} + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@ant-design/colors@7.2.1': + resolution: {integrity: sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==} + + '@ant-design/colors@8.0.1': + resolution: {integrity: sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==} + + '@ant-design/cssinjs-utils@1.1.3': + resolution: {integrity: sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@ant-design/cssinjs@1.24.0': + resolution: {integrity: sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/fast-color@2.0.6': + resolution: {integrity: sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==} + engines: {node: '>=8.x'} + + '@ant-design/fast-color@3.0.1': + resolution: {integrity: sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==} + engines: {node: '>=8.x'} + + '@ant-design/icons-svg@4.5.0': + resolution: {integrity: sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==} + + '@ant-design/icons@5.6.1': + resolution: {integrity: sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==} + engines: {node: '>=8'} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/icons@6.3.2': + resolution: {integrity: sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==} + engines: {node: '>=8'} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/react-slick@1.1.2': + resolution: {integrity: sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==} + peerDependencies: + react: '>=16.9.0' + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@emotion/hash@0.8.0': + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + + '@emotion/unitless@0.7.5': + resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@hookform/resolvers@3.10.0': + resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} + peerDependencies: + react-hook-form: ^7.0.0 + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + + '@rc-component/async-validator@5.1.2': + resolution: {integrity: sha512-WYbrZSjzznU1ekD0qFq2qRxt309VoS61MTG5npnFQlKYcoy9IzU8T+ZCIhq5bGAXRbXysABFWTspicMfmWFwow==} + engines: {node: '>=14.x'} + + '@rc-component/color-picker@2.0.1': + resolution: {integrity: sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/context@1.4.0': + resolution: {integrity: sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/mini-decimal@1.1.4': + resolution: {integrity: sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==} + engines: {node: '>=8.x'} + + '@rc-component/mutate-observer@1.1.0': + resolution: {integrity: sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/portal@1.1.2': + resolution: {integrity: sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/qrcode@1.1.3': + resolution: {integrity: sha512-aGv6alnn4HbDEsURzKP+jv13rbi1VxmAYfBNZr5GKF1iohMNWy5tAVoJ1E3cOvzMB1kbUPvCXchM6zSFlRGPhA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/tour@1.15.1': + resolution: {integrity: sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/trigger@2.3.1': + resolution: {integrity: sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/util@1.12.0': + resolution: {integrity: sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@tanstack/query-core@5.101.2': + resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + + '@tanstack/react-query@5.101.2': + resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + peerDependencies: + react: ^18 || ^19 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/dompurify@3.2.0': + resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} + deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@typescript-eslint/eslint-plugin@8.63.0': + resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.63.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.63.0': + resolution: {integrity: sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.63.0': + resolution: {integrity: sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.63.0': + resolution: {integrity: sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.63.0': + resolution: {integrity: sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.63.0': + resolution: {integrity: sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.63.0': + resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.63.0': + resolution: {integrity: sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.63.0': + resolution: {integrity: sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.63.0': + resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/coverage-v8@2.1.9': + resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} + peerDependencies: + '@vitest/browser': 2.1.9 + vitest: 2.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + antd@5.29.3: + resolution: {integrity: sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.42: + resolution: {integrity: sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.5: + resolution: {integrity: sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001803: + resolution: {integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + copy-to-clipboard@3.3.3: + resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + electron-to-chromium@1.5.389: + resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-module-utils@2.8.1: + resolution: {integrity: sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-boundaries@4.2.2: + resolution: {integrity: sha512-cjwpZqkCXgfz953bc74uDetOtGVxwgMgNZ7hAKi6Oxck+x4oY6Z/9DzgPqAYhtQdSNHFVg+vhft/lSL+snPMQg==} + engines: {node: '>=14.0.0'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-mobile@5.0.0: + resolution: {integrity: sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json2mq@0.2.0: + resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + micromatch@4.0.7: + resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + rc-cascader@3.34.0: + resolution: {integrity: sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-checkbox@3.5.0: + resolution: {integrity: sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-collapse@3.9.0: + resolution: {integrity: sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-dialog@9.6.0: + resolution: {integrity: sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-drawer@7.3.0: + resolution: {integrity: sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-dropdown@4.2.1: + resolution: {integrity: sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==} + peerDependencies: + react: '>=16.11.0' + react-dom: '>=16.11.0' + + rc-field-form@2.7.1: + resolution: {integrity: sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-image@7.12.0: + resolution: {integrity: sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-input-number@9.5.0: + resolution: {integrity: sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-input@1.8.0: + resolution: {integrity: sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-mentions@2.20.0: + resolution: {integrity: sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-menu@9.16.1: + resolution: {integrity: sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-motion@2.9.5: + resolution: {integrity: sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-notification@5.6.4: + resolution: {integrity: sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-overflow@1.5.0: + resolution: {integrity: sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-pagination@5.1.0: + resolution: {integrity: sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-picker@4.11.3: + resolution: {integrity: sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==} + engines: {node: '>=8.x'} + peerDependencies: + date-fns: '>= 2.x' + dayjs: '>= 1.x' + luxon: '>= 3.x' + moment: '>= 2.x' + react: '>=16.9.0' + react-dom: '>=16.9.0' + peerDependenciesMeta: + date-fns: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + + rc-progress@4.0.0: + resolution: {integrity: sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-rate@2.13.1: + resolution: {integrity: sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-resize-observer@1.4.3: + resolution: {integrity: sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-segmented@2.7.1: + resolution: {integrity: sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-select@14.16.8: + resolution: {integrity: sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '*' + react-dom: '*' + + rc-slider@11.1.9: + resolution: {integrity: sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-steps@6.0.1: + resolution: {integrity: sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-switch@4.1.0: + resolution: {integrity: sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-table@7.54.0: + resolution: {integrity: sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tabs@15.7.0: + resolution: {integrity: sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-textarea@1.10.2: + resolution: {integrity: sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tooltip@6.4.0: + resolution: {integrity: sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tree-select@5.27.0: + resolution: {integrity: sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==} + peerDependencies: + react: '*' + react-dom: '*' + + rc-tree@5.13.1: + resolution: {integrity: sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==} + engines: {node: '>=10.x'} + peerDependencies: + react: '*' + react-dom: '*' + + rc-upload@4.11.0: + resolution: {integrity: sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-util@5.44.4: + resolution: {integrity: sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-virtual-list@3.19.2: + resolution: {integrity: sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-hook-form@7.81.0: + resolution: {integrity: sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.7: + resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-convert@0.2.1: + resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toggle-selection@1.0.6: + resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@ant-design/colors@7.2.1': + dependencies: + '@ant-design/fast-color': 2.0.6 + + '@ant-design/colors@8.0.1': + dependencies: + '@ant-design/fast-color': 3.0.1 + + '@ant-design/cssinjs-utils@1.1.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@ant-design/cssinjs': 1.24.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@babel/runtime': 7.29.7 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@ant-design/cssinjs@1.24.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@emotion/hash': 0.8.0 + '@emotion/unitless': 0.7.5 + classnames: 2.5.1 + csstype: 3.2.3 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + stylis: 4.4.0 + + '@ant-design/fast-color@2.0.6': + dependencies: + '@babel/runtime': 7.29.7 + + '@ant-design/fast-color@3.0.1': {} + + '@ant-design/icons-svg@4.5.0': {} + + '@ant-design/icons@5.6.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@ant-design/colors': 7.2.1 + '@ant-design/icons-svg': 4.5.0 + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@ant-design/icons@6.3.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@ant-design/colors': 8.0.1 + '@ant-design/icons-svg': 4.5.0 + '@rc-component/util': 1.12.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + clsx: 2.1.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@ant-design/react-slick@1.1.2(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + json2mq: 0.2.0 + react: 19.2.7 + resize-observer-polyfill: 1.5.1 + throttle-debounce: 5.0.2 + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.5 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@0.2.3': {} + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@emotion/hash@0.8.0': {} + + '@emotion/unitless@0.7.5': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5)': + dependencies: + eslint: 9.39.5 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@hookform/resolvers@3.10.0(react-hook-form@7.81.0(react@19.2.7))': + dependencies: + react-hook-form: 7.81.0(react@19.2.7) + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + + '@rc-component/async-validator@5.1.2': + dependencies: + '@babel/runtime': 7.29.7 + + '@rc-component/color-picker@2.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@ant-design/fast-color': 2.0.6 + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@rc-component/context@1.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@rc-component/mini-decimal@1.1.4': + dependencies: + '@babel/runtime': 7.29.7 + + '@rc-component/mutate-observer@1.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@rc-component/portal@1.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@rc-component/qrcode@1.1.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@rc-component/tour@1.15.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@rc-component/trigger@2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@rc-component/util@1.12.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + is-mobile: 5.0.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-is: 19.2.7 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@tanstack/query-core@5.101.2': {} + + '@tanstack/react-query@5.101.2(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.2 + react: 19.2.7 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/dompurify@3.2.0': + dependencies: + dompurify: 3.4.11 + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/trusted-types@2.0.7': + optional: true + + '@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/type-utils': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.63.0 + eslint: 9.39.5 + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.63.0 + debug: 4.4.3 + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.63.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3) + '@typescript-eslint/types': 8.63.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.63.0': + dependencies: + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 + + '@typescript-eslint/tsconfig-utils@8.63.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.63.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.5 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.63.0': {} + + '@typescript-eslint/typescript-estree@8.63.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.63.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3) + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.63.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.63.0': + dependencies: + '@typescript-eslint/types': 8.63.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.1))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21(@types/node@22.20.1) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@22.20.1)(jsdom@25.0.1))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@22.20.1)(jsdom@25.0.1) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.20.1))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.20.1) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + antd@5.29.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@ant-design/colors': 7.2.1 + '@ant-design/cssinjs': 1.24.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@ant-design/cssinjs-utils': 1.1.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@ant-design/fast-color': 2.0.6 + '@ant-design/icons': 5.6.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@ant-design/react-slick': 1.1.2(react@19.2.7) + '@babel/runtime': 7.29.7 + '@rc-component/color-picker': 2.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rc-component/mutate-observer': 1.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rc-component/qrcode': 1.1.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rc-component/tour': 1.15.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + copy-to-clipboard: 3.3.3 + dayjs: 1.11.21 + rc-cascader: 3.34.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-checkbox: 3.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-collapse: 3.9.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-dialog: 9.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-drawer: 7.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-dropdown: 4.2.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-field-form: 2.7.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-image: 7.12.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-input: 1.8.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-input-number: 9.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-mentions: 2.20.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-menu: 9.16.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-notification: 5.6.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-pagination: 5.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-picker: 4.11.3(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-progress: 4.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-rate: 2.13.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-segmented: 2.7.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-select: 14.16.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-slider: 11.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-steps: 6.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-switch: 4.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-table: 7.54.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-tabs: 15.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-textarea: 1.10.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-tooltip: 6.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-tree: 5.13.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-tree-select: 5.27.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-upload: 4.11.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + scroll-into-view-if-needed: 3.1.0 + throttle-debounce: 5.0.2 + transitivePeerDependencies: + - date-fns + - luxon + - moment + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.42: {} + + brace-expansion@1.1.16: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.5: + dependencies: + baseline-browser-mapping: 2.10.42 + caniuse-lite: 1.0.30001803 + electron-to-chromium: 1.5.389 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.5) + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001803: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + classnames@2.5.1: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + compute-scroll-into-view@3.1.1: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + copy-to-clipboard@3.3.3: + dependencies: + toggle-selection: 1.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + dayjs@1.11.21: {} + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dompurify@3.4.11: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + electron-to-chromium@1.5.389: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.2 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.8.1(@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + eslint: 9.39.5 + eslint-import-resolver-node: 0.3.9 + transitivePeerDependencies: + - supports-color + + eslint-plugin-boundaries@4.2.2(@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5): + dependencies: + chalk: 4.1.2 + eslint: 9.39.5 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.8.1(@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5) + micromatch: 4.0.7 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.5): + dependencies: + eslint: 9.39.5 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + follow-redirects@1.16.0: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + globals@14.0.0: {} + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + html-escaper@2.0.2: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-mobile@5.0.0: {} + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-tokens@4.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json2mq@0.2.0: + dependencies: + string-convert: 0.2.1 + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + math-intrinsics@1.1.0: {} + + micromatch@4.0.7: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.16 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + + minipass@7.1.3: {} + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.51: {} + + nwsapi@2.2.24: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + rc-cascader@3.34.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-select: 14.16.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-tree: 5.13.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-checkbox@3.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-collapse@3.9.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-dialog@9.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-drawer@7.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-dropdown@4.2.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-field-form@2.7.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/async-validator': 5.1.2 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-image@7.12.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-dialog: 9.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-input-number@9.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/mini-decimal': 1.1.4 + classnames: 2.5.1 + rc-input: 1.8.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-input@1.8.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-mentions@2.20.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-input: 1.8.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-menu: 9.16.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-textarea: 1.10.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-menu@9.16.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-overflow: 1.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-motion@2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-notification@5.6.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-overflow@1.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-pagination@5.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-picker@4.11.3(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-overflow: 1.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + dayjs: 1.11.21 + + rc-progress@4.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-rate@2.13.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-resize-observer@1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + resize-observer-polyfill: 1.5.1 + + rc-segmented@2.7.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-select@14.16.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-overflow: 1.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-virtual-list: 3.19.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-slider@11.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-steps@6.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-switch@4.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-table@7.54.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/context': 1.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-virtual-list: 3.19.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-tabs@15.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-dropdown: 4.2.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-menu: 9.16.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-textarea@1.10.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-input: 1.8.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-tooltip@6.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-tree-select@5.27.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-select: 14.16.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-tree: 5.13.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-tree@5.13.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-virtual-list: 3.19.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-upload@4.11.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-util@5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-is: 18.3.1 + + rc-virtual-list@3.19.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-hook-form@7.81.0(react@19.2.7): + dependencies: + react: 19.2.7 + + react-is@17.0.2: {} + + react-is@18.3.1: {} + + react-is@19.2.7: {} + + react-refresh@0.17.0: {} + + react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + react-router@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie: 1.1.1 + react: 19.2.7 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + + react@19.2.7: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + resize-observer-polyfill@1.5.1: {} + + resolve-from@4.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.1 + + semver@6.3.1: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-convert@0.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + stylis@4.4.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.5 + + throttle-debounce@5.0.2: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toggle-selection@1.0.6: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.5): + dependencies: + browserslist: 4.28.5 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-node@2.1.9(@types/node@22.20.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.20.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.20.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.16 + rollup: 4.62.2 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@22.20.1)(jsdom@25.0.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.20.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.20.1) + vite-node: 2.1.9(@types/node@22.20.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + ws@8.21.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} + + zustand@5.0.14(@types/react@19.2.17)(react@19.2.7): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*"